5.1 KB135 lines
Blame
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
8import type {Dag} from './dag/dag';
9import type {SmartlogCommits} from './types';
10
11import {atom} from 'jotai';
12import {MutationDag} from './dag/mutation_dag';
13import {writeAtom} from './jotaiUtils';
14import {registerCleanup} from './utils';
15
16type Successions = Array<[oldHash: string, newHash: string]>;
17type 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 */
38export 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
122export const successionTracker = new SuccessionTracker();
123
124export const latestSuccessorsMapAtom = atom<MutationDag>(new MutationDag());
125
126registerCleanup(
127 successionTracker,
128 successionTracker.onSuccessions(successions => {
129 writeAtom(latestSuccessorsMapAtom, dag => {
130 return dag.addMutations(successions);
131 });
132 }),
133 import.meta.hot,
134);
135