| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import type {Dag} from './dag/dag'; |
| 9 | import type {SmartlogCommits} from './types'; |
| 10 | |
| 11 | import {atom} from 'jotai'; |
| 12 | import {MutationDag} from './dag/mutation_dag'; |
| 13 | import {writeAtom} from './jotaiUtils'; |
| 14 | import {registerCleanup} from './utils'; |
| 15 | |
| 16 | type Successions = Array<[oldHash: string, newHash: string]>; |
| 17 | type SuccessionCallback = (successions: Successions) => unknown; |
| 18 | |
| 19 | /** |
| 20 | * When a commit is amended or rebased or otherwise modified, the old commit |
| 21 | * is marked obsolete and "succeeded" by a new commit. |
| 22 | * Some state in the UI is keyed by hash, so a succession event can cause the UI |
| 23 | * to show stale data. For example, if you select a commit and amend a commit earlier in the stack, |
| 24 | * your selection will now disappear. |
| 25 | * |
| 26 | * To avoid this, we keep track of commits being succeeded, and any recoil state keyed on hashes |
| 27 | * can listen to this event and update itself with the new oldHash -> newHash. |
| 28 | * |
| 29 | * Succession is tracked by looking at each new batch of commits we get, each of which may have |
| 30 | * a closestPredecessors field. Technically, it's probably possible that a commit is succeeded twice |
| 31 | * between results from `sl log`, which would cause us to miss a succession. We'll ignore this case for now, |
| 32 | * and assume it's rare. |
| 33 | * |
| 34 | * Note that successions could also be detected on the server by stdout or other means from sl, |
| 35 | * but by doing it on the client we know that all successions are dealt with at the exact moment the |
| 36 | * UI state gets a new list of commits, reducing any race between succession events and new commits rendering. |
| 37 | */ |
| 38 | export class SuccessionTracker { |
| 39 | private callbacks: Set<SuccessionCallback> = new Set(); |
| 40 | /** |
| 41 | * Run a callback when a succession is detected for the first time. |
| 42 | * Returns a dispose function. |
| 43 | */ |
| 44 | public onSuccessions(cb: SuccessionCallback): () => void { |
| 45 | this.callbacks.add(cb); |
| 46 | return () => { |
| 47 | this.callbacks.delete(cb); |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | private seenHashes = new Set<string>(); |
| 52 | /** |
| 53 | * Called once in the app each time a new batch of commits is fetched, |
| 54 | * in order to find successions and run callbacks on them. |
| 55 | */ |
| 56 | public findNewSuccessionsFromCommits(previousDag: Dag, commits: SmartlogCommits) { |
| 57 | const tracker = window.globalIslClientTracker; // avoid import cycle |
| 58 | const successions: Successions = []; |
| 59 | for (const commit of commits) { |
| 60 | if (commit.phase === 'public') { |
| 61 | continue; |
| 62 | } |
| 63 | |
| 64 | const {hash: newHash, closestPredecessors: oldHashes} = commit; |
| 65 | |
| 66 | // Commits we've seen before should have already had their successions computed, so they are skipped |
| 67 | |
| 68 | // Commits we've never seen before, who have predecessors we've never seen are just entirely new commits |
| 69 | // or from our first time fetching commits. Skip computing predecessors for these. |
| 70 | |
| 71 | // Commits we've *never* seen before, who have predecessors that we *have* seen before are actually successions. |
| 72 | if (oldHashes != null && !this.seenHashes.has(newHash)) { |
| 73 | for (const oldHash of oldHashes) { |
| 74 | if (this.seenHashes.has(oldHash)) { |
| 75 | // HACKY: When we see a succession, we want to persist data forward. |
| 76 | // However, we've seen a bug where commit messages get mixed up between commits. |
| 77 | // As a precaution, let's not consider commits that change their commit messages |
| 78 | // to have different attached diffs. |
| 79 | // There may be false positives from this, but they should be rare, |
| 80 | // and the cost of successions wrong is relatively small: |
| 81 | // commit messages and selection wouldn't be persisted correctly. |
| 82 | // TODO: use this for debugging, then find a proper fix or legitimize this. |
| 83 | const previousCommit = previousDag.get(oldHash); |
| 84 | if ( |
| 85 | previousCommit != null && |
| 86 | previousCommit.diffId && |
| 87 | previousCommit.diffId !== commit.diffId |
| 88 | ) { |
| 89 | tracker?.track('BuggySuccessionDetected', { |
| 90 | extras: { |
| 91 | oldHash, |
| 92 | newHash, |
| 93 | old: previousCommit.title + '\n' + previousCommit.description, |
| 94 | new: commit.title + '\n' + commit.description, |
| 95 | }, |
| 96 | }); |
| 97 | continue; |
| 98 | } |
| 99 | |
| 100 | successions.push([oldHash, newHash]); |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | this.seenHashes.add(newHash); |
| 106 | } |
| 107 | |
| 108 | if (successions.length > 0) { |
| 109 | tracker?.track('SuccessionsDetected', {extras: {successions}}); |
| 110 | for (const cb of this.callbacks) { |
| 111 | cb(successions); |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /** Clear all known hashes, useful for resetting between tests */ |
| 117 | public clear() { |
| 118 | this.seenHashes.clear(); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | export const successionTracker = new SuccessionTracker(); |
| 123 | |
| 124 | export const latestSuccessorsMapAtom = atom<MutationDag>(new MutationDag()); |
| 125 | |
| 126 | registerCleanup( |
| 127 | successionTracker, |
| 128 | successionTracker.onSuccessions(successions => { |
| 129 | writeAtom(latestSuccessorsMapAtom, dag => { |
| 130 | return dag.addMutations(successions); |
| 131 | }); |
| 132 | }), |
| 133 | import.meta.hot, |
| 134 | ); |
| 135 | |