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