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.
UI layer
The UI layer is "mostly reusable code" (source).
| Component | The plan says | At this pin |
|---|---|---|
| VS Code commands | Mostly 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 graph | Shared. | 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 editor | Shared. | 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 providers | Shared. | Landed. JjUiBase registers a read-only CommitIdFileSystemProvider for the jj-commits scheme (source), though nothing opens such a URI yet. See below. |
| Conflict marker highlighter | Shared. | 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).
| Component | Role in the plan | At this pin |
|---|---|---|
| ApiStateManager | The orchestration layer, combining what every other API-layer component knows. | planned Not named in a milestone. |
| WorkspaceStateManager | Fetches 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. |
| PendingSnapshotsManager | Optional. 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. |
| ActionsQueue | Runs every write operation one at a time, so writes neither race nor cause divergence. | planned To be upstreamed in M3. |
| FileSystemWatcher | Watches 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 PubSub | Watches 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 Manager | Internally 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 facade | Not 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, Telemetry | Not 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.
| Milestone | Goal | Items | At this pin |
|---|---|---|---|
| M1 Oct–Nov 2026 | Show the commit graph and modified files, without refreshes | Open-source the commit graph [Done]; the jj subprocess client [In progress]; upstream the "Open Diff" commands | The 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 refreshes | FileSystemWatcher [In progress]; upstream PendingSnapshotsManager; JJ PubSub; multi-workspace support | The 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 operations | Upstream ActionsQueue; jj new/edit/squash commands; jj describe/commit commands with the commit dialog webview | planned |
| M4 Apr–Jun 2027 | Forge integration | Publishing to GitHub, and possibly Gerrit; upload and sync | planned |
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,
parentIdsandchildIds; - whether it is immutable: the root commit or an ancestor of
immutable_heads(); files, the files it modifies, each aCommitFilewith a path, a modification type (ADDED,MODIFIED,DELETED,RENAMED,COPIED,CONFLICTEDorUNKNOWN), its metadata as aMergeof optional file IDs that may be conflicted, and, for a move or copy, thesourcepath (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*) andisConflicted(source); ancestorsanddescendants, 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:
snapshotWithoutIntegratingrunsjj --no-integrate-operation op log -n=1 --no-graph -T=id. Running any command snapshots the working copy, and--no-integrate-operationkeeps the resulting operation out of the op log, so this returns the ID of an unpublished operation (see operations). A TODO notes that--no-graphdrops jj's elided edges, which the commit graph can't draw either.getCommitsrunsjj --at-op <op> log --no-graphwith 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 bytoJsonObjectTemplate. No revset is passed, so it shows whatever the user's defaultlogrevset 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
defaultworkspace 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).
rawCommitToApiCommitappends/<offset>to a divergent commit's display ID, takes the first line as the title, and setsneedsUploadfor a local bookmark that is neither synced nor conflicted.isImmutablecomes straight from the template.filesnow comes from the template'sdiff.files()(source): jj's status maps to a modification type, with a conflicted file becomingCONFLICTEDand an unknown status logged as an internal error and returned asUNKNOWN;sourceis set when the source path differs; and the metadata is aMergebuilt 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.
| Interface | Method | Purpose | Source |
|---|---|---|---|
OpHeadWatcher | subscribe(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 |
NetworkReconnectWatcher | subscribe(cb) | Calls back when the network reconnects. Not in the plan. | 20–35 |
Telemetry | logAction(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
| Field | Type | Meaning | Source |
|---|---|---|---|
FileSystemWatcher.subscribe(cb) | vscode.Disposable | Registers a callback. An undefined delta means "anything may have changed", for example after a network interruption or an OS event-queue overflow. | 24–38 |
FileChangeType | enum | ADDED, MODIFIED, DELETED | 40–44 |
SnapshotDelta.stateChanged | boolean | True 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.snapshotNumber | number | Must increase monotonically. | 56–57 |
SnapshotDelta.fileChanges | readonly 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).
- 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
realpathfirst, 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 replacementsubscribe. Once the subscription is ready, it logs the path and every subscriber getsundefined, so nothing that happened during setup is missed. If subscribing fails, the error is logged and shown to the user, and not rethrown (source). - Each batch of parcel events becomes one
SnapshotDeltawith the next counter value. Each path is made relative to the watched directory.stateChangedis set when an event's path is exactly<dir>/.jj/working_copy/checkout. A parcel error is logged and sent to subscribers asundefined(source, source). - Fan-out calls every callback and logs any exception it throws, so one failing subscriber doesn't starve the others (source).
- 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 Bazeldepskeep 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,litandsafevalues(source). - Reads should stay reads. Read commands will pass
--no-integrate-operationand--ignore-working-copy, so a read neither causes divergence nor adds entries to the user's op log (see operations and working copy).SubprocessClientpasses--no-integrate-operationand 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.includeWorkingCopyInParentDiffsandthreeWayMerge.enabledbeing on. The plan says to check whether those defaults suit external users. At this pincontributes.configurationholds onlyjj.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/watcherfor its exclusion support, yet callssubscribewith noignoreoption (source). As written, gitignored build output and every write under.jj/produce events. - A native module in a package that ships no dependencies.
@parcel/watcheris a native npm package, and the.vsixis 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).
getCommitsprints 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/apicommits, while the graph takes its own protocol types, and theApifacade 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.
activatenow reaches the client, but only through thejj-commitsprovider, and no command or view opens ajj-commitsURI yet. The watcher, the estimator, the gitignore matcher, the command hooks undersrc/ui/commands/, the focus-mode setting cache insrc/ui/config/(onlydisposetouches it) and the three interfaces are built and mostly tested on every CI run without affecting the shipped extension, as is the unregisteredEmptyFileSystemProvider. They move from "in progress" to implemented once something uses them.