jj-dojo / Shared utilities

Shared utilities

The small helpers in src/utils/: a map and a set that compare keys by value, commit topology (ancestors, descendants, the working copy), a value with possible conflicts, the exhaustiveness checks, disposal of a list of disposables, and relative timestamps.

On this page
  1. 1. Files and who uses them
  2. 2. HashMap
  3. HashMapKey
  4. Methods
  5. 3. HashSet
  6. 4. commit_topology
  7. 5. check: exhaustiveness helpers
  8. 6. dispose
  9. 7. time: relative timestamps
  10. 8. Tests and build
  11. 9. Loose ends at this pin

In short: JavaScript's Map and Set compare object keys by identity, so two equal vscode.Uris are different keys. HashMap and HashSet key on toString() instead, and each has a read-only interface that the Commit type uses. commit_topology fills in every commit's ancestor and descendant sets and finds the working copy and its parent. Merge holds a value that may be conflicted, as jj does. dispose disposes everything in an array and empties it. check.ts and time.ts just re-export the commit graph's own copies, which give compile-time exhaustive switch checks and Mercurial-style "5 minutes ago" strings. check and time are the only files nothing imports at this pin.

1. Files and who uses them

FileExportsImported by
hashmap.tsHashMap, ReadonlyHashMap, HashMapKeyConflictTracker, keyed by document URI (tracker.ts, see merge-conflicts), SubprocessClient, hashset.ts and commit_topology.ts.
hashset.tsHashSet, ReadonlyHashSetapi.ts, for Commit.ancestors and descendants (api.ts), SubprocessClient, the fakeCommit test fake, and the not-yet-wired MultiSelectManager.
commit_topology.tsSix functions, see §4SubprocessClient.getCommits, which uses three of them (subprocess_client.ts).
merge.tsMergeapi.ts, for CommitFile.mergedFileMetadata (api.ts), which nothing fills in yet.
dispose.tsdisposeThe UI bundle's dispose() (ui.ts) and the graph view's teardown when its webview is disposed (commit_graph_provider.ts).
check.tsEverything in commit_graph/utils/check.tsNothing. The graph components import the original directly.
time.tsEverything in commit_graph/utils/time.tsNothing. jj-time-ago-text imports the original directly.

The re-exports exist because of Bazel visibility. //src/ui/commit_graph/utils is visible only to //src/utils and to the commit graph's own packages (BUILD.bazel). //src/utils is public and depends on it (BUILD.bazel, target), so it is the route for any other code to reach those helpers.

2. HashMap

A thin wrapper over Map<string, [Key, Value]>. Every operation calls key.toString() and uses the result as the real key. The original key object is stored next to the value so keys() and entries() can hand it back (source). Two keys are "equal" exactly when their strings are.

HashMapKey

hashmap.ts, lines 150–155

MemberTypeMeaningSource
toString() => stringThe identity of the key. It must be stable while the key is in the map, and different for keys that should differ.L154

Methods

hashmap.ts, lines 42–112

MemberTypeMeaningSource
constructor(iterable?: Iterable<[Key, Value]>)Calls set for each pair. A later duplicate wins.L48–54
set(key, value) => voidStores the pair. A replaced entry keeps its position in iteration order, but its stored key object becomes the new one.L56–58
has, get, delete(key) => …As on Map, by string.L60–74
keys, values() => IterableIteratorWrap the inner values() iterator and project the key or the value.L76–82
entries() => IterableIterator<[Key, Value]>The stored tuples themselves, not copies.L84–86
[Symbol.iterator]() => IterableIterator<[Key, Value]>Returns entries(), so the map works directly in for … of and can be passed to another map's constructor.L88–90
clear() => voidEmpties the map.L92–94
size() => numberA method, unlike Map.size.L96–98
putIfAbsent(key, value) => ValueReturns the existing value if the key is present. Otherwise stores and returns the new one.L104–111

ReadonlyHashMap is the same surface minus set, delete, clear and putIfAbsent (source). HashMap implements it, so a function can take a map it promises not to change, as calculateAncestors does.

The two private iterator classes implement both next() and [Symbol.iterator] over the same inner iterator (source). for … of and spreading go through the generator. Calling next() by hand goes through the method instead.

3. HashSet

A HashMap<Key, void> with set-shaped methods: add, has, delete, clear, size(), keys(), and [Symbol.iterator], so it can be used directly in for … of. The constructor adds each element of an optional iterable (source). It has no values, entries or forEach.

ReadonlyHashSet keeps only [Symbol.iterator], keys, has and size (source). The public Commit type exposes ancestors and descendants through it, and MutableCommit maps each such field back to a writable HashSet so the client can fill them in (api.ts).

4. commit_topology

Functions over a list of Commits (source). Upstream says it was copied from Google's internal code with few changes, and added the ancestors and descendants fields after it. Only the two calculate… functions and getCommitIdToCommitMap are called, from the end of SubprocessClient.getCommits; the other three are used only by their tests or not at all.

FunctionWhat it doesSource
getWorkingCopyCommit(commits)The commit with active set. Throws, through logAndShowInternalError, if there is none.L22–32
getOnlyWorkingCopyParentCommit(commits, wc)The working copy's parent when it has exactly one and that parent is in the list. Throws if it has no parents; returns undefined for a merge.L34–53
getCommitIdToCommitMap(commits)A HashMap from commit ID to commit.L55–66
calculateAncestors(commitMap)Fills each commit's ancestors: itself, plus the ancestors of every parent. See below.L68–115
calculateDescendants(commitMap)The mirror image over childIds: itself, plus the descendants of every child.L117–165
getChangeIdToCommitsMap(commits)A HashMap from change ID to every commit with it, in list order. More than one entry means the change is divergent.L167–183
getEvolvedCommit(commits, original)Follows a remembered commit across a refresh. Still present by commit ID: itself. Otherwise the one commit with its change ID. Gone, or divergent: undefined, and the comment says the caller then falls back to the working copy's parent.L185–219

