Bridging git worktrees and jj workspaces for agentic workflows
My jj workspaces were invisible to Zed, Neovim, and Codex, no diff gutters, no blame annotations. The fix? A single line pointing libgit2 to the Git object store that was already there.
I run agentic workflows in jj workspaces: parallel feature development, code reviews, spikes, experiments, each isolated in its own working copy. The workflow is ideal for isolation. You can work on a feature, create stacked PRs, switch to another workspace for spikes or code reviews, or set up a scratch environment for debugging flaky tests. But when I opened a workspace, the editors showed no diff gutters, no change indicators, no blame annotations. As far as Zed, Neovim, and the others were concerned, I was editing unversioned directories.
This sent me down a rabbit hole through libgit2's repository discovery, Git's internal storage model, and jj's colocated backend, and the fix turned out to be a single line.
How Git Repository Discovery Actually Works
When an editor opens a directory, it needs to find the Git repository. How it does this varies. Zed links against libgit2, a C implementation of Git's object model, and performs repository discovery in-process. VS Code shells out to a bundled git binary, which means PATH-based shims can still affect it. Neovim plugins vary; some use libgit2 via FFI, others call git directly. But the discovery algorithm is the same regardless of who runs it. It starts with git_repository_discover() (or the equivalent CLI invocation), walking up from the working directory:
~/project/feature-auth/lib/accounts/
~/project/feature-auth/lib/
~/project/feature-auth/
~/project/
~/
...
If til_codes/.git existed somewhere along this path, discovery would succeed. But the workspace is a sibling directory, feature-auth/ sits next to til_codes/, not inside it. There's no .git anywhere in the walk.
At each level, it looks for a .git entry. Here's where it gets interesting: .git can be one of two things.
Case 1: A directory. The standard layout. Contains objects/, refs/, HEAD, index, and the rest of the repository internals. libgit2 opens it directly.
Case 2: A file. A plain text file containing a single line:
gitdir: /path/to/actual/git/directory
libgit2 reads the file, resolves the path (relative paths are resolved against the file's parent directory), and opens the referenced directory as the repository. This isn't an extension or a hack; it's part of the Git repository layout specification. Git originally added it for submodule support and later adopted the same mechanism for git worktree.
When you run git worktree add ../feature-branch, Git creates exactly this: a .git file in the new worktree that points back to .git/worktrees/feature-branch/ inside the main repository. Every tool built on libgit2 follows these pointers transparently.
A jj workspace has neither. It has a .jj directory, which libgit2 doesn't recognize. Discovery fails at the workspace root, bubbles up to the filesystem root, finds nothing, and the IDE concludes there's no repository.
Inside jj's Storage Model
To understand why a fix is even possible, you need to know how jj stores data. When you initialize a repository with jj git init --colocate and in current jj releases, colocation is the default, you get two version control systems sharing one directory:
til_codes/
.git/ # Standard Git repository
HEAD
objects/ # Git object store (commits, trees, blobs)
refs/ # Branch refs, tags
index # Working tree cache
info/
exclude # Per-repo ignore patterns
.jj/ # Jujutsu metadata
repo/
store/
git_target # Points to ../.git (the colocated backend)
op_store/ # Operation log (jj's undo history)
op_heads/ # Current operation heads
working_copy/ # Snapshot state for this working copy
The critical detail is in .jj/repo/store/git_target. In a colocated repository, this file contains the path to the .git directory. jj doesn't maintain its own object store; it writes directly to Git's. Every jj describe, jj new, or jj squash creates real Git commit objects in .git/objects/. The commit graph, the tree objects, the file blobs, they're all standard Git objects that any Git tool can read.
When you create a workspace with jj workspace add ../feature-auth, the new directory gets a minimal .jj:
feature-auth/
.jj/
repo # Text file: path back to the main repo's .jj/repo/
working_copy/ # This workspace's snapshot state
lib/
test/
mix.exs
...
The repo file is a pointer, not a copy. All workspaces in a jj repository share the same commit graph, operation log, and, because jj is colocated, the same Git object store. The Git objects representing your feature branch's commits are sitting in the main repo's .git/objects/. The workspace just has no way to tell libgit2 where to find them.
Why the Shim Approach Can't Solve This
Before arriving at the fix, I tried the obvious approach: intercepting git commands. jj-worktree is a Rust binary that gets symlinked as git early in PATH. When invoked inside a jj repository, it translates commands:
git status -> jj diff --summary (output converted to porcelain v1)
git rev-parse HEAD -> jj log -r @ -T commit_id
git worktree add -> jj workspace add
git branch -d -> jj bookmark delete
The translation includes output format conversion. jj diff --summary produces M file.txt, but tools that parse git status --porcelain expect M file.txt (note the leading space indicating unstaged changes). jj-worktree handles this:
// From jj-worktree's shim.rs - cmd_status()
for line in diff.lines() {
let trimmed = line.trim();
if let Some((status, path)) = trimmed.split_once(' ') {
match status {
"M" => println!(" M {path}"),
"A" => println!("?? {path}"),
"D" => println!(" D {path}"),
"R" => println!(" M {path}"),
_ => println!(" M {path}"),
}
}
}
This works for CLI tools. Claude Code's git integration, ghpre-commit hooks, anything that spawns git as a subprocess sees the shim. VS Code would too, since its built-in Git extension shells out to a git binary. But Zed doesn't spawn git. It calls git_repository_discover() from libgit2, which is compiled into the editor binary. There's no PATH lookup, no process spawning, no opportunity for interception. Other tools that use libgit2 directly, including some Neovim plugins and GUI clients, have the same limitation.
I verified this by adding debug logging to the shim and opening the workspace in Zed. The shim was never invoked. Zed's git integration operates entirely within its own process space.
The Fix: One Line
Since jj's colocated backend writes to Git's object store, and libgit2 follows gitdir: pointers, the fix is to connect the two:
echo "gitdir: ~/project/til_codes/.git" > ~/project/feature-auth/.git
That's it. One file, one line. libgit2's discovery walk hits the workspace root, finds a .git file, reads the gitdir: pointer, opens the main repository's .git directory, and now has full access to the object store. Zed immediately shows diff gutters. Codex shows file changes. git diff, git status, and git blame become useful from the workspace directory. git log still follows the shared Git HEAD, it won't show your workspace's jj history unless you explicitly point it at exported refs or commit hashes.
But to understand the limitations of this approach, you need to understand how Git actually computes diffs.
Git's Three-Layer Diffing Model
Git doesn't compute diffs by comparing files against a single reference point. It maintains three distinct representations of your project, and different commands compare different pairs:
+------------------+
| HEAD commit | (the tree object pointed to by HEAD)
+------------------+
|
git diff --cached
|
+------------------+
| Index | (.git/index - binary file, aka "staging area")
+------------------+
|
git diff
|
+------------------+
| Working tree | (actual files on disk)
+------------------+
git diffcompares the index against the working tree.git diff --cachedcompares HEAD against the index.git statusdoes both comparisons and reports the union.
The index is the key piece. It's a binary file at .git/index that caches metadata about every tracked file: path, file size, modification time, inode number, and the SHA-1 of the blob object representing the file's contents. When you run git status, Git stats every file in the working tree, compares the stat data against the index entries, and only reads file contents (to compute SHA-1) when the stat data doesn't match. This is what makes git status fast even in large repositories, it's a stat cache, not a content scan.
Here's why this matters for our workspace setup. The gitdir: pointer shares the main repository's .git directory, which means it shares the main repository's index. That index was built by the main repository's working copy. It contains stat entries for files as they exist in the main repo, not in the workspace.
This creates three distinct behaviors depending on the type of change:
New files (files that exist in the workspace but not in the index) show as "untracked." This is correct, the IDE displays them with the "new file" indicator, which is exactly what you want.
Modified files (files that exist in both the workspace and the index) are compared against the index's cached blob SHA-1. If the shared index still reflects the tree state your workspace branched from, the diff is accurate, the cached content matches the file's pre-modification state. But if any Git operation or tool updates that shared index to reflect a different working copy (e.g., running git checkout or git reset in the main repo), diffs in the workspace will be computed against the wrong base.
Deleted files (files in the index but not in the workspace) would show as deleted if the workspace doesn't contain them. In practice, jj workspaces start as copies of the full tree, so this only matters if you've explicitly removed files.
For my workflow, feature branches off trunk, usually a handful of files changed, shared index untouched, and the diffs have been accurate. The index still reflects the tree state my workspace branched from, so the comparison base is correct.
Contrast: How git worktree Does It Properly
For context, here's what git worktree add creates that our approach doesn't. When you run git worktree add ../feature-branch, Git creates:
.git/worktrees/feature-branch/
HEAD # Separate HEAD for this worktree
index # Separate index for this worktree
commondir # Points back to the main .git
gitdir # Path to the worktree's .git file
Each worktree gets its own HEAD and its own index. The object store and refs are shared (via commondir), but the working tree state is independent. This is why git worktree doesn't have the index-sharing problem, each worktree tracks its own file state.
Our gitdir: hack skips this entirely. We point directly at the main .git, sharing everything, including the index and HEAD. It's a read-only window into the object store, not a proper worktree registration. This is fine for IDE diffing but means you should never run Git write operations (git add, git commit, git checkout) from the workspace, they would mutate the main repository's state.
Hiding .jj From Git's View
With the gitdir: pointer in place, git status now sees the .jj directory as untracked. Git has three layers of ignore rules, evaluated in this order:
.gitignoretracked, committed, and shared with the team. Not appropriate for this;.jjis a local concern.core.excludesFile(defaults to~/.config/git/ignore) global, applies to every repository. Too broad..git/info/excludeper-repository, never committed, never shared. This is the right layer.
The info/exclude file uses identical syntax to .gitignore but lives inside the .git directory:
echo '.jj' >> ~/project/til_codes/.git/info/exclude
After adding this, .jj disappears from git status and from Zed's file tree. The exclude file doesn't need to exist beforehand; Git creates the info/ directory during git init, but the exclude file may not be present. The >> append handles both cases (creates the file if missing, appends if it exists).
Automating the Setup
I manage jj workspaces with two shell functions: jwa (workspace add) and jws (workspace sync). jwa creates the workspace and calls jws to copy configuration files from the main repo. Adding the gitdir: setup to jws means every new workspace gets IDE support automatically:
jws() {
local target="$1"
local root=$(jj root 2>/dev/null)
if [[ -z "$root" ]]; then
echo "Not in a jj repository"
return 1
fi
# Sync AI tool configs into the workspace
local items=(".claude" "CLAUDE.md" "AGENTS.md" ".env" ".mcp.json")
local copied=0
for item in "${items[@]}"; do
if [[ -e "$root/$item" ]]; then
cp -r "$root/$item" "$target/"
((copied++))
fi
done
# Set up gitdir pointer for IDE diff support
if [[ -e "$root/.git" ]]; then
local git_dir
if [[ -d "$root/.git" ]]; then
git_dir="$root/.git"
elif [[ -f "$root/.git" ]]; then
git_dir=$(cat "$root/.git" | sed 's/^gitdir: //')
[[ "$git_dir" != /* ]] && git_dir="$root/$git_dir"
fi
if [[ -n "$git_dir" ]]; then
echo "gitdir: $git_dir" > "$target/.git"
mkdir -p "$git_dir/info"
if ! grep -qx '.jj' "$git_dir/info/exclude" 2>/dev/null; then
echo '.jj' >> "$git_dir/info/exclude"
fi
echo "Linked .git for IDE diff support"
fi
fi
echo "Synced $copied items to $target"
}
The elif branch handles the case where the main repo's .git is itself a gitdir: pointer (e.g., the main repo was created inside another git worktree). It reads the pointer, resolves relative paths, and chains through to the actual .git directory.
The jwa function creates the workspace and calls jws:
jwa() {
local input="$1"
local root=$(jj root 2>/dev/null)
local workspace_path
if [[ "$input" = /* ]]; then
workspace_path="$input"
elif [[ "$input" == */* ]]; then
workspace_path="$PWD/$input"
else
workspace_path="$root/../$input"
fi
jj workspace add "$workspace_path" || return 1
workspace_path="$(cd "$workspace_path" && pwd)"
jws "$workspace_path"
cd "$workspace_path"
}
The full workflow becomes:
$ cd ~/project/til_codes
$ jwa feature-auth
Created workspace in "../feature-auth"
Linked .git for IDE diff support
Synced 3 items to ~/project/feature-auth
$ cd ../feature-auth
$ zed . # Diff gutters work immediately
The Rule: Read-Only Git, All Writes Through jj
This bears repeating because violating it will corrupt your main repo's state. The gitdir: pointer gives IDEs read access to the object store. It does not register the workspace as a proper git worktree. Git commands that read from the working tree and object store (diff, status, blame) are safe and useful. git log works but follows the shared HEAD, not your workspace's jj history. Git commands that write (add, commit, checkout, reset, stash) will modify the main repo's index and HEAD.
In the workspace, all version control goes through jj:
jj describe -m "add user authentication" # set commit message
jj new # start next change
jj bookmark set feature-auth -r @- # create bookmark for push
jj git push -b feature-auth # push for PR
Zed shows the diffs. jj manages the history. They operate on the same underlying objects without needing to agree on a protocol.
When This Breaks
There are specific scenarios where the diff accuracy degrades:
Shared index gets mutated. If a Git operation in the main repo updates the shared index, git checkout, git reset, or even an IDE refreshing the main repo's working tree, diffs in the workspace will be computed against the wrong base. Note that jj operations alone don't touch the Git index; this only happens when something invokes Git directly against the main repo. The fix is simple: avoid Git write operations in the main repo while workspaces are active, or accept occasional noise in the diff gutter.
Multiple workspaces sharing one .git. All workspaces point to the same index. If you open two workspaces in Zed simultaneously, both see diffs computed against the same index state. For new files (the most common case in feature branches), this is fine. For modified files, the workspace whose base commit matches the index will show correct diffs; the other may not.
Binary files and large objects. Diff computation requires reading blob objects from the object store. If your workspace modifies large binaries, the IDE may be slower to compute diffs since it's resolving objects from the main repo's packfiles over the gitdir: pointer.
Complementary Tools
The gitdir: pointer and jj-worktree solve different problems and can coexist:
| Concern | gitdir: pointer | jj-worktree |
|---|---|---|
| IDE diff gutters | Yes | No (IDEs bypass PATH) |
CLI git status/diff/log | Yes (read-only) | Yes (full translation) |
git commit/add/checkout | Dangerous (mutates main repo) | Translated to jj commands |
| Dependencies | None | Rust binary in PATH |
| Workspace metadata tracking | None | Bookmark and path tracking |
For IDE-only support, the gitdir: pointer is sufficient. For CLI tools that need transparent git compatibility, install jj-worktree alongside it. They don't conflict, jj-worktree's shim detects whether it's in a jj repo and passes through to real git otherwise, and the gitdir: file is invisible to jj.
The Long-Term Fix
The real solution is native jj support in editors. Zed has an open issue for it. VS Code has community extensions in early development. Until those ship, the gitdir: pointer gets you most of the way there, accurate diffs for new and modified files (when the shared index still matches the expected base), blame annotations, and object resolution, with a single line of text and zero dependencies.
The objects were always there. libgit2 just needed a signpost.
Further reading:
- Git repository layout specification -- the
gitdir:format definition - libgit2 repository discovery -- the C API that IDEs call
- jj workspace documentation -- how workspaces share the repo store
- jj-worktree -- Rust git shim for CLI compatibility
- Git index format – the binary index specification