addons/isl/src/SuccessionTracker.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Dag} from './dag/dag';
b69ab319import type {SmartlogCommits} from './types';
b69ab3110
b69ab3111import {atom} from 'jotai';
b69ab3112import {MutationDag} from './dag/mutation_dag';
b69ab3113import {writeAtom} from './jotaiUtils';
b69ab3114import {registerCleanup} from './utils';
b69ab3115
b69ab3116type Successions = Array<[oldHash: string, newHash: string]>;
b69ab3117type SuccessionCallback = (successions: Successions) => unknown;
b69ab3118
b69ab3119/**
b69ab3120 * When a commit is amended or rebased or otherwise modified, the old commit
b69ab3121 * is marked obsolete and "succeeded" by a new commit.
b69ab3122 * Some state in the UI is keyed by hash, so a succession event can cause the UI
b69ab3123 * to show stale data. For example, if you select a commit and amend a commit earlier in the stack,
b69ab3124 * your selection will now disappear.
b69ab3125 *
b69ab3126 * To avoid this, we keep track of commits being succeeded, and any recoil state keyed on hashes
b69ab3127 * can listen to this event and update itself with the new oldHash -> newHash.
b69ab3128 *
b69ab3129 * Succession is tracked by looking at each new batch of commits we get, each of which may have
b69ab3130 * a closestPredecessors field. Technically, it's probably possible that a commit is succeeded twice
b69ab3131 * between results from `sl log`, which would cause us to miss a succession. We'll ignore this case for now,
b69ab3132 * and assume it's rare.
b69ab3133 *
b69ab3134 * Note that successions could also be detected on the server by stdout or other means from sl,
b69ab3135 * but by doing it on the client we know that all successions are dealt with at the exact moment the
b69ab3136 * UI state gets a new list of commits, reducing any race between succession events and new commits rendering.
b69ab3137 */
b69ab3138export class SuccessionTracker {
b69ab3139 private callbacks: Set<SuccessionCallback> = new Set();
b69ab3140 /**
b69ab3141 * Run a callback when a succession is detected for the first time.
b69ab3142 * Returns a dispose function.
b69ab3143 */
b69ab3144 public onSuccessions(cb: SuccessionCallback): () => void {
b69ab3145 this.callbacks.add(cb);
b69ab3146 return () => {
b69ab3147 this.callbacks.delete(cb);
b69ab3148 };
b69ab3149 }
b69ab3150
b69ab3151 private seenHashes = new Set<string>();
b69ab3152 /**
b69ab3153 * Called once in the app each time a new batch of commits is fetched,
b69ab3154 * in order to find successions and run callbacks on them.
b69ab3155 */
b69ab3156 public findNewSuccessionsFromCommits(previousDag: Dag, commits: SmartlogCommits) {
b69ab3157 const tracker = window.globalIslClientTracker; // avoid import cycle
b69ab3158 const successions: Successions = [];
b69ab3159 for (const commit of commits) {
b69ab3160 if (commit.phase === 'public') {
b69ab3161 continue;
b69ab3162 }
b69ab3163
b69ab3164 const {hash: newHash, closestPredecessors: oldHashes} = commit;
b69ab3165
b69ab3166 // Commits we've seen before should have already had their successions computed, so they are skipped
b69ab3167
b69ab3168 // Commits we've never seen before, who have predecessors we've never seen are just entirely new commits
b69ab3169 // or from our first time fetching commits. Skip computing predecessors for these.
b69ab3170
b69ab3171 // Commits we've *never* seen before, who have predecessors that we *have* seen before are actually successions.
b69ab3172 if (oldHashes != null && !this.seenHashes.has(newHash)) {
b69ab3173 for (const oldHash of oldHashes) {
b69ab3174 if (this.seenHashes.has(oldHash)) {
b69ab3175 // HACKY: When we see a succession, we want to persist data forward.
b69ab3176 // However, we've seen a bug where commit messages get mixed up between commits.
b69ab3177 // As a precaution, let's not consider commits that change their commit messages
b69ab3178 // to have different attached diffs.
b69ab3179 // There may be false positives from this, but they should be rare,
b69ab3180 // and the cost of successions wrong is relatively small:
b69ab3181 // commit messages and selection wouldn't be persisted correctly.
b69ab3182 // TODO: use this for debugging, then find a proper fix or legitimize this.
b69ab3183 const previousCommit = previousDag.get(oldHash);
b69ab3184 if (
b69ab3185 previousCommit != null &&
b69ab3186 previousCommit.diffId &&
b69ab3187 previousCommit.diffId !== commit.diffId
b69ab3188 ) {
b69ab3189 tracker?.track('BuggySuccessionDetected', {
b69ab3190 extras: {
b69ab3191 oldHash,
b69ab3192 newHash,
b69ab3193 old: previousCommit.title + '\n' + previousCommit.description,
b69ab3194 new: commit.title + '\n' + commit.description,
b69ab3195 },
b69ab3196 });
b69ab3197 continue;
b69ab3198 }
b69ab3199
b69ab31100 successions.push([oldHash, newHash]);
b69ab31101 }
b69ab31102 }
b69ab31103 }
b69ab31104
b69ab31105 this.seenHashes.add(newHash);
b69ab31106 }
b69ab31107
b69ab31108 if (successions.length > 0) {
b69ab31109 tracker?.track('SuccessionsDetected', {extras: {successions}});
b69ab31110 for (const cb of this.callbacks) {
b69ab31111 cb(successions);
b69ab31112 }
b69ab31113 }
b69ab31114 }
b69ab31115
b69ab31116 /** Clear all known hashes, useful for resetting between tests */
b69ab31117 public clear() {
b69ab31118 this.seenHashes.clear();
b69ab31119 }
b69ab31120}
b69ab31121
b69ab31122export const successionTracker = new SuccessionTracker();
b69ab31123
b69ab31124export const latestSuccessorsMapAtom = atom<MutationDag>(new MutationDag());
b69ab31125
b69ab31126registerCleanup(
b69ab31127 successionTracker,
b69ab31128 successionTracker.onSuccessions(successions => {
b69ab31129 writeAtom(latestSuccessorsMapAtom, dag => {
b69ab31130 return dag.addMutations(successions);
b69ab31131 });
b69ab31132 }),
b69ab31133 import.meta.hot,
b69ab31134);