Both calculate… functions are an iterative post-order depth-first search, so a long history can't overflow the call stack. For each commit they keep an explicit stack. A commit whose set is already non-empty is finished and is popped. Otherwise every parent that is in the map and not yet finished is pushed, and the commit is revisited later. Once none are pushed, the commit's set becomes itself plus the union of its parents' sets, so each set is computed once and reused, whatever order the map iterates in. A parent ID that isn't in the map, such as a public base outside the revset, is added as-is but not followed. A non-empty set doubles as the "visited" mark, which works because every finished set contains at least the commit itself.

5. check: exhaustiveness helpers

Re-exported unchanged from the commit graph (source). Each helper takes a value whose type should already have been narrowed to never (or to an explicitly allowed type). Adding a new enum or union member then turns every unhandled switch into a compile error.

MemberTypeMeaningSource
checkExhaustive(value: never, msg?) => neverCompile-time check that always throws at runtime, with unexpected value …! by default. Used in the graph's row title, lines and drag-and-drop code.L63–65
checkExhaustiveAllowing<Allowed>(value, msg?) => neverThe same, but values of Allowed also type-check, for enum members that "can't happen".L97–102
LegacyIncorrectUsageunknownPlaceholder type argument for old call sites. Unused here.L115
assumeExhaustive, assumeExhaustiveAllowing(value) => voidCompile-time check only. A no-op at runtime, for switches that fall back to a default instead of failing.L140–168

The file's comments point to an internal "go/guards-and-assertions" library. It was carried over from Google's internal TypeScript tooling and has no public counterpart here.

6. dispose

dispose(disposables) calls dispose() on each element in order, then sets the array's length to 0 so the same array can be reused or disposed again harmlessly (source). If one dispose() throws, the loop stops: the remaining elements aren't disposed and the array isn't emptied.

7. time: relative timestamps

age(unixSeconds) deliberately mirrors Mercurial's age template filter, so the graph reads like hg xl (source). The graph webview shows it through jj-time-ago-text (graph-webview).

  1. The exact same second is now.
  2. More than 30 years in the future is in the distant future. More than two years in the past is Date.toDateString(), for example Tue Mar 03 2020.
  3. Otherwise it picks the largest unit that fits at least twice: years of 365 days, months of 30 days, weeks, days, hours, minutes, seconds (table). So 90 seconds is 90 seconds ago, and 13 days is 13 days ago, not 1 week ago. A future date gets … from now.

8. Tests and build

TestChecksSource
HashMap finds the valueA different instance with the same string finds and overwrites the entry.28–37
HashMap iterates correctlykeys, values and entries each yield the three entries.39–83
HashMap [Symbol.iterator] works correctlyfor … of over the map yields the three pairs.85–112
HashMap putIfAbsent works correctlyThe second call returns the first value.114–118
HashSet finds elements correctlyMembership by string.30–34
HashSet iterates correctlyfor … of yields three keys.36–50
getOnlyWorkingCopyParentCommit (3 tests)Parent missing from the list, one parent, a merge.32–76
getEvolvedCommit (2 tests)Unchanged commit; a rewritten commit found by change ID.78–105
calculateAncestors (7 tests)A root, a chain, a diamond with no duplicates, a parent outside the map, no leakage to children or sibling branches, reuse of a parent's set, and a deep chain without stack overflow.107–273
calculateDescendants (7 tests)The same seven cases, mirrored over children.275–441
Merge fromResolved, fromAlternatingAddsAndRemoves, fromAddsAndRemoves (8 tests)A merge always has one more add than removes, the two list constructors throw otherwise, and fromAddsAndRemoves copies its input arrays.21–110
Merge simplify (7 tests)simplify cancels each add that equals a remove, in place, by a caller-supplied equality, so a multi-way conflict whose removes all cancel becomes resolved.112–185

time.ts has its tests next to the original in src/ui/commit_graph/utils/. dispose, check, getWorkingCopyCommit, getCommitIdToCommitMap and getChangeIdToCommitsMap have none of their own. The topology tests build commits with the fakeCommit fake from //src/testing.

hashmap, hashset, merge and commit_topology are now their own ts_library targets, "to avoid circular dependencies with api.ts": api.ts needs HashSet and Merge, and commit_topology needs //src/api (BUILD.bazel). The utils target globs the rest of *.ts, excluding those four, and the ts_library macro drops the *_test.ts files from it, so they compile only into the tests target (BUILD.bazel).

9. Loose ends at this pin

  • Unused exports. The check and time re-exports have no importers, and getWorkingCopyCommit, getOnlyWorkingCopyParentCommit, getChangeIdToCommitsMap and getEvolvedCommit have no callers outside tests.
  • toString is not checked. Every object has one, so the type system accepts a key whose toString is the default [object Object]. All such keys would collapse into one entry. vscode.Uri, the only key type used, has a real one.
  • ValueIterator.next() at the end. It reads next.value[1] without the ?. that KeyIterator has, so calling next() by hand past the last value throws a TypeError (source). for … of isn't affected.
  • Shared tuples. entries() yields the stored [key, value] arrays, so a caller that mutates one changes the map without updating its string key.
  • HashSet's iterator comment still calls it "the ImmutableSet" (source).