jj-dojo / Externalization plan

Externalization plan

Jujutsu Dojo started as a Google-internal extension. This page lays out Google's plan for making it work outside Google, layer by layer and milestone by milestone, and checks each item against the code at the pinned commit.

On this page
  1. 1. The three layers
  2. UI layer
  3. File system providers
  4. API layer
  5. Client layer
  6. 2. Milestones
  7. 3. The subprocess client so far
  8. 4. Interfaces without implementations
  9. 5. The file-system watcher
  10. The interface
  11. LocalFileSystemWatcher
  12. 6. Design considerations
  13. 7. Loose ends at this pin

In short: docs/intro.md splits the extension into a UI layer, an API layer and a client layer. Most of the UI layer can be shared with the internal build. The API and client layers need external implementations, built on the jj CLI where Google uses gRPC-web servers. At this pin, the commit graph, the conflict highlighter and the first file system provider have landed. activate now builds a SubprocessClient and wraps it in an ApiImpl, whose one method reads files for the jj-commits provider; the graph still gets fake data. Other lower-layer pieces are in the tree but nothing calls them yet: a @parcel/watcher-based FileSystemWatcher, an OptimisticStateEstimator that takes the place of the plan's PendingSnapshotsManager, and bare interfaces for an op-head watcher, a network-reconnect watcher and telemetry. Everything else is planned. The M1–M4 labels on this page come from the four-milestone timeline intro.md carried until this pin; it now points to a GitHub project board instead (§2).

1. The three layers

The plan's architecture drawing (architecture.svg) colours each component green if it will be shared between the internal and external builds, and red if it needs a separate external implementation (source). The diagram below redraws those layers with their status at this pin. Solid boxes have code in the tree. Dashed boxes are only planned, or have code that nothing uses yet. Each box links to the page that covers it, or to the plan.

Externalization status by layer Three rows. The UI layer has seven components. The commit graph, the conflict highlighter, package.json and the commands are in the tree, though the only commands so far are the two merge-conflict ones. The file system providers have landed as a jj-commits provider. The split editor and the commit dialog are planned. The API layer has seven components. The FileSystemWatcher and the OptimisticStateEstimator, which stands in for PendingSnapshotsManager, are in the tree but not wired in. JJ PubSub has an interface only. ApiStateManager, WorkspaceStateManager, ActionsQueue and the Forge Manager are planned. The client layer has the jj subprocess client, in progress: activate builds it and the jj-commits provider reads files through it, but the graph doesn't use it. A daemon client is a possibility for later. Below the client layer is the jj CLI. UI layer: mostly shared commit graph done (M1) conflicts highlighter package.json not shared commands 2 of them so far split editor planned commit dialog planned (M3) FS providers jj-commits API layer: separate implementations FileSystem Watcher (M2) ApiState Manager WorkspaceState Manager OptimisticState Estimator (M2) ActionsQueue M3 JJ PubSub interface (M2) Forge Manager M4 Client layer: jj CLI externally jj subprocess client in progress (M1): file reads wired, graph not daemon client long-term possibility jj CLI with templates, run against the repository

UI layer

The UI layer is "mostly reusable code" (source).

ComponentThe plan saysAt this pin
VS Code commandsMostly shared. Forge commands such as upload, sync and submit are not.Two commands, both for merge conflicts: jj.mergeConflict.acceptAllSides and acceptOneSide (activation). Upstreaming the "Open Diff" commands is part of M1.
Commit graphShared.Landed, and marked Done in M1. The protocol, layout and webview pages cover it. The host still feeds it fake data: the client is constructed, but only file reads go through it.
Split editorShared.planned Not in the tree.
Commit dialog"Very hard to externalize due to its strong internal ties."planned M3 needs a commit dialog webview for jj describe and jj commit.
File system providersShared.Landed. JjUiBase registers a read-only CommitIdFileSystemProvider for the jj-commits scheme (source), though nothing opens such a URI yet. See below.
Conflict marker highlighterShared.Landed. See merge-conflict support.
package.json"Hard to share due to very different contents."The public manifest is small. See activation.

File system providers

The providers live in src/ui/file_system_providers/. An abstract BaseFileSystemProvider owns the onDidChangeFile emitter, gives watch a no-op, and makes readDirectory, writeFile, createDirectory, delete and rename throw internal errors, leaving subclasses only stat and readFile.

CommitIdFileSystemProvider serves URIs of the form jj-commits://<path>?commitId=<id>, with an optional opId and a showParents flag that reads the merged file of the commit's parents instead (source). stat always returns an mtime of 0, since a commit's file never changes. readFile parses the query, requiring commitId, turns the path into a workspace-relative one, and calls Api.readFileAtCommit; a missing file becomes empty bytes, "most likely a new file" (source). Six tests cover the stat, each query parameter, the missing file and the missing commitId (source). A tested EmptyFileSystemProvider sits beside it, but nothing registers it.

API layer

The API layer sits between the UI and the client. Each component needs an external implementation (source).

ComponentRole in the planAt this pin
ApiStateManagerThe orchestration layer, combining what every other API-layer component knows.planned Not named in a milestone.
WorkspaceStateManagerFetches workspace state from the client (gRPC-web internally, the CLI externally) and caches it. It refetches on FileSystemWatcher events.planned Not named in a milestone.
PendingSnapshotsManagerOptional. Turns FileSystemWatcher events into an estimated workspace state from the changed files, to hide latency.in progress M2. It arrived as OptimisticStateEstimator in src/state/: it buffers SnapshotDeltas, drops them when stateChanged is set, and estimates the working copy's file list, marking newly seen files UNKNOWN. It is tested and in //src's deps, but nothing constructs one.
ActionsQueueRuns every write operation one at a time, so writes neither race nor cause divergence.planned To be upstreamed in M3.
FileSystemWatcherWatches for file changes and feeds WorkspaceStateManager and PendingSnapshotsManager.in progress The interface and a local implementation are in the tree and tested, but nothing constructs one. See §5.
JJ PubSubWatches for changes to jj's global op head (see operations).in progress M2. Only an OpHeadWatcher interface with a subscribe method, and no implementation. See §4.
Forge ManagerInternally ChangelistStateManager, which tracks CL state with one CL per commit and a single remote. Externally it needs one implementation per forge. GitHub pull requests map to many commits, and several remotes may be needed.planned M4.
Api facadeNot named in the plan; the "API exposed to the UI layer".Landed with one method, readFileAtCommit. ApiImpl passes it straight to the client (source, source), and activate hands it to the UI (source).
NetworkReconnectWatcher, TelemetryNot in the plan.Interfaces only, with no implementation and no callers. See §4.

Client layer

The client layer fetches information about jj (source). Internally that is gRPC-web calls to Google's jj servers, which are built directly on jj-lib. Externally it will be the jj CLI with templates, possibly replaced by a daemon client in the long run. The plan warns that a CLI client is harder than it looks. The internal server exposes things the CLI doesn't, such as file IDs, and answers in one request what may take several CLI commands. The plan says the internal server and the CLI may need to converge as part of open-sourcing. At this pin the client reads the commit graph through the CLI, but only its file reads are called; see §3.

2. Milestones

At this pin the Milestones section of intro.md is a single link to the jj-vcs project board (source). The board lives outside the repository and can't be pinned, so this page doesn't track it. The table below is the timeline intro.md listed up to the previous commit, kept for the M1–M4 labels used above; its dates and statuses are no longer in the repository, but the last column is checked against the code at this pin.

MilestoneGoalItemsAt this pin
M1
Oct–Nov 2026
Show the commit graph and modified files, without refreshesOpen-source the commit graph [Done]; the jj subprocess client [In progress]; upstream the "Open Diff" commandsThe graph has landed. SubprocessClient has landed, is tested and now fills in each commit's modified files; activate builds it, but the graph doesn't use it yet. The "Open Diff" commands haven't landed, though the jj-commits provider they would open has.
M2
Nov 2026–Feb 2027
Support refreshesFileSystemWatcher [In progress]; upstream PendingSnapshotsManager; JJ PubSub; multi-workspace supportThe watcher has landed but isn't wired in. PendingSnapshotsManager's replacement, OptimisticStateEstimator, is in the tree but unused. JJ PubSub has an interface only. SubprocessClient can now add workspaces and reports each commit's working copies, but nothing uses that yet.
M3
Feb–Apr 2027
Basic mutating operationsUpstream ActionsQueue; jj new/edit/squash commands; jj describe/commit commands with the commit dialog webviewplanned
M4
Apr–Jun 2027
Forge integrationPublishing to GitHub, and possibly Gerrit; upload and syncplanned

3. The subprocess client so far

client.ts (88 lines), subprocess_client.ts (512 lines), subprocess_util.ts (90 lines), their tests, src/api/api.ts and BUILD.bazel

The Client interface has four methods. getWorkspaceState snapshots the working copy without publishing an operation and returns a RepoState: a list of commits and the opHead they were read at, which may be that unpublished operation (source). Its doc comment explains the optional lastKnownUnpublishedOperation argument. Because an unpublished snapshot is never remembered, jj keeps snapshotting the same files again, which caused "resource exhaustion issues internally inside Google". The internal extension passes the last unpublished operation to its server as the base to snapshot on top of. The CLI can't do that, so the CLI implementation ignores the argument (source). workspaceAdd is "the equivalent of jj workspace add <workspaceRoot>" and returns the new WorkingCopy (source). The other two read file contents as bytes. readFileAtCommit reads a path at a commit, or at its combined parents when showParent is set, and returns undefined if the file isn't there. It takes an optional opId because on the Git backend jj file show only finds commits reachable from the operation it runs at; the comment says Google's commit cloud may ignore it. readFileById reads a file by its FileId (source).

The types it returns live in the //src/api package. A Commit carries:

  • its ID and change ID, parentIds and childIds;
  • whether it is immutable: the root commit or an ancestor of immutable_heads();
  • files, the files it modifies, each a CommitFile with a path, a modification type (ADDED, MODIFIED, DELETED, RENAMED, COPIED, CONFLICTED or UNKNOWN), its metadata as a Merge of optional file IDs that may be conflicted, and, for a move or copy, the source path (source);
  • whether it is the working copy (active) or a parent of it (isWorkingCopyParentCommit);
  • its full description and a descriptionTitle, "usually" the first line;
  • author and committer times in milliseconds, and conflict and divergence flags;
  • the display ID with how much of it to highlight, and the change offset when the change is divergent;
  • its bookmarks, each with a name, an optional remote, needsUpload (jj's *) and isConflicted (source);
  • ancestors and descendants, read-only sets of commit IDs that include the commit itself;
  • workingCopies: the workspaces whose working copy it is, each with a jj workspace name, an optional root path and a display name (source).

CommitId, ChangeId and OperationId are small classes that wrap a hex string and implement HashMapKey, as does the new FileId, so they can be HashMap keys (source). A MutableCommit mapped type drops the readonlys and turns the read-only sets into HashSets, so the client can fill them in (source). These are not the commit graph's own protocol types; nothing converts one into the other yet.

SubprocessClient answers getWorkspaceState with two jj calls:

  1. snapshotWithoutIntegrating runs jj --no-integrate-operation op log -n=1 --no-graph -T=id. Running any command snapshots the working copy, and --no-integrate-operation keeps the resulting operation out of the op log, so this returns the ID of an unpublished operation (see operations). A TODO notes that --no-graph drops jj's elided edges, which the commit graph can't draw either.
  2. getCommits runs jj --at-op <op> log --no-graph with a template that prints one JSON object per commit, including its bookmarks, working copies and modified files (source). The template is built field by field by toJsonObjectTemplate. No revset is passed, so it shows whatever the user's default log revset selects. It then:
    • makes every display ID as long as the longest shortest unique prefix, and at least 4 characters (source);
    • builds each commit's child list from the parent lists, and marks the working copy's parents (source);
    • gives the working-copy commit a default workspace rooted at the client's directory when jj reports none, which jj does when there is only one workspace. A comment admits this is "technically not correct" for a renamed default workspace (source);
    • fills in every commit's ancestors and descendants with commit_topology (source).
    rawCommitToApiCommit appends /<offset> to a divergent commit's display ID, takes the first line as the title, and sets needsUpload for a local bookmark that is neither synced nor conflicted. isImmutable comes straight from the template. files now comes from the template's diff.files() (source): jj's status maps to a modification type, with a conflicted file becoming CONFLICTED and an unknown status logged as an internal error and returned as UNKNOWN; source is set when the source path differs; and the metadata is a Merge built from the file IDs on each side (source).

workspaceAdd runs jj workspace add --name=<basename> -- <root>. It names the workspace after the last path component itself, because jj workspace add prints nothing machine-readable, then finds that name in jj workspace list --ignore-working-copy and returns it. The -- keeps a path that starts with - from being read as a flag. If the new workspace isn't listed, it throws through logAndShowInternalError (see activation), which is why the target now depends on //src/logging.

readFileAtCommit calls fileShow, which runs jj --at-op=<op> --color=never file show -r=<id> -- root:"<path>", adding - after the ID for showParent. Without an operation ID it passes --ignore-working-copy instead of --at-op. The quoted root: pattern makes the path match literally instead of as a glob. jj exits with 1 for every user error, so a missing file is recognised by the text Error: No such path: and turned into undefined. A TODO notes that - fails on a merge commit until jj PR 10464 makes parents_of() usable. readFileById runs jj debug object file -- <path> <id>; its comment says there is no stable API for this yet, a jj file show --id was rejected upstream, and git cat-file would only work on the Git backend.

Every call goes through subprocess, Node's execFile promisified with a 10-second timeout, or its twin subprocessBuffer, which returns stdout as bytes for the file reads. Both run the command directly, with no shell, and raise execFile's 1 MiB output limit to 10 MiB. The comment explains the cap: diffs still go through VS Code's FileSystemProvider, which loads whole files, and jj doesn't snapshot files over 1 MiB by default anyway (source). The helper's only dependency is @types/node, so under the deps scheme it belongs on the external-only side. The interface target client depends only on //src/api.

The 31 SubprocessClient tests run the pinned jj (see the jj rule) in fresh repositories from newRepo. Thirteen cover a new repository, the returned opHead, modified files of every kind including a symlink, conflicted files, descriptions (including binary data and a multi-line title), divergent commits with their change offsets, a conflict, ancestors and descendants across several commits, a second workspace, and a workspace path that starts with -. The snapshot test reads the op log the same unpublished way. A clean working copy adds no operation. A dirty file shows one "snapshot working copy" operation on top, and once the file is removed, the op log is back to what it was. Nothing was ever published (source). Five more run against a local repository with a remote and check local, conflicted, synced, unsynced and diverged remote bookmarks (source). Nine read files at a commit: text, text read without an operation ID, binary data, a missing file, glob characters taken literally, showParent on a normal commit, and files just under and just over the buffer limit. On a merge commit the showParent reads are expected to fail until the TODO above is fixed (source). Four read files by ID, which the test gets from jj debug tree (source). The subprocess_util test still runs jj version (source). extension.ts constructs the client, and //src's deps now include //src/client:subprocess_client and //src/api:api_impl (source).

4. Interfaces without implementations

Three new directories hold one interface each. None has an implementation, a test or its own BUILD.bazel, so //src's source glob picks them up. Nothing imports them.

InterfaceMethodPurposeSource
OpHeadWatchersubscribe(cb)Calls back when the repository's global op head changes. "Global" means the op head that all workspaces share. This matches the plan's JJ PubSub.20–33
NetworkReconnectWatchersubscribe(cb)Calls back when the network reconnects. Not in the plan.20–35
TelemetrylogAction(commandName, uiLatencyMillis)Records how long a command took. Not in the plan.18–29

5. The file-system watcher

file_system_watcher.ts (65 lines), local_file_system_watcher.ts (205 lines), local_file_system_watcher_test.ts (20 tests) and BUILD.bazel

The package follows the interface/implementation split from the plan's design notes. The file_system_watcher target holds only the interface and depends only on @types/vscode. local_file_system_watcher adds @parcel/watcher, @types/node and the logging and error packages. A third target, gitignore, holds a 553-line gitignore matcher with no dependencies that nothing imports yet (source). The interface's doc comment describes a watcher "compatible with both local and virtual file systems" (source).

The interface

FieldTypeMeaningSource
FileSystemWatcher.subscribe(cb)vscode.DisposableRegisters a callback. An undefined delta means "anything may have changed", for example after a network interruption or an OS event-queue overflow.24–38
FileChangeTypeenumADDED, MODIFIED, DELETED40–44
SnapshotDelta.stateChangedbooleanTrue when a version-control or workspace state transition happened, which invalidates estimating from file changes alone. Locally that is a change to .jj/working_copy/checkout, which jj writes last when it moves the working copy (see working copy).47–55
SnapshotDelta.snapshotNumbernumberMust increase monotonically.56–57
SnapshotDelta.fileChangesreadonly FileChange[]Each change's path, relative to the workspace root, and type.58–65

LocalFileSystemWatcher

Its doc comment explains why it uses @parcel/watcher rather than VS Code's createFileSystemWatcher: parcel supports exclusions, which are needed to ignore changes to gitignored files, and VS Code has no plans to add them (source).

  1. Construction takes a directory, which must be an absolute path, or it throws an internal error. Parcel doesn't follow symlinks, so it resolves the directory with realpath first, which also drops a trailing slash; a path that can't be resolved is reported as a user error. It then subscribes to the directory recursively. Tests can inject a replacement subscribe. Once the subscription is ready, it logs the path and every subscriber gets undefined, so nothing that happened during setup is missed. If subscribing fails, the error is logged and shown to the user, and not rethrown (source).
  2. Each batch of parcel events becomes one SnapshotDelta with the next counter value. Each path is made relative to the watched directory. stateChanged is set when an event's path is exactly <dir>/.jj/working_copy/checkout. A parcel error is logged and sent to subscribers as undefined (source, source).
  3. Fan-out calls every callback and logs any exception it throws, so one failing subscriber doesn't starve the others (source).
  4. Dispose clears the callbacks, swaps in a no-op subscription so repeated calls are harmless, and then unsubscribes, logging any failure (source).

The 20 tests run against the real @parcel/watcher on a temporary directory. They wait up to 30 seconds for each expected delta and use the fake VS Code API from //src/testing (fake API). Besides the three change types, they check that stateChanged fires for the workspace's own checkout file but not for a nested workspace's or for foo.jj/working_copy/checkout (source). The others cover subscriber isolation, disposal in every order, the error path, and the new path handling: a relative path is rejected, a trailing slash is ignored, and a directory opened through a symlink still produces events (source).

//src lists //src/file_system_watcher in its deps, which is the interface target, but nothing in the extension creates a watcher yet (source).

6. Design considerations

The plan ends with five rules for the work (source).

  • Use Bazel for builds. The internal extension runs on Cider, a VS Code Web fork that can't import packages like node. So code is split into interface files and implementation files, and Bazel deps keep external-only packages out of the interfaces. See build and test for how far this is enforced at this pin.
  • Avoid dependencies. Fewer dependencies mean less supply-chain risk and less work importing TypeScript libraries into Google. The extension's runtime dependencies at this pin are @parcel/watcher, @vscode-elements/elements, @vscode/codicons, jsonc-parser, lit and safevalues (source).
  • Reads should stay reads. Read commands will pass --no-integrate-operation and --ignore-working-copy, so a read neither causes divergence nor adds entries to the user's op log (see operations and working copy). SubprocessClient passes --no-integrate-operation and reads at that operation with --at-op, but not --ignore-working-copy: it snapshots on purpose, so the graph reflects unsaved file changes without publishing an operation. A file read with no operation ID passes --ignore-working-copy.
  • Different defaults internally and externally. Google developers are used to settings such as jj.includeWorkingCopyInParentDiffs and threeWayMerge.enabled being on. The plan says to check whether those defaults suit external users. At this pin contributes.configuration holds only jj.graph.focusMode, so neither setting exists.
  • Missing features in parallel. Features the internal extension still lacks, such as blame and squashing part of a file, are open for outside contributors while externalization goes on.

7. Loose ends at this pin

  • Exclusions are the reason for parcel, but none are passed. The watcher picks @parcel/watcher for its exclusion support, yet calls subscribe with no ignore option (source). As written, gitignored build output and every write under .jj/ produce events.
  • A native module in a package that ships no dependencies. @parcel/watcher is a native npm package, and the .vsix is built with --no-dependencies. A TODO already notes this has to change before the watcher is used (build and test).
  • The subprocess helper's limits. Output is capped at 10 MiB and every call has a fixed 10-second timeout (source). getCommits prints the whole log in one call, so a very large repository would still make it reject, and a file over the cap can't be read at all.
  • Two commit types. The client returns //src/api commits, while the graph takes its own protocol types, and the Api facade has no method that returns commits yet. Something still has to convert one into the other before the graph can show real data.
  • A provider nothing opens. activate now reaches the client, but only through the jj-commits provider, and no command or view opens a jj-commits URI yet. The watcher, the estimator, the gitignore matcher, the command hooks under src/ui/commands/, the focus-mode setting cache in src/ui/config/ (only dispose touches it) and the three interfaces are built and mostly tested on every CI run without affecting the shipped extension, as is the unregistered EmptyFileSystemProvider. They move from "in progress" to implemented once something uses them.