Skip to content

[enhancement] Support for jj workspaces #879

@jm96441n

Description

@jm96441n

Like the title says, could this support JJ workspaces? Had claude write up the following proposal:

Add Jujutsu (jj) backend for worktree operations

Summary

Add optional support for Jujutsu (jj) as a backend for aoe's worktree lifecycle operations. Today aoe is git-only: src/git/worktree.rs shells out to git worktree {add,remove,prune,list} and uses libgit2 for repo introspection. This proposal extracts a WorktreeBackend trait, keeps the existing git implementation unchanged, and adds a JjBackend for users who manage their repos with jj.

Scope for v1: colocated jj repos only (jj git init --colocate / jj git clone --colocate). Non-colocated jj is a follow-up.

Motivation

jj users currently have two options:

  1. Don't use aoe's auto-worktree features. Pre-create workspaces with jj workspace add, point aoe at the directory, skip --worktree / --new-branch / --delete-worktree.
  2. Let aoe run git worktree add inside a colocated jj repo. Works, but those worktrees aren't known to jj — jj workspace list won't see them, and cleanup is git-only.

Neither is great. Native support means the TUI's "create session" flow and aoe worktree {list,cleanup} work the same way for both crowds.

Detection

Backends are detected per-repo at the root:

pub enum VcsBackend { Jj, Git }

pub fn detect_backend(repo_path: &Path) -> VcsBackend {
    if repo_path.join(".jj").is_dir() { return VcsBackend::Jj; }
    if let Some(parent) = repo_path.parent() {
        if parent.join(".jj").is_dir() { return VcsBackend::Jj; }
    }
    VcsBackend::Git
}

Rules:

  • .jj/ present → JjBackend. Colocated repos always also have .git/; we prefer jj when both exist.
  • .jj/ absent, .git/ present → GitBackend (today's behavior, byte-for-byte).
  • Neither → existing GitError::NotAGitRepo.

Override path: add worktree.backend = "auto" | "git" | "jj" to WorktreeConfig. auto runs the detection above. Per AGENTS.md, this also requires a FieldKey, SettingField, apply_field_to_global / apply_field_to_profile, clear_profile_override, and *ConfigOverride wiring. Standard playbook.

Optional CLI flag for one-offs: aoe add --vcs jj <branch>.

Dispatch

Extract the existing GitWorktree API into a trait. Today's call sites (~10, in src/cli/add.rs, src/cli/remove.rs, src/cli/worktree.rs, src/git/cleanup.rs, src/git/diff.rs, src/server/api/git.rs) become trait calls.

// src/vcs/mod.rs (renamed from src/git/)
pub trait WorktreeBackend {
    fn add_worktree(&self, path: &Path, branch: &str, new: bool) -> Result<()>;
    fn remove_worktree(&self, path: &Path, force: bool) -> Result<()>;
    fn list_worktrees(&self) -> Result<Vec<WorktreeEntry>>;
    fn prune(&self) -> Result<()>;
    fn current_branch(&self, path: &Path) -> Result<String>;
    fn fetch_branch(&self, remote: &str, branch: &str) -> Result<()>;
    fn is_dirty(&self, path: &Path) -> Result<bool>;
    // ...remaining GitWorktree surface
}

pub struct GitBackend { repo_path: PathBuf } // existing impl, renamed
pub struct JjBackend  { repo_path: PathBuf } // new

pub fn open(repo_path: PathBuf) -> Result<Box<dyn WorktreeBackend>> {
    match detect_backend(&repo_path) {
        VcsBackend::Jj  => Ok(Box::new(JjBackend::new(repo_path)?)),
        VcsBackend::Git => Ok(Box::new(GitBackend::new(repo_path)?)),
    }
}

Call site change is mechanical:

// before
let git_wt = GitWorktree::new(main_repo)?;
git_wt.add_worktree(...);

// after
let wt = vcs::open(main_repo)?;
wt.add_worktree(...);

Command mapping

Operation Git Jj
add worktree git worktree add <path> <branch> jj workspace add <path> + jj new <bookmark> in it
add new branch git worktree add -b <new> <path> jj workspace add <path> + jj bookmark create <new> + jj new
remove worktree git worktree remove <path> jj workspace forget <name> (main repo) + remove dir
list git worktree list --porcelain jj workspace list
prune git worktree prune jj workspace forget for missing (or no-op; jj self-cleans)
current branch libgit2 repo.head() libgit2 (colocated) — no jj-specific code needed
fetch git fetch <remote> <branch> jj git fetch --branch <branch>
dirty check libgit2 status libgit2 (colocated)
diff libgit2 diff libgit2 (colocated)

Key insight: for colocated repos, all read paths stay on libgit2. jj keeps .git/ exported. The JjBackend only needs to override the mutating ops (add/remove/fetch). This keeps the diff/status surface single-pathed.

Edge cases

aoe already handles bare-repo + linked-worktree layouts (find_main_repo_from_linked_worktree_gitfile, find_main_repo_from_worktree_gitdir, src/git/worktree.rs:84-122). The jj equivalents:

  • jj workspace from a bare repo. .jj/repo/store/git/ holds the bare. Detection walks up looking for .jj/ or the existing bare-repo parent pattern.
  • jj workspace add workspaces. Each has its own .jj/ pointing back to the main repo. jj workspace list from any workspace returns all of them.
  • Mixed: git worktree add inside a colocated jj repo. That worktree has a .git file link but no .jj/. Detection → GitBackend. Correct: git-created worktrees aren't known to jj. aoe treats it as a git worktree. Acceptable.
  • Non-colocated jj. Out of scope for v1. Detection error message points users to jj git init --colocate, or we run it for them on first use behind a config flag.

User-facing behavior

jj user, no config changes needed:

jj git clone --colocate https://github.com/foo/bar
cd bar
aoe add feature-x --new-branch
# detects .jj/, dispatches to JjBackend
# runs: jj workspace add ../bar-feature-x
#       cd ../bar-feature-x && jj bookmark create feature-x && jj new feature-x
# session created, TUI shows it like any other worktree session

aoe rm feature-x --delete-worktree
# JjBackend: jj workspace forget bar-feature-x, then remove the directory

git user — zero behavior change:

git clone https://github.com/foo/bar
cd bar
aoe add feature-x --new-branch
# .jj/ absent → GitBackend → today's code path, byte-for-byte

Effort estimate

  • Trait extraction: ~half a day. Mechanical, well-bounded.
  • JjBackend (colocated only): 1–2 days. Mirrors GitBackend's shell-out structure; jj's CLI is machine-readable.
  • Tests: 1–2 days. Add jj-fixture variants alongside the existing src/git/worktree.rs tests.
  • Config wiring (WorktreeConfig field, settings TUI, profile overrides): few hours.
  • Docs (docs/guides/workflow.md, CLI reference, new "Using aoe with Jujutsu" section): few hours.

Total: ~1 week of focused work for a clean v1.

Why this is low-risk

  • Existing git users see nothing new. The trait extraction is a refactor with no behavior change.
  • The shell-out boundary is already clean — ~18 Command::new("git") calls all in one file.
  • Read paths stay on libgit2 for colocated repos, so diff/status/branch-name code is untouched.
  • Detection prefers jj only when .jj/ is actually present, so plain git repos never hit jj code paths.

Out of scope (follow-ups)

  • Non-colocated jj support.
  • Sapling, fossil, or other VCS backends (the trait makes them possible, but no user demand cited).
  • Auto-running jj git init --colocate on a plain git repo to migrate it. Should be explicit user action.

Happy to take this on if there's interest. Wanted to sanity-check the design before opening a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions