From f03bbf665c5f8055678a25ef6072c64d2986a7f8 Mon Sep 17 00:00:00 2001
From: Patrik Sundberg
Date: Sun, 22 Feb 2026 01:10:27 +0000
Subject: [PATCH 1/8] Add Vcs trait abstraction and GitVcs/JjVcs
implementations
Introduce src/vcs/ module with:
- Vcs trait encapsulating all VCS operations needed by workmux
- GitVcs implementation delegating to existing git:: functions
- JjVcs stub implementation with todo errors for future phases
- detect_vcs() factory function that walks up from CWD to find .jj or .git
- VcsStatus type alias for GitStatus
This is Phase 1 of jj support: purely additive, no existing behavior changes.
The existing git:: module remains intact and all callers still use it directly.
---
src/main.rs | 1 +
src/vcs/git.rs | 319 +++++++++++++++++++++++++++++++++++++++++++++++++
src/vcs/jj.rs | 307 +++++++++++++++++++++++++++++++++++++++++++++++
src/vcs/mod.rs | 263 ++++++++++++++++++++++++++++++++++++++++
4 files changed, 890 insertions(+)
create mode 100644 src/vcs/git.rs
create mode 100644 src/vcs/jj.rs
create mode 100644 src/vcs/mod.rs
diff --git a/src/main.rs b/src/main.rs
index 280f32be..f713fddc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,6 +19,7 @@ mod spinner;
mod state;
mod template;
mod util;
+mod vcs;
mod workflow;
use anyhow::Result;
diff --git a/src/vcs/git.rs b/src/vcs/git.rs
new file mode 100644
index 00000000..3fcdfac4
--- /dev/null
+++ b/src/vcs/git.rs
@@ -0,0 +1,319 @@
+use anyhow::Result;
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+use crate::config::MuxMode;
+use crate::git;
+use crate::shell::shell_quote;
+
+use super::{Vcs, VcsStatus};
+
+/// Git implementation of the Vcs trait.
+///
+/// Delegates to the existing `git::*` module functions.
+pub struct GitVcs;
+
+impl GitVcs {
+ pub fn new() -> Self {
+ GitVcs
+ }
+}
+
+impl Vcs for GitVcs {
+ fn name(&self) -> &str {
+ "git"
+ }
+
+ // ── Repo detection ───────────────────────────────────────────────
+
+ fn is_repo(&self) -> Result {
+ git::is_git_repo()
+ }
+
+ fn has_commits(&self) -> Result {
+ git::has_commits()
+ }
+
+ fn get_repo_root(&self) -> Result {
+ git::get_repo_root()
+ }
+
+ fn get_repo_root_for(&self, dir: &Path) -> Result {
+ git::get_repo_root_for(dir)
+ }
+
+ fn get_main_workspace_root(&self) -> Result {
+ git::get_main_worktree_root()
+ }
+
+ fn get_shared_dir(&self) -> Result {
+ git::get_git_common_dir()
+ }
+
+ fn is_path_ignored(&self, repo_path: &Path, file_path: &str) -> bool {
+ git::is_path_ignored(repo_path, file_path)
+ }
+
+ // ── Workspace lifecycle ──────────────────────────────────────────
+
+ fn workspace_exists(&self, branch_name: &str) -> Result {
+ git::worktree_exists(branch_name)
+ }
+
+ fn create_workspace(
+ &self,
+ path: &Path,
+ branch: &str,
+ create_branch: bool,
+ base: Option<&str>,
+ track_upstream: bool,
+ ) -> Result<()> {
+ git::create_worktree(path, branch, create_branch, base, track_upstream)
+ }
+
+ fn list_workspaces(&self) -> Result> {
+ git::list_worktrees()
+ }
+
+ fn find_workspace(&self, name: &str) -> Result<(PathBuf, String)> {
+ git::find_worktree(name)
+ }
+
+ fn get_workspace_path(&self, branch: &str) -> Result {
+ git::get_worktree_path(branch)
+ }
+
+ fn prune_workspaces(&self, shared_dir: &Path) -> Result<()> {
+ git::prune_worktrees_in(shared_dir)
+ }
+
+ // ── Workspace metadata ───────────────────────────────────────────
+
+ fn set_workspace_meta(&self, handle: &str, key: &str, value: &str) -> Result<()> {
+ git::set_worktree_meta(handle, key, value)
+ }
+
+ fn get_workspace_meta(&self, handle: &str, key: &str) -> Option {
+ git::get_worktree_meta(handle, key)
+ }
+
+ fn get_workspace_mode(&self, handle: &str) -> MuxMode {
+ git::get_worktree_mode(handle)
+ }
+
+ fn get_all_workspace_modes(&self) -> HashMap {
+ git::get_all_worktree_modes()
+ }
+
+ fn remove_workspace_meta(&self, handle: &str) -> Result<()> {
+ git::remove_worktree_meta(handle)
+ }
+
+ // ── Branch/bookmark operations ───────────────────────────────────
+
+ fn get_default_branch(&self) -> Result {
+ git::get_default_branch()
+ }
+
+ fn get_default_branch_in(&self, workdir: Option<&Path>) -> Result {
+ git::get_default_branch_in(workdir)
+ }
+
+ fn branch_exists(&self, name: &str) -> Result {
+ git::branch_exists(name)
+ }
+
+ fn branch_exists_in(&self, name: &str, workdir: Option<&Path>) -> Result {
+ git::branch_exists_in(name, workdir)
+ }
+
+ fn get_current_branch(&self) -> Result {
+ git::get_current_branch()
+ }
+
+ fn list_checkout_branches(&self) -> Result> {
+ git::list_checkout_branches()
+ }
+
+ fn delete_branch(&self, name: &str, force: bool, shared_dir: &Path) -> Result<()> {
+ git::delete_branch_in(name, force, shared_dir)
+ }
+
+ fn get_merge_base(&self, main_branch: &str) -> Result {
+ git::get_merge_base(main_branch)
+ }
+
+ fn get_unmerged_branches(&self, base: &str) -> Result> {
+ git::get_unmerged_branches(base)
+ }
+
+ fn get_gone_branches(&self) -> Result> {
+ git::get_gone_branches()
+ }
+
+ // ── Base branch tracking ─────────────────────────────────────────
+
+ fn set_branch_base(&self, branch: &str, base: &str) -> Result<()> {
+ git::set_branch_base(branch, base)
+ }
+
+ fn get_branch_base(&self, branch: &str) -> Result {
+ git::get_branch_base(branch)
+ }
+
+ fn get_branch_base_in(&self, branch: &str, workdir: Option<&Path>) -> Result {
+ git::get_branch_base_in(branch, workdir)
+ }
+
+ // ── Status ───────────────────────────────────────────────────────
+
+ fn get_status(&self, worktree: &Path) -> VcsStatus {
+ git::get_git_status(worktree)
+ }
+
+ fn has_uncommitted_changes(&self, worktree: &Path) -> Result {
+ git::has_uncommitted_changes(worktree)
+ }
+
+ fn has_tracked_changes(&self, worktree: &Path) -> Result {
+ git::has_tracked_changes(worktree)
+ }
+
+ fn has_untracked_files(&self, worktree: &Path) -> Result {
+ git::has_untracked_files(worktree)
+ }
+
+ fn has_staged_changes(&self, worktree: &Path) -> Result {
+ git::has_staged_changes(worktree)
+ }
+
+ fn has_unstaged_changes(&self, worktree: &Path) -> Result {
+ git::has_unstaged_changes(worktree)
+ }
+
+ // ── Merge operations ─────────────────────────────────────────────
+
+ fn commit_with_editor(&self, worktree: &Path) -> Result<()> {
+ git::commit_with_editor(worktree)
+ }
+
+ fn merge_in_workspace(&self, worktree: &Path, branch: &str) -> Result<()> {
+ git::merge_in_worktree(worktree, branch)
+ }
+
+ fn rebase_onto_base(&self, worktree: &Path, base: &str) -> Result<()> {
+ git::rebase_branch_onto_base(worktree, base)
+ }
+
+ fn merge_squash(&self, worktree: &Path, branch: &str) -> Result<()> {
+ git::merge_squash_in_worktree(worktree, branch)
+ }
+
+ fn switch_branch(&self, worktree: &Path, branch: &str) -> Result<()> {
+ git::switch_branch_in_worktree(worktree, branch)
+ }
+
+ fn stash_push(&self, msg: &str, untracked: bool, patch: bool) -> Result<()> {
+ git::stash_push(msg, untracked, patch)
+ }
+
+ fn stash_pop(&self, worktree: &Path) -> Result<()> {
+ git::stash_pop(worktree)
+ }
+
+ fn reset_hard(&self, worktree: &Path) -> Result<()> {
+ git::reset_hard(worktree)
+ }
+
+ fn abort_merge(&self, worktree: &Path) -> Result<()> {
+ git::abort_merge_in_worktree(worktree)
+ }
+
+ // ── Remotes ──────────────────────────────────────────────────────
+
+ fn list_remotes(&self) -> Result> {
+ git::list_remotes()
+ }
+
+ fn remote_exists(&self, name: &str) -> Result {
+ git::remote_exists(name)
+ }
+
+ fn fetch_remote(&self, remote: &str) -> Result<()> {
+ git::fetch_remote(remote)
+ }
+
+ fn fetch_prune(&self) -> Result<()> {
+ git::fetch_prune()
+ }
+
+ fn add_remote(&self, name: &str, url: &str) -> Result<()> {
+ git::add_remote(name, url)
+ }
+
+ fn set_remote_url(&self, name: &str, url: &str) -> Result<()> {
+ git::set_remote_url(name, url)
+ }
+
+ fn get_remote_url(&self, remote: &str) -> Result {
+ git::get_remote_url(remote)
+ }
+
+ fn ensure_fork_remote(&self, owner: &str) -> Result {
+ git::ensure_fork_remote(owner)
+ }
+
+ fn get_repo_owner(&self) -> Result {
+ git::get_repo_owner()
+ }
+
+ // ── Deferred cleanup ─────────────────────────────────────────────
+
+ fn build_cleanup_commands(
+ &self,
+ shared_dir: &Path,
+ branch: &str,
+ handle: &str,
+ keep_branch: bool,
+ force: bool,
+ ) -> Vec {
+ let git_dir = shell_quote(&shared_dir.to_string_lossy());
+
+ let mut cmds = Vec::new();
+
+ // Prune git worktrees
+ cmds.push(format!(
+ "git -C {} worktree prune >/dev/null 2>&1",
+ git_dir
+ ));
+
+ // Delete branch (if not keeping)
+ if !keep_branch {
+ let branch_q = shell_quote(branch);
+ let force_flag = if force { "-D" } else { "-d" };
+ cmds.push(format!(
+ "git -C {} branch {} {} >/dev/null 2>&1",
+ git_dir, force_flag, branch_q
+ ));
+ }
+
+ // Remove worktree metadata from git config
+ let handle_q = shell_quote(handle);
+ cmds.push(format!(
+ "git -C {} config --local --remove-section workmux.worktree.{} >/dev/null 2>&1",
+ git_dir, handle_q
+ ));
+
+ cmds
+ }
+
+ // ── Status cache ─────────────────────────────────────────────────
+
+ fn load_status_cache(&self) -> HashMap {
+ git::load_status_cache()
+ }
+
+ fn save_status_cache(&self, statuses: &HashMap) {
+ git::save_status_cache(statuses)
+ }
+}
diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs
new file mode 100644
index 00000000..59034662
--- /dev/null
+++ b/src/vcs/jj.rs
@@ -0,0 +1,307 @@
+use anyhow::{Result, anyhow};
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+use crate::config::MuxMode;
+
+use super::{Vcs, VcsStatus};
+
+/// Jujutsu (jj) implementation of the Vcs trait.
+///
+/// Stub implementation - methods will be filled in during Phases 3-5.
+pub struct JjVcs;
+
+impl JjVcs {
+ pub fn new() -> Self {
+ JjVcs
+ }
+}
+
+/// Helper to return a "not yet implemented" error for jj operations.
+fn jj_todo(operation: &str) -> anyhow::Error {
+ anyhow!("jj support not yet implemented: {}", operation)
+}
+
+impl Vcs for JjVcs {
+ fn name(&self) -> &str {
+ "jj"
+ }
+
+ // ── Repo detection ───────────────────────────────────────────────
+
+ fn is_repo(&self) -> Result {
+ // Walk up from CWD looking for .jj directory
+ let cwd = std::env::current_dir()?;
+ for dir in cwd.ancestors() {
+ if dir.join(".jj").is_dir() {
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+
+ fn has_commits(&self) -> Result {
+ Err(jj_todo("has_commits"))
+ }
+
+ fn get_repo_root(&self) -> Result {
+ Err(jj_todo("get_repo_root"))
+ }
+
+ fn get_repo_root_for(&self, _dir: &Path) -> Result {
+ Err(jj_todo("get_repo_root_for"))
+ }
+
+ fn get_main_workspace_root(&self) -> Result {
+ Err(jj_todo("get_main_workspace_root"))
+ }
+
+ fn get_shared_dir(&self) -> Result {
+ Err(jj_todo("get_shared_dir"))
+ }
+
+ fn is_path_ignored(&self, _repo_path: &Path, _file_path: &str) -> bool {
+ false // TODO: implement jj ignore checking
+ }
+
+ // ── Workspace lifecycle ──────────────────────────────────────────
+
+ fn workspace_exists(&self, _branch_name: &str) -> Result {
+ Err(jj_todo("workspace_exists"))
+ }
+
+ fn create_workspace(
+ &self,
+ _path: &Path,
+ _branch: &str,
+ _create_branch: bool,
+ _base: Option<&str>,
+ _track_upstream: bool,
+ ) -> Result<()> {
+ Err(jj_todo("create_workspace"))
+ }
+
+ fn list_workspaces(&self) -> Result> {
+ Err(jj_todo("list_workspaces"))
+ }
+
+ fn find_workspace(&self, _name: &str) -> Result<(PathBuf, String)> {
+ Err(jj_todo("find_workspace"))
+ }
+
+ fn get_workspace_path(&self, _branch: &str) -> Result {
+ Err(jj_todo("get_workspace_path"))
+ }
+
+ fn prune_workspaces(&self, _shared_dir: &Path) -> Result<()> {
+ Err(jj_todo("prune_workspaces"))
+ }
+
+ // ── Workspace metadata ───────────────────────────────────────────
+
+ fn set_workspace_meta(&self, _handle: &str, _key: &str, _value: &str) -> Result<()> {
+ Err(jj_todo("set_workspace_meta"))
+ }
+
+ fn get_workspace_meta(&self, _handle: &str, _key: &str) -> Option {
+ None // TODO: implement jj config reading
+ }
+
+ fn get_workspace_mode(&self, _handle: &str) -> MuxMode {
+ MuxMode::Window // Default until jj metadata is implemented
+ }
+
+ fn get_all_workspace_modes(&self) -> HashMap {
+ HashMap::new() // TODO: implement jj config batch reading
+ }
+
+ fn remove_workspace_meta(&self, _handle: &str) -> Result<()> {
+ Err(jj_todo("remove_workspace_meta"))
+ }
+
+ // ── Branch/bookmark operations ───────────────────────────────────
+
+ fn get_default_branch(&self) -> Result {
+ Err(jj_todo("get_default_branch"))
+ }
+
+ fn get_default_branch_in(&self, _workdir: Option<&Path>) -> Result {
+ Err(jj_todo("get_default_branch_in"))
+ }
+
+ fn branch_exists(&self, _name: &str) -> Result {
+ Err(jj_todo("branch_exists"))
+ }
+
+ fn branch_exists_in(&self, _name: &str, _workdir: Option<&Path>) -> Result {
+ Err(jj_todo("branch_exists_in"))
+ }
+
+ fn get_current_branch(&self) -> Result {
+ Err(jj_todo("get_current_branch"))
+ }
+
+ fn list_checkout_branches(&self) -> Result> {
+ Err(jj_todo("list_checkout_branches"))
+ }
+
+ fn delete_branch(&self, _name: &str, _force: bool, _shared_dir: &Path) -> Result<()> {
+ Err(jj_todo("delete_branch"))
+ }
+
+ fn get_merge_base(&self, _main_branch: &str) -> Result {
+ Err(jj_todo("get_merge_base"))
+ }
+
+ fn get_unmerged_branches(&self, _base: &str) -> Result> {
+ Err(jj_todo("get_unmerged_branches"))
+ }
+
+ fn get_gone_branches(&self) -> Result> {
+ Err(jj_todo("get_gone_branches"))
+ }
+
+ // ── Base branch tracking ─────────────────────────────────────────
+
+ fn set_branch_base(&self, _branch: &str, _base: &str) -> Result<()> {
+ Err(jj_todo("set_branch_base"))
+ }
+
+ fn get_branch_base(&self, _branch: &str) -> Result {
+ Err(jj_todo("get_branch_base"))
+ }
+
+ fn get_branch_base_in(&self, _branch: &str, _workdir: Option<&Path>) -> Result {
+ Err(jj_todo("get_branch_base_in"))
+ }
+
+ // ── Status ───────────────────────────────────────────────────────
+
+ fn get_status(&self, _worktree: &Path) -> VcsStatus {
+ VcsStatus::default() // TODO: implement jj status
+ }
+
+ fn has_uncommitted_changes(&self, _worktree: &Path) -> Result {
+ Err(jj_todo("has_uncommitted_changes"))
+ }
+
+ fn has_tracked_changes(&self, _worktree: &Path) -> Result {
+ Err(jj_todo("has_tracked_changes"))
+ }
+
+ fn has_untracked_files(&self, _worktree: &Path) -> Result {
+ Err(jj_todo("has_untracked_files"))
+ }
+
+ fn has_staged_changes(&self, _worktree: &Path) -> Result {
+ // jj has no staging area - "staged" is equivalent to "has changes"
+ Err(jj_todo("has_staged_changes"))
+ }
+
+ fn has_unstaged_changes(&self, _worktree: &Path) -> Result {
+ // jj has no staging area - "unstaged" is equivalent to "has changes"
+ Err(jj_todo("has_unstaged_changes"))
+ }
+
+ // ── Merge operations ─────────────────────────────────────────────
+
+ fn commit_with_editor(&self, _worktree: &Path) -> Result<()> {
+ Err(jj_todo("commit_with_editor"))
+ }
+
+ fn merge_in_workspace(&self, _worktree: &Path, _branch: &str) -> Result<()> {
+ Err(jj_todo("merge_in_workspace"))
+ }
+
+ fn rebase_onto_base(&self, _worktree: &Path, _base: &str) -> Result<()> {
+ Err(jj_todo("rebase_onto_base"))
+ }
+
+ fn merge_squash(&self, _worktree: &Path, _branch: &str) -> Result<()> {
+ Err(jj_todo("merge_squash"))
+ }
+
+ fn switch_branch(&self, _worktree: &Path, _branch: &str) -> Result<()> {
+ Err(jj_todo("switch_branch"))
+ }
+
+ fn stash_push(&self, _msg: &str, _untracked: bool, _patch: bool) -> Result<()> {
+ // jj doesn't need stash - working copy is always committed
+ Ok(())
+ }
+
+ fn stash_pop(&self, _worktree: &Path) -> Result<()> {
+ // jj doesn't need stash - working copy is always committed
+ Ok(())
+ }
+
+ fn reset_hard(&self, _worktree: &Path) -> Result<()> {
+ Err(jj_todo("reset_hard"))
+ }
+
+ fn abort_merge(&self, _worktree: &Path) -> Result<()> {
+ Err(jj_todo("abort_merge"))
+ }
+
+ // ── Remotes ──────────────────────────────────────────────────────
+
+ fn list_remotes(&self) -> Result> {
+ Err(jj_todo("list_remotes"))
+ }
+
+ fn remote_exists(&self, _name: &str) -> Result {
+ Err(jj_todo("remote_exists"))
+ }
+
+ fn fetch_remote(&self, _remote: &str) -> Result<()> {
+ Err(jj_todo("fetch_remote"))
+ }
+
+ fn fetch_prune(&self) -> Result<()> {
+ Err(jj_todo("fetch_prune"))
+ }
+
+ fn add_remote(&self, _name: &str, _url: &str) -> Result<()> {
+ Err(jj_todo("add_remote"))
+ }
+
+ fn set_remote_url(&self, _name: &str, _url: &str) -> Result<()> {
+ Err(jj_todo("set_remote_url"))
+ }
+
+ fn get_remote_url(&self, _remote: &str) -> Result {
+ Err(jj_todo("get_remote_url"))
+ }
+
+ fn ensure_fork_remote(&self, _owner: &str) -> Result {
+ Err(jj_todo("ensure_fork_remote"))
+ }
+
+ fn get_repo_owner(&self) -> Result {
+ Err(jj_todo("get_repo_owner"))
+ }
+
+ // ── Deferred cleanup ─────────────────────────────────────────────
+
+ fn build_cleanup_commands(
+ &self,
+ _shared_dir: &Path,
+ _branch: &str,
+ _handle: &str,
+ _keep_branch: bool,
+ _force: bool,
+ ) -> Vec {
+ Vec::new() // TODO: implement jj cleanup commands
+ }
+
+ // ── Status cache ─────────────────────────────────────────────────
+
+ fn load_status_cache(&self) -> HashMap {
+ // Reuse the same cache infrastructure as git
+ crate::git::load_status_cache()
+ }
+
+ fn save_status_cache(&self, statuses: &HashMap) {
+ crate::git::save_status_cache(statuses)
+ }
+}
diff --git a/src/vcs/mod.rs b/src/vcs/mod.rs
new file mode 100644
index 00000000..a5de3a25
--- /dev/null
+++ b/src/vcs/mod.rs
@@ -0,0 +1,263 @@
+mod git;
+mod jj;
+
+use anyhow::{Result, anyhow};
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use crate::config::MuxMode;
+
+// Re-export VCS implementations
+pub use self::git::GitVcs;
+pub use self::jj::JjVcs;
+
+/// VCS-agnostic status information for a workspace
+pub type VcsStatus = crate::git::GitStatus;
+
+/// Custom error type for workspace not found
+#[derive(Debug, thiserror::Error)]
+#[error("Workspace not found: {0}")]
+pub struct WorkspaceNotFound(pub String);
+
+/// Trait encapsulating all VCS operations needed by workmux.
+///
+/// Implementations exist for git (GitVcs) and jj/Jujutsu (JjVcs).
+pub trait Vcs: Send + Sync {
+ /// Return the name of this VCS backend ("git" or "jj")
+ fn name(&self) -> &str;
+
+ // ── Repo detection ───────────────────────────────────────────────
+
+ /// Check if CWD is inside a repository managed by this VCS
+ fn is_repo(&self) -> Result;
+
+ /// Check if the repository has any commits
+ fn has_commits(&self) -> Result;
+
+ /// Get the root directory of the repository
+ fn get_repo_root(&self) -> Result;
+
+ /// Get the root directory of the repository containing the given path
+ fn get_repo_root_for(&self, dir: &Path) -> Result;
+
+ /// Get the main workspace root (primary worktree or bare repo path)
+ fn get_main_workspace_root(&self) -> Result;
+
+ /// Get the shared directory (git-common-dir or jj repo dir)
+ fn get_shared_dir(&self) -> Result;
+
+ /// Check if a path is ignored by the VCS
+ fn is_path_ignored(&self, repo_path: &Path, file_path: &str) -> bool;
+
+ // ── Workspace lifecycle ──────────────────────────────────────────
+
+ /// Check if a workspace already exists for a branch
+ fn workspace_exists(&self, branch_name: &str) -> Result;
+
+ /// Create a new workspace
+ fn create_workspace(
+ &self,
+ path: &Path,
+ branch: &str,
+ create_branch: bool,
+ base: Option<&str>,
+ track_upstream: bool,
+ ) -> Result<()>;
+
+ /// List all workspaces as (path, branch/bookmark) pairs
+ fn list_workspaces(&self) -> Result>;
+
+ /// Find a workspace by handle (directory name) or branch name.
+ /// Returns (path, branch_name).
+ fn find_workspace(&self, name: &str) -> Result<(PathBuf, String)>;
+
+ /// Get the path to a workspace for a given branch
+ fn get_workspace_path(&self, branch: &str) -> Result;
+
+ /// Prune stale workspace metadata
+ fn prune_workspaces(&self, shared_dir: &Path) -> Result<()>;
+
+ // ── Workspace metadata ───────────────────────────────────────────
+
+ /// Store per-workspace metadata
+ fn set_workspace_meta(&self, handle: &str, key: &str, value: &str) -> Result<()>;
+
+ /// Retrieve per-workspace metadata. Returns None if key doesn't exist.
+ fn get_workspace_meta(&self, handle: &str, key: &str) -> Option;
+
+ /// Determine the mux mode for a workspace from metadata
+ fn get_workspace_mode(&self, handle: &str) -> MuxMode;
+
+ /// Batch-load all workspace modes in a single call
+ fn get_all_workspace_modes(&self) -> HashMap;
+
+ /// Remove all metadata for a workspace handle
+ fn remove_workspace_meta(&self, handle: &str) -> Result<()>;
+
+ // ── Branch/bookmark operations ───────────────────────────────────
+
+ /// Get the default branch (main/master)
+ fn get_default_branch(&self) -> Result;
+
+ /// Get the default branch for a repository at a specific path
+ fn get_default_branch_in(&self, workdir: Option<&Path>) -> Result;
+
+ /// Check if a branch exists
+ fn branch_exists(&self, name: &str) -> Result;
+
+ /// Check if a branch exists in a specific workdir
+ fn branch_exists_in(&self, name: &str, workdir: Option<&Path>) -> Result;
+
+ /// Get the current branch name
+ fn get_current_branch(&self) -> Result;
+
+ /// List branches available for checkout (excluding those already checked out)
+ fn list_checkout_branches(&self) -> Result>;
+
+ /// Delete a branch
+ fn delete_branch(&self, name: &str, force: bool, shared_dir: &Path) -> Result<()>;
+
+ /// Get the base ref for merge checks, preferring local over remote
+ fn get_merge_base(&self, main_branch: &str) -> Result;
+
+ /// Get branches not merged into the base branch
+ fn get_unmerged_branches(&self, base: &str) -> Result>;
+
+ /// Get branches whose upstream tracking branch has been deleted
+ fn get_gone_branches(&self) -> Result>;
+
+ // ── Base branch tracking (metadata) ──────────────────────────────
+
+ /// Store the base branch that a branch was created from
+ fn set_branch_base(&self, branch: &str, base: &str) -> Result<()>;
+
+ /// Retrieve the base branch that a branch was created from
+ fn get_branch_base(&self, branch: &str) -> Result;
+
+ /// Get the base branch for a given branch in a specific workdir
+ fn get_branch_base_in(&self, branch: &str, workdir: Option<&Path>) -> Result;
+
+ // ── Status ───────────────────────────────────────────────────────
+
+ /// Get full VCS status for a workspace (for dashboard display)
+ fn get_status(&self, worktree: &Path) -> VcsStatus;
+
+ /// Check if the workspace has any uncommitted changes
+ fn has_uncommitted_changes(&self, worktree: &Path) -> Result;
+
+ /// Check if the workspace has tracked changes (staged or modified, excluding untracked)
+ fn has_tracked_changes(&self, worktree: &Path) -> Result;
+
+ /// Check if the workspace has untracked files
+ fn has_untracked_files(&self, worktree: &Path) -> Result;
+
+ /// Check if the workspace has staged changes
+ fn has_staged_changes(&self, worktree: &Path) -> Result;
+
+ /// Check if the workspace has unstaged changes
+ fn has_unstaged_changes(&self, worktree: &Path) -> Result;
+
+ // ── Merge operations ─────────────────────────────────────────────
+
+ /// Commit staged changes using the user's editor
+ fn commit_with_editor(&self, worktree: &Path) -> Result<()>;
+
+ /// Merge a branch into the current branch in a workspace
+ fn merge_in_workspace(&self, worktree: &Path, branch: &str) -> Result<()>;
+
+ /// Rebase the current branch onto a base branch
+ fn rebase_onto_base(&self, worktree: &Path, base: &str) -> Result<()>;
+
+ /// Squash merge a branch (stages changes but does not commit)
+ fn merge_squash(&self, worktree: &Path, branch: &str) -> Result<()>;
+
+ /// Switch to a different branch in a workspace
+ fn switch_branch(&self, worktree: &Path, branch: &str) -> Result<()>;
+
+ /// Stash uncommitted changes
+ fn stash_push(&self, msg: &str, untracked: bool, patch: bool) -> Result<()>;
+
+ /// Pop the latest stash in a workspace
+ fn stash_pop(&self, worktree: &Path) -> Result<()>;
+
+ /// Reset the workspace to HEAD, discarding all changes
+ fn reset_hard(&self, worktree: &Path) -> Result<()>;
+
+ /// Abort a merge in progress
+ fn abort_merge(&self, worktree: &Path) -> Result<()>;
+
+ // ── Remotes ──────────────────────────────────────────────────────
+
+ /// List configured remotes
+ fn list_remotes(&self) -> Result>;
+
+ /// Check if a remote exists
+ fn remote_exists(&self, name: &str) -> Result;
+
+ /// Fetch updates from a remote
+ fn fetch_remote(&self, remote: &str) -> Result<()>;
+
+ /// Fetch from remote with prune
+ fn fetch_prune(&self) -> Result<()>;
+
+ /// Add a remote
+ fn add_remote(&self, name: &str, url: &str) -> Result<()>;
+
+ /// Set the URL for an existing remote
+ fn set_remote_url(&self, name: &str, url: &str) -> Result<()>;
+
+ /// Get the URL for a remote
+ fn get_remote_url(&self, remote: &str) -> Result;
+
+ /// Ensure a remote exists for a specific fork owner.
+ /// Returns the remote name.
+ fn ensure_fork_remote(&self, owner: &str) -> Result;
+
+ /// Get the repository owner from the origin remote URL
+ fn get_repo_owner(&self) -> Result;
+
+ // ── Deferred cleanup ─────────────────────────────────────────────
+
+ /// Build shell commands for deferred cleanup.
+ /// Returns individual commands (caller handles joining/formatting).
+ fn build_cleanup_commands(
+ &self,
+ shared_dir: &Path,
+ branch: &str,
+ handle: &str,
+ keep_branch: bool,
+ force: bool,
+ ) -> Vec;
+
+ // ── Status cache ─────────────────────────────────────────────────
+
+ /// Load the status cache from disk
+ fn load_status_cache(&self) -> HashMap;
+
+ /// Save the status cache to disk
+ fn save_status_cache(&self, statuses: &HashMap);
+}
+
+/// Detect the VCS backend for the current directory.
+///
+/// Walks up from CWD looking for `.jj/` or `.git/` directories.
+/// Prefers jj if both are found (colocated repo).
+pub fn detect_vcs() -> Result> {
+ let cwd = std::env::current_dir()?;
+ for dir in cwd.ancestors() {
+ if dir.join(".jj").is_dir() {
+ return Ok(Arc::new(JjVcs::new()));
+ }
+ if dir.join(".git").exists() {
+ return Ok(Arc::new(GitVcs::new()));
+ }
+ }
+ Err(anyhow!("Not in a git or jj repository"))
+}
+
+/// Try to detect VCS, returning None if not in a repository.
+/// Useful for contexts where being outside a repo is not an error (e.g., shell completions).
+pub fn try_detect_vcs() -> Option> {
+ detect_vcs().ok()
+}
From d4a907f5bbd57101159b7a2b558f391acc770245 Mon Sep 17 00:00:00 2001
From: Patrik Sundberg
Date: Sun, 22 Feb 2026 01:28:15 +0000
Subject: [PATCH 2/8] Thread Arc through the entire codebase
Replace all direct git::* calls with Vcs trait calls throughout the
codebase. WorkflowContext now holds vcs: Arc and exposes
shared_dir (renamed from git_common_dir). All workflow/ modules use
context.vcs.* methods. All command/ modules detect VCS via
vcs::detect_vcs() or vcs::try_detect_vcs(). cli.rs and config.rs
use VCS-agnostic repo discovery.
Key changes:
- DeferredCleanup stores pre-computed vcs_cleanup_commands instead
of git_common_dir, keeping it VCS-agnostic
- workflow::list() and agent_resolve functions take &dyn Vcs param
- GitStatus type alias renamed to VcsStatus
- All worktree terminology replaced with workspace in VCS calls
- git::parse_remote_branch_spec kept as git-specific string parsing
---
src/cli.rs | 39 +++---
src/command/add.rs | 14 +-
src/command/capture.rs | 3 +-
src/command/close.rs | 34 ++---
src/command/dashboard/app.rs | 18 ++-
src/command/dashboard/mod.rs | 6 +-
src/command/dashboard/ui/format.rs | 4 +-
src/command/list.rs | 3 +-
src/command/open.rs | 4 +-
src/command/path.rs | 7 +-
src/command/remove.rs | 54 ++++----
src/command/run.rs | 3 +-
src/command/sandbox.rs | 5 +-
src/command/send.rs | 3 +-
src/command/set_base.rs | 10 +-
src/command/status.rs | 16 ++-
src/command/wait.rs | 5 +-
src/config.rs | 22 ++--
src/workflow/agent_resolve.rs | 13 +-
src/workflow/cleanup.rs | 201 +++++++----------------------
src/workflow/context.rs | 39 +++---
src/workflow/create.rs | 65 +++++-----
src/workflow/list.rs | 20 +--
src/workflow/merge.rs | 44 +++----
src/workflow/open.rs | 9 +-
src/workflow/remove.rs | 11 +-
src/workflow/types.rs | 6 +-
27 files changed, 285 insertions(+), 373 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
index 9644ca02..5d47c1e9 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,5 +1,5 @@
use crate::command::args::{MultiArgs, PromptArgs, RescueArgs, SetupFlags};
-use crate::{claude, command, config, git, nerdfont};
+use crate::{claude, command, config, nerdfont, vcs};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate};
@@ -13,18 +13,19 @@ impl WorktreeBranchParser {
}
fn get_branches(&self) -> Vec {
- // Don't attempt completions if not in a git repo.
- if !git::is_git_repo().unwrap_or(false) {
- return Vec::new();
- }
+ // Don't attempt completions if not in a VCS repo.
+ let vcs = match vcs::try_detect_vcs() {
+ Some(v) => v,
+ None => return Vec::new(),
+ };
- let worktrees = match git::list_worktrees() {
+ let worktrees = match vcs.list_workspaces() {
Ok(wt) => wt,
// Fail silently on completion; don't disrupt the user's shell.
Err(_) => return Vec::new(),
};
- let main_branch = git::get_default_branch().ok();
+ let main_branch = vcs.get_default_branch().ok();
worktrees
.into_iter()
@@ -70,18 +71,19 @@ impl WorktreeHandleParser {
}
fn get_handles() -> Vec {
- // Don't attempt completions if not in a git repo.
- if !git::is_git_repo().unwrap_or(false) {
- return Vec::new();
- }
+ // Don't attempt completions if not in a VCS repo.
+ let vcs = match vcs::try_detect_vcs() {
+ Some(v) => v,
+ None => return Vec::new(),
+ };
- let worktrees = match git::list_worktrees() {
+ let worktrees = match vcs.list_workspaces() {
Ok(wt) => wt,
// Fail silently on completion; don't disrupt the user's shell.
Err(_) => return Vec::new(),
};
- let main_worktree_root = git::get_main_worktree_root().ok();
+ let main_worktree_root = vcs.get_main_workspace_root().ok();
worktrees
.into_iter()
@@ -130,13 +132,14 @@ impl GitBranchParser {
}
fn get_branches() -> Vec {
- // Don't attempt completions if not in a git repo.
- if !git::is_git_repo().unwrap_or(false) {
- return Vec::new();
- }
+ // Don't attempt completions if not in a VCS repo.
+ let vcs = match vcs::try_detect_vcs() {
+ Some(v) => v,
+ None => return Vec::new(),
+ };
// Fail silently on completion; don't disrupt the user's shell.
- git::list_checkout_branches().unwrap_or_default()
+ vcs.list_checkout_branches().unwrap_or_default()
}
}
diff --git a/src/command/add.rs b/src/command/add.rs
index 30d39e65..8cec99c7 100644
--- a/src/command/add.rs
+++ b/src/command/add.rs
@@ -10,7 +10,7 @@ use crate::template::{
use crate::workflow::SetupOptions;
use crate::workflow::pr::detect_remote_branch;
use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt, parse_prompt_with_frontmatter};
-use crate::{config, git, workflow};
+use crate::{config, vcs, workflow};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use std::collections::BTreeMap;
@@ -90,11 +90,11 @@ fn read_stdin_lines() -> Result> {
/// Check preconditions for the add command (git repo and multiplexer session).
/// Returns Ok(()) if all preconditions are met, or an error listing all failures.
fn check_preconditions() -> Result<()> {
- let is_git = git::is_git_repo()?;
+ let is_repo = vcs::try_detect_vcs().is_some();
let mux = create_backend(detect_backend());
let is_mux_running = mux.is_running()?;
- if is_git && is_mux_running {
+ if is_repo && is_mux_running {
return Ok(());
}
@@ -103,8 +103,8 @@ fn check_preconditions() -> Result<()> {
if !is_mux_running {
errors.push(format!("{} is not running.", mux.name()));
}
- if !is_git {
- errors.push("Current directory is not a git repository.".to_string());
+ if !is_repo {
+ errors.push("Current directory is not a git or jj repository.".to_string());
}
// Add blank line before suggestions
@@ -113,8 +113,8 @@ fn check_preconditions() -> Result<()> {
if !is_mux_running {
errors.push(format!("Please start a {} session first.", mux.name()));
}
- if !is_git {
- errors.push("Please run this command from within a git repository.".to_string());
+ if !is_repo {
+ errors.push("Please run this command from within a git or jj repository.".to_string());
}
Err(anyhow!(errors.join("\n")))
diff --git a/src/command/capture.rs b/src/command/capture.rs
index 32065084..13fb455c 100644
--- a/src/command/capture.rs
+++ b/src/command/capture.rs
@@ -6,7 +6,8 @@ use crate::workflow;
pub fn run(name: &str, lines: u16) -> Result<()> {
let mux = create_backend(detect_backend());
- let (_path, agent) = workflow::resolve_worktree_agent(name, mux.as_ref())?;
+ let vcs = crate::vcs::detect_vcs()?;
+ let (_path, agent) = workflow::resolve_worktree_agent(name, mux.as_ref(), vcs.as_ref())?;
let output = mux
.capture_pane(&agent.pane_id, lines)
diff --git a/src/command/close.rs b/src/command/close.rs
index 3e83fa48..889104ce 100644
--- a/src/command/close.rs
+++ b/src/command/close.rs
@@ -1,6 +1,6 @@
use crate::multiplexer::handle::mode_label;
use crate::multiplexer::{MuxHandle, create_backend, detect_backend};
-use crate::{config, git, sandbox};
+use crate::{config, sandbox, vcs};
use anyhow::{Context, Result, anyhow};
pub fn run(name: Option<&str>) -> Result<()> {
@@ -8,34 +8,28 @@ pub fn run(name: Option<&str>) -> Result<()> {
let mux = create_backend(detect_backend());
let prefix = config.window_prefix();
- // Resolve the handle first. When the user passes a branch name that differs
- // from the worktree directory name, find_worktree resolves through both handle
- // and branch lookups, then we extract the true handle from the path basename.
+ // Resolve the handle first to determine target mode
let resolved_handle = match name {
- Some(n) => {
- let (path, _branch) = git::find_worktree(n).with_context(|| {
- format!(
- "No worktree found with name '{}'. Use 'workmux list' to see available worktrees.",
- n
- )
- })?;
- path.file_name()
- .ok_or_else(|| anyhow!("Invalid worktree path: no directory name"))?
- .to_string_lossy()
- .to_string()
- }
+ Some(h) => h.to_string(),
None => super::resolve_name(None)?,
};
// Determine if this worktree was created as a session or window
- let mode = git::get_worktree_mode(&resolved_handle);
+ let vcs = vcs::detect_vcs()?;
+ let mode = vcs.get_workspace_mode(&resolved_handle);
// When no name is provided, prefer the current window/session name
// This handles duplicate windows/sessions (e.g., wm:feature-2) correctly
let (full_target_name, is_current_target) = match name {
- Some(_) => {
- // Explicit name provided - worktree already validated above
- let target = MuxHandle::new(mux.as_ref(), mode, prefix, &resolved_handle);
+ Some(handle) => {
+ // Explicit name provided - validate the worktree exists
+ vcs.find_workspace(handle).with_context(|| {
+ format!(
+ "No worktree found with name '{}'. Use 'workmux list' to see available worktrees.",
+ handle
+ )
+ })?;
+ let target = MuxHandle::new(mux.as_ref(), mode, prefix, handle);
let full = target.full_name();
let current = target.current_name()?;
let is_current = current.as_deref() == Some(full.as_str());
diff --git a/src/command/dashboard/app.rs b/src/command/dashboard/app.rs
index ff454681..14098544 100644
--- a/src/command/dashboard/app.rs
+++ b/src/command/dashboard/app.rs
@@ -10,7 +10,7 @@ use std::sync::{Arc, mpsc};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::config::Config;
-use crate::git::{self, GitStatus};
+use crate::vcs::{self, VcsStatus};
use crate::github::PrSummary;
use crate::multiplexer::{AgentPane, AgentStatus, Multiplexer};
use crate::state::StateStore;
@@ -69,11 +69,11 @@ pub struct App {
/// Height of the preview area (updated during rendering)
pub preview_height: u16,
/// Git status for each worktree path
- pub git_statuses: HashMap,
+ pub git_statuses: HashMap,
/// Channel receiver for git status updates from background thread
- git_rx: mpsc::Receiver<(PathBuf, GitStatus)>,
+ git_rx: mpsc::Receiver<(PathBuf, VcsStatus)>,
/// Channel sender for git status updates (cloned for background threads)
- git_tx: mpsc::Sender<(PathBuf, GitStatus)>,
+ git_tx: mpsc::Sender<(PathBuf, VcsStatus)>,
/// Last time git status was fetched (to throttle background fetches)
last_git_fetch: std::time::Instant,
/// Flag to track if a git fetch is in progress (prevents thread pile-up)
@@ -124,7 +124,9 @@ impl App {
let palette = ThemePalette::from_theme(config.theme);
let sort_mode = SortMode::load();
- let git_statuses = git::load_status_cache();
+ let git_statuses = vcs::try_detect_vcs()
+ .map(|v| v.load_status_cache())
+ .unwrap_or_default();
let pr_statuses = crate::github::load_pr_cache();
let hide_stale = load_hide_stale();
let last_pane_id = load_last_pane_id();
@@ -204,7 +206,7 @@ impl App {
.into_iter()
.map(|path| {
std::thread::spawn(move || {
- let root = git::get_repo_root_for(&path).ok();
+ let root = vcs::try_detect_vcs().and_then(|v| v.get_repo_root_for(&path).ok());
(path, root)
})
})
@@ -322,7 +324,9 @@ impl App {
let _reset = ResetFlag(is_fetching);
for path in agent_paths {
- let status = git::get_git_status(&path);
+ let status = vcs::try_detect_vcs()
+ .map(|v| v.get_status(&path))
+ .unwrap_or_default();
// Ignore send errors (receiver dropped means app is shutting down)
let _ = tx.send((path, status));
}
diff --git a/src/command/dashboard/mod.rs b/src/command/dashboard/mod.rs
index c89b1044..574d49e2 100644
--- a/src/command/dashboard/mod.rs
+++ b/src/command/dashboard/mod.rs
@@ -45,8 +45,8 @@ use ratatui::backend::CrosstermBackend;
use std::io;
use std::time::Duration;
-use crate::git;
use crate::github;
+use crate::vcs;
use crate::multiplexer::{create_backend, detect_backend};
use self::actions::apply_action;
@@ -233,7 +233,9 @@ pub fn run(cli_preview_size: Option, open_diff: bool) -> Result<()> {
}
// Save git status cache before exiting
- git::save_status_cache(&app.git_statuses);
+ if let Some(v) = vcs::try_detect_vcs() {
+ v.save_status_cache(&app.git_statuses);
+ };
// Save PR status cache before exiting
github::save_pr_cache(app.pr_statuses());
diff --git a/src/command/dashboard/ui/format.rs b/src/command/dashboard/ui/format.rs
index 9b291024..97d43acd 100644
--- a/src/command/dashboard/ui/format.rs
+++ b/src/command/dashboard/ui/format.rs
@@ -2,7 +2,7 @@
use ratatui::style::{Color, Modifier, Style};
-use crate::git::GitStatus;
+use crate::vcs::VcsStatus;
use crate::github::{CheckState, PrSummary};
use crate::nerdfont;
@@ -13,7 +13,7 @@ use super::theme::ThemePalette;
/// Format: "→branch +N -M +X -Y ↑A ↓B"
/// When there are uncommitted changes that differ from total, branch totals are dimmed
pub fn format_git_status(
- status: Option<&GitStatus>,
+ status: Option<&VcsStatus>,
spinner_frame: u8,
palette: &ThemePalette,
) -> Vec<(String, Style)> {
diff --git a/src/command/list.rs b/src/command/list.rs
index 0140ae06..ff488032 100644
--- a/src/command/list.rs
+++ b/src/command/list.rs
@@ -112,7 +112,8 @@ fn format_agent_status(
pub fn run(show_pr: bool, filter: &[String]) -> Result<()> {
let config = config::Config::load(None)?;
let mux = create_backend(detect_backend());
- let worktrees = workflow::list(&config, mux.as_ref(), show_pr, filter)?;
+ let vcs = crate::vcs::detect_vcs()?;
+ let worktrees = workflow::list(&config, mux.as_ref(), vcs.as_ref(), show_pr, filter)?;
if worktrees.is_empty() {
println!("No worktrees found");
diff --git a/src/command/open.rs b/src/command/open.rs
index 575bb004..81d2457c 100644
--- a/src/command/open.rs
+++ b/src/command/open.rs
@@ -3,7 +3,7 @@ use crate::config::MuxMode;
use crate::multiplexer::{create_backend, detect_backend};
use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt};
use crate::workflow::{SetupOptions, WorkflowContext};
-use crate::{config, git, workflow};
+use crate::{config, workflow};
use anyhow::{Context, Result, bail};
pub fn run(
@@ -27,7 +27,7 @@ pub fn run(
let context = WorkflowContext::new(config, mux, config_location)?;
// Determine the target mode from stored metadata
- let stored_mode = git::get_worktree_mode(&resolved_name);
+ let stored_mode = context.vcs.get_workspace_mode(&resolved_name);
let target_type = match stored_mode {
MuxMode::Session => "session",
MuxMode::Window => "window",
diff --git a/src/command/path.rs b/src/command/path.rs
index 66858d58..6a482337 100644
--- a/src/command/path.rs
+++ b/src/command/path.rs
@@ -1,11 +1,12 @@
-use crate::git;
+use crate::vcs;
use anyhow::{Context, Result};
pub fn run(name: &str) -> Result<()> {
+ let vcs = vcs::detect_vcs()?;
// Smart resolution: try handle first, then branch name
- let (path, _branch) = git::find_worktree(name).with_context(|| {
+ let (path, _branch) = vcs.find_workspace(name).with_context(|| {
format!(
- "No worktree found with name '{}'. Use 'workmux list' to see available worktrees.",
+ "No workspace found with name '{}'. Use 'workmux list' to see available workspaces.",
name
)
})?;
diff --git a/src/command/remove.rs b/src/command/remove.rs
index 09f825cb..30019da2 100644
--- a/src/command/remove.rs
+++ b/src/command/remove.rs
@@ -1,6 +1,6 @@
use crate::multiplexer::{create_backend, detect_backend};
use crate::workflow::WorkflowContext;
-use crate::{config, git, spinner, workflow};
+use crate::{config, spinner, vcs, workflow};
use anyhow::{Context, Result, anyhow};
use std::io::{self, Write};
use std::path::PathBuf;
@@ -35,11 +35,13 @@ fn run_specified(names: Vec, force: bool, keep_branch: bool) -> Result<(
.collect::>>()?
};
+ let vcs = vcs::detect_vcs()?;
+
// 2. Resolve all targets and validate they exist
let mut candidates: Vec<(String, PathBuf, String)> = Vec::new();
for name in resolved_names {
- let (worktree_path, branch_name) = git::find_worktree(&name)
- .with_context(|| format!("No worktree found with name '{}'", name))?;
+ let (worktree_path, branch_name) = vcs.find_workspace(&name)
+ .with_context(|| format!("No workspace found with name '{}'", name))?;
let handle = worktree_path
.file_name()
@@ -83,13 +85,13 @@ fn run_specified(names: Vec, force: bool, keep_branch: bool) -> Result<(
for (handle, path, branch) in candidates {
// Check uncommitted (blocking)
- if path.exists() && git::has_uncommitted_changes(&path).unwrap_or(false) {
+ if path.exists() && vcs.has_uncommitted_changes(&path).unwrap_or(false) {
uncommitted.push(handle);
continue;
}
// Check unmerged (promptable), only if we're deleting the branch
- if !keep_branch && let Some(base) = is_unmerged(&branch)? {
+ if !keep_branch && let Some(base) = is_unmerged(vcs.as_ref(), &branch)? {
unmerged.push((handle, branch, base));
continue;
}
@@ -144,25 +146,25 @@ fn run_specified(names: Vec, force: bool, keep_branch: bool) -> Result<(
}
/// Check if a branch has unmerged commits. Returns Some(base) if unmerged, None otherwise.
-fn is_unmerged(branch: &str) -> Result
- Parallel development in tmux* with git worktrees
+ Parallel development in tmux* with git worktrees & jj workspaces
@@ -20,7 +20,7 @@
---
Giga opinionated zero-friction workflow tool for managing
-[git worktrees](https://git-scm.com/docs/git-worktree) and tmux windows as
+[git worktrees](https://git-scm.com/docs/git-worktree) (or [jj workspaces](https://jj-vcs.github.io/jj/latest/working-copy/#workspaces)) and tmux windows as
isolated development environments. Perfect for running multiple AI agents in
parallel without conflict.
@@ -1920,12 +1920,16 @@ entire process and pairs each worktree with a dedicated tmux window, creating
fully isolated development environments. See
[Before and after](#before-and-after) for how workmux streamlines this workflow.
+**Using jj?** workmux also supports [jj (Jujutsu)](https://jj-vcs.github.io/jj/) natively. jj workspaces provide the same parallel development benefits. workmux auto-detects your VCS backend.
+
## Git worktree caveats
While powerful, git worktrees have nuances that are important to understand.
workmux is designed to automate solutions to these, but awareness of the
underlying mechanics helps.
+> **Note:** These caveats are specific to git worktrees. If you're using jj, some (like ignored files and conflicts) still apply to jj workspaces, while git-specific ones (like `.git/info/exclude`) do not.
+
- [Gitignored files require configuration](#gitignored-files-require-configuration)
- [Conflicts](#conflicts)
- [Package manager considerations (pnpm, yarn)](#package-manager-considerations-pnpm-yarn)
@@ -2242,7 +2246,7 @@ workmux completions fish | source
## Requirements
- Rust (for building)
-- Git 2.5+ (for worktree support)
+- Git 2.5+ (for worktree support) or [jj](https://jj-vcs.github.io/jj/) (Jujutsu)
- tmux (or an alternative backend)
### Alternative backends
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 47f5d99a..3b77eef9 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -117,7 +117,7 @@ export default defineConfig({
{ text: "Session mode", link: "/guide/session-mode" },
{ text: "direnv", link: "/guide/direnv" },
{ text: "Monorepos", link: "/guide/monorepos" },
- { text: "Git worktree caveats", link: "/guide/git-worktree-caveats" },
+ { text: "Worktree caveats", link: "/guide/git-worktree-caveats" },
{ text: "Nix", link: "/guide/nix" },
],
},
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index a9d7a9ee..d48730f7 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -140,7 +140,7 @@ windows:
### File operations
-New worktrees are clean checkouts with no gitignored files (`.env`, `node_modules`, etc.). Use `files` to automatically copy or symlink what each worktree needs:
+New worktrees are clean checkouts with no ignored files (`.env`, `node_modules`, etc.). Use `files` to automatically copy or symlink what each worktree needs:
```yaml
files:
diff --git a/docs/guide/git-worktree-caveats.md b/docs/guide/git-worktree-caveats.md
index 1e698ca2..73bbbd2d 100644
--- a/docs/guide/git-worktree-caveats.md
+++ b/docs/guide/git-worktree-caveats.md
@@ -1,14 +1,16 @@
---
-description: Common git worktree pitfalls and how workmux handles them
+description: Common worktree pitfalls and how workmux handles them
---
-# Git worktree caveats
+# Worktree caveats
-While powerful, git worktrees have nuances that are important to understand. workmux is designed to automate solutions to these, but awareness of the underlying mechanics helps.
+While powerful, worktrees (git worktrees and jj workspaces) have nuances that are important to understand. workmux is designed to automate solutions to these, but awareness of the underlying mechanics helps.
-## Gitignored files require configuration
+> **Note:** Most caveats below apply to both git worktrees and jj workspaces. Sections that are git-specific are marked as such.
-When `git worktree add` creates a new working directory, it's a clean checkout. Files listed in your `.gitignore` (e.g., `.env` files, `node_modules`, IDE configuration) will not exist in the new worktree by default. Your application will be broken in the new worktree until you manually create or link these necessary files.
+## Ignored files require configuration
+
+When a new worktree is created (via `git worktree add` or `jj workspace add`), it's a clean checkout. Ignored files (e.g., `.env` files, `node_modules`, IDE configuration) will not exist in the new worktree by default. Your application will be broken in the new worktree until you manually create or link these necessary files.
This is a primary feature of workmux. Use the `files` section in your `.workmux.yaml` to automatically copy or symlink these files on creation:
@@ -36,9 +38,9 @@ panes:
## Conflicts
-Worktrees isolate your filesystem, but they do not prevent merge conflicts. If you modify the same area of code on two different branches (in two different worktrees), you will still have a conflict when you merge one into the other.
+Worktrees isolate your filesystem, but they do not prevent merge conflicts. If you modify the same area of code on two different branches (in two different worktrees), you will still have a conflict when you merge one into the other. This applies to both git and jj.
-The best practice is to work on logically separate features in parallel worktrees. When conflicts are unavoidable, use standard git tools to resolve them. You can also leverage an AI agent within the worktree to assist with the conflict resolution.
+The best practice is to work on logically separate features in parallel worktrees. When conflicts are unavoidable, use standard VCS tools to resolve them (`git` conflict resolution or `jj resolve`). You can also leverage an AI agent within the worktree to assist with the conflict resolution.
## Package manager considerations (pnpm, yarn)
@@ -69,7 +71,7 @@ rustc-wrapper = "sccache"
This caches compiled dependencies globally, so new worktrees benefit from cached artifacts without any lock contention.
-## Symlinks and `.gitignore` trailing slashes
+## Symlinks and `.gitignore` trailing slashes (git-specific)
If your `.gitignore` uses a trailing slash to ignore directories (e.g., `tests/venv/`), symlinks to that path in the created worktree will **not** be ignored and will show up in `git status`. This is because `venv/` only matches directories, not files (symlinks).
@@ -80,7 +82,7 @@ To ignore both directories and symlinks, remove the trailing slash:
+ tests/venv
```
-## Local git ignores are not shared
+## Local git ignores are not shared (git-specific)
The local git ignore file, `.git/info/exclude`, is specific to the main worktree's git directory and is not respected in other worktrees. Personal ignore patterns for your editor or temporary files may not apply in new worktrees, causing them to appear in `git status`.
diff --git a/docs/guide/index.md b/docs/guide/index.md
index 7cc87fc6..c6e93de5 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -4,7 +4,7 @@ description: A workflow tool for managing git worktrees and tmux windows as isol
# What is workmux?
-workmux is a giga opinionated zero-friction workflow tool for managing [git worktrees](https://git-scm.com/docs/git-worktree) and tmux windows as isolated development environments. Also supports [kitty](/guide/kitty), [WezTerm](/guide/wezterm), and [Zellij](/guide/zellij) (experimental). Perfect for running multiple AI agents in parallel without conflict.
+workmux is a giga opinionated zero-friction workflow tool for managing [git worktrees](https://git-scm.com/docs/git-worktree) (or [jj workspaces](https://jj-vcs.github.io/jj/latest/working-copy/#workspaces)) and tmux windows as isolated development environments. Also supports [kitty](/guide/kitty), [WezTerm](/guide/wezterm), and [Zellij](/guide/zellij) (experimental). Perfect for running multiple AI agents in parallel without conflict.
**Philosophy:** Do one thing well, then compose. Your terminal handles windowing and layout, git handles branches and worktrees, your agent executes, and workmux ties it all together.
@@ -122,7 +122,7 @@ state, editor session, dev server, and AI agent. Context switching is switching
## Features
-- Create git worktrees with matching tmux windows (or kitty/WezTerm/Zellij tabs) in a single command (`add`)
+- Create worktrees (git) or workspaces (jj) with matching tmux windows (or kitty/WezTerm/Zellij tabs) in a single command (`add`)
- Merge branches and clean up everything (worktree, tmux window, branches) in one command (`merge`)
- [Dashboard](/guide/dashboard/) for monitoring agents, reviewing changes, and sending commands
- [Delegate tasks to worktree agents](/guide/skills#-worktree) with a `/worktree` skill
@@ -186,9 +186,11 @@ workmux merge
In a standard Git setup, switching branches disrupts your flow by requiring a clean working tree. Worktrees remove this friction. `workmux` automates the entire process and pairs each worktree with a dedicated tmux window, creating fully isolated development environments.
+**Using jj?** workmux also supports [jj (Jujutsu)](https://jj-vcs.github.io/jj/) natively. jj workspaces provide the same parallel development benefits. workmux auto-detects your VCS backend.
+
## Requirements
-- Git 2.5+ (for worktree support)
+- Git 2.5+ (for worktree support) or [jj](https://jj-vcs.github.io/jj/) (Jujutsu)
- tmux (or [WezTerm](/guide/wezterm), [kitty](/guide/kitty), or [Zellij](/guide/zellij))
## Inspiration and related tools
diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md
index 4f8154d6..a214f5b2 100644
--- a/docs/guide/quick-start.md
+++ b/docs/guide/quick-start.md
@@ -32,7 +32,7 @@ workmux add new-feature
This will:
-- Create a git worktree at `/../__worktrees/new-feature`
+- Create a worktree (git) or workspace (jj) at `/../__worktrees/new-feature`
- Copy config files and symlink dependencies (if [configured](/guide/configuration#file-operations))
- Run any [`post_create`](/guide/configuration#lifecycle-hooks) setup commands
- Create a tmux window named `wm-new-feature` (the prefix is configurable)
diff --git a/docs/guide/workflows.md b/docs/guide/workflows.md
index bd2042df..6ad24757 100644
--- a/docs/guide/workflows.md
+++ b/docs/guide/workflows.md
@@ -117,6 +117,9 @@ git push -u origin feature-123
gh pr create
```
+> For jj users, push with `jj git push` instead.
+
+
Once your PR is merged on GitHub, use `workmux remove` to clean up:
```bash
@@ -127,4 +130,4 @@ workmux remove feature-123
workmux rm --gone
```
-The `--gone` flag is particularly useful - it automatically finds worktrees whose upstream branches no longer exist (because the PR was merged and the branch was deleted on GitHub) and removes them.
+The `--gone` flag is particularly useful - it automatically finds worktrees whose upstream branches no longer exist (because the PR was merged and the branch was deleted on GitHub) and removes them. For jj repos, workmux detects gone branches via `jj git fetch` and bookmark tracking.
diff --git a/docs/index.md b/docs/index.md
index 614fedf3..953f9f13 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,6 +1,6 @@
---
layout: home
-description: The zero-friction workflow for git worktrees and tmux, kitty, WezTerm, or Zellij
+description: The zero-friction workflow for git worktrees, jj workspaces, and tmux, kitty, WezTerm, or Zellij
---
@@ -61,7 +61,7 @@ description: The zero-friction workflow for git worktrees and tmux, kitty, WezTe
Worktree pain points, solved
-
Git worktrees are powerful, but managing them manually is painful. workmux automates the rough edges.
+
Git worktrees (and jj workspaces) are powerful, but managing them manually is painful. workmux automates the rough edges.
"You need to reinstall everything"
diff --git a/docs/reference/commands/add.md b/docs/reference/commands/add.md
index b295da46..c47a83a6 100644
--- a/docs/reference/commands/add.md
+++ b/docs/reference/commands/add.md
@@ -1,10 +1,10 @@
---
-description: Create git worktrees and tmux windows, with support for AI prompts and parallel generation
+description: Create worktrees and tmux windows, with support for AI prompts and parallel generation
---
# add
-Creates a new git worktree with a matching tmux window and switches you to it immediately. If the branch doesn't exist, it will be created automatically.
+Creates a new worktree with a matching tmux window and switches you to it immediately. If the branch doesn't exist, it will be created automatically.
```bash
workmux add [flags]
@@ -48,7 +48,7 @@ These options allow you to skip expensive setup steps when they're not needed (e
## What happens
1. Determines the **handle** for the worktree by slugifying the branch name (e.g., `feature/auth` becomes `feature-auth`). This can be overridden with the `--name` flag.
-2. Creates a git worktree at `/` (the `worktree_dir` is configurable and defaults to a sibling directory of your project)
+2. Creates a worktree (via `git worktree add` or `jj workspace add`) at `/` (the `worktree_dir` is configurable and defaults to a sibling directory of your project)
3. Runs any configured file operations (copy/symlink)
4. Executes `post_create` commands if defined (runs before the tmux window/session opens, so keep them fast)
5. Creates a new tmux window named `` (e.g., `wm-feature-auth` with `window_prefix: wm-`). With `--session`, the window is created in its own dedicated session instead of the current session.
diff --git a/docs/reference/commands/list.md b/docs/reference/commands/list.md
index 66470eb9..db6e328c 100644
--- a/docs/reference/commands/list.md
+++ b/docs/reference/commands/list.md
@@ -1,10 +1,10 @@
---
-description: List all git worktrees with their agent, window, and merge status
+description: List all worktrees with their agent, window, and merge status
---
# list
-Lists all git worktrees with their agent status, multiplexer window status, and merge status. Alias: `ls`
+Lists all worktrees with their agent status, multiplexer window status, and merge status. Alias: `ls`
```bash
workmux list [options] [worktree-or-branch...]
diff --git a/docs/reference/commands/merge.md b/docs/reference/commands/merge.md
index e575d401..7abee0dc 100644
--- a/docs/reference/commands/merge.md
+++ b/docs/reference/commands/merge.md
@@ -35,8 +35,8 @@ If your workflow uses pull requests, the merge happens on the remote after revie
By default, `workmux merge` performs a standard merge commit (configurable via `merge_strategy`). You can override the configured behavior with these mutually exclusive flags:
-- `--rebase`: Rebase the feature branch onto the target before merging (creates a linear history via fast-forward merge). If conflicts occur, you'll need to resolve them manually in the worktree and run `git rebase --continue`.
-- `--squash`: Squash all commits from the feature branch into a single commit on the target. You'll be prompted to provide a commit message in your editor.
+- `--rebase`: Rebase the feature branch onto the target before merging (creates a linear history via fast-forward merge). If conflicts occur, you'll need to resolve them manually in the worktree and run `git rebase --continue`. For jj repos, this uses `jj rebase`.
+- `--squash`: Squash all commits from the feature branch into a single commit on the target. You'll be prompted to provide a commit message in your editor. For jj repos, this uses `jj squash`.
If you don't want to have merge commits in your main branch, use the `rebase` merge strategy, which does `--rebase` by default.
diff --git a/docs/reference/commands/remove.md b/docs/reference/commands/remove.md
index 38b19f18..88b6d781 100644
--- a/docs/reference/commands/remove.md
+++ b/docs/reference/commands/remove.md
@@ -19,7 +19,7 @@ workmux remove [name]... [flags]
| Flag | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--all` | Remove all worktrees at once (except the main worktree). Prompts for confirmation unless `--force` is used. Safely skips worktrees with uncommitted changes or unmerged commits. |
-| `--gone` | Remove worktrees whose upstream remote branch has been deleted (e.g., after a PR is merged on GitHub). Automatically runs `git fetch --prune` first. |
+| `--gone` | Remove worktrees whose upstream remote branch has been deleted (e.g., after a PR is merged on GitHub). Automatically runs `git fetch --prune` (or `jj git fetch` for jj repos) first. |
| `--force, -f` | Skip confirmation prompt and ignore uncommitted changes. |
| `--keep-branch, -k` | Remove only the worktree and tmux window while keeping the local branch. |
diff --git a/skills/merge/SKILL.md b/skills/merge/SKILL.md
index b646496c..d306b668 100644
--- a/skills/merge/SKILL.md
+++ b/skills/merge/SKILL.md
@@ -24,6 +24,17 @@ This command finishes work on the current branch by:
2. Rebasing onto the base branch
3. Running `workmux merge` to merge and clean up
+## Step 0: Detect VCS
+
+Determine the VCS backend:
+- If `.jj/` directory exists at or above the current directory → jj mode
+- Otherwise → git mode
+
+For jj mode, adapt the steps below:
+- Step 1: Use `jj describe` instead of `git commit`
+- Step 2: Use `jj rebase -d ` instead of `git rebase`
+- For conflicts: Use `jj resolve` and inspect with `jj diff`
+
## Step 1: Commit
If there are staged changes, commit them. Use lowercase, imperative mood, no conventional commit prefixes. Skip if nothing is staged.
diff --git a/skills/rebase/SKILL.md b/skills/rebase/SKILL.md
index 59697952..a4d3d5a7 100644
--- a/skills/rebase/SKILL.md
+++ b/skills/rebase/SKILL.md
@@ -11,6 +11,13 @@ Rebase the current branch.
Arguments: $ARGUMENTS
+## Step 0: Detect VCS
+
+If `.jj/` exists at or above the current directory → use jj commands:
+- `jj git fetch` instead of `git fetch`
+- `jj rebase -d ` instead of `git rebase`
+- For conflicts: `jj resolve` instead of manual conflict resolution + `git rebase --continue`
+
Behavior:
- No arguments: rebase on local main
diff --git a/skills/worktree/SKILL.md b/skills/worktree/SKILL.md
index e1366d3b..97cbcf2c 100644
--- a/skills/worktree/SKILL.md
+++ b/skills/worktree/SKILL.md
@@ -1,11 +1,11 @@
---
name: worktree
-description: Launch one or more tasks in new git worktrees using workmux.
+description: Launch one or more tasks in new worktrees using workmux.
disable-model-invocation: true
allowed-tools: Bash, Write
---
-Launch one or more tasks in new git worktrees using workmux.
+Launch one or more tasks in new worktrees using workmux.
Tasks: $ARGUMENTS
From fcfc79e681bd7434c15687b0fa0a93da49819795 Mon Sep 17 00:00:00 2001
From: Patrik Sundberg
Date: Wed, 4 Mar 2026 12:29:01 +0000
Subject: [PATCH 8/8] add stale worktree lock removal to GitVcs abstraction
git worktree prune silently skips locked worktrees. If git worktree add
was interrupted, it leaves a "locked" file in $GIT_COMMON_DIR/worktrees//
that prevents prune from cleaning up the metadata, causing "cannot delete
branch used by worktree" errors during cleanup.
Add resolve_worktree_admin_dir() to GitVcs which reads the worktree's
.git file to find the admin directory (with fallback to the conventional
path), then:
- remove_workspace_lock(): resolves admin dir and removes the lock file
before pruning in the synchronous cleanup path
- build_cleanup_commands(): includes an rm -f command before
the prune command in deferred cleanup scripts
Both methods are no-ops in JjVcs since jj has no equivalent lock
mechanism.
---
src/vcs/git.rs | 68 ++++++++++++++++++++++++++++++++++++++
src/vcs/jj.rs | 8 +++++
src/vcs/mod.rs | 10 ++++++
src/workflow/cleanup.rs | 72 ++++++++++++++++++++++++++++++++++++++++-
4 files changed, 157 insertions(+), 1 deletion(-)
diff --git a/src/vcs/git.rs b/src/vcs/git.rs
index 3fcdfac4..52f582ef 100644
--- a/src/vcs/git.rs
+++ b/src/vcs/git.rs
@@ -7,6 +7,7 @@ use crate::git;
use crate::shell::shell_quote;
use super::{Vcs, VcsStatus};
+use tracing::{debug, warn};
/// Git implementation of the Vcs trait.
///
@@ -19,6 +20,51 @@ impl GitVcs {
}
}
+/// Read the worktree's `.git` file to find the admin directory path.
+///
+/// Linked worktrees have a `.git` file (not directory) containing `gitdir: `.
+/// The path may be absolute or relative to the worktree directory.
+/// Falls back to `$GIT_COMMON_DIR/worktrees/` if the `.git` file is missing
+/// (e.g., the worktree directory was already deleted).
+fn resolve_worktree_admin_dir(
+ worktree_path: &Path,
+ git_common_dir: &Path,
+) -> Option {
+ let git_file = worktree_path.join(".git");
+ if git_file.is_file() {
+ match std::fs::read_to_string(&git_file) {
+ Ok(content) => {
+ if let Some(raw) = content.trim().strip_prefix("gitdir: ") {
+ let p = Path::new(raw.trim());
+ let abs = if p.is_absolute() {
+ p.to_path_buf()
+ } else {
+ worktree_path.join(p)
+ };
+ return Some(abs);
+ }
+ warn!(
+ path = %git_file.display(),
+ "cleanup:worktree .git file missing 'gitdir:' prefix"
+ );
+ }
+ Err(e) => {
+ warn!(
+ path = %git_file.display(),
+ error = %e,
+ "cleanup:failed to read worktree .git file"
+ );
+ }
+ }
+ }
+
+ // Fallback: construct expected admin dir from the worktree directory name.
+ // Git uses the basename of the worktree path as the admin directory name.
+ worktree_path
+ .file_name()
+ .map(|name| git_common_dir.join("worktrees").join(name))
+}
+
impl Vcs for GitVcs {
fn name(&self) -> &str {
"git"
@@ -87,6 +133,19 @@ impl Vcs for GitVcs {
git::prune_worktrees_in(shared_dir)
}
+ fn remove_workspace_lock(&self, worktree_path: &Path, shared_dir: &Path) {
+ if let Some(admin_dir) = resolve_worktree_admin_dir(worktree_path, shared_dir) {
+ let locked_file = admin_dir.join("locked");
+ match std::fs::remove_file(&locked_file) {
+ Ok(()) => debug!(path = %locked_file.display(), "cleanup:removed worktree lock"),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => {
+ warn!(path = %locked_file.display(), error = %e, "cleanup:failed to remove worktree lock")
+ }
+ }
+ }
+ }
+
// ── Workspace metadata ───────────────────────────────────────────
fn set_workspace_meta(&self, handle: &str, key: &str, value: &str) -> Result<()> {
@@ -276,11 +335,20 @@ impl Vcs for GitVcs {
handle: &str,
keep_branch: bool,
force: bool,
+ worktree_path: &Path,
) -> Vec {
let git_dir = shell_quote(&shared_dir.to_string_lossy());
let mut cmds = Vec::new();
+ // Remove worktree lock if present (git worktree prune skips locked entries)
+ if let Some(ref admin_dir) = resolve_worktree_admin_dir(worktree_path, shared_dir) {
+ if admin_dir.is_absolute() {
+ let locked = shell_quote(&admin_dir.join("locked").to_string_lossy());
+ cmds.push(format!("rm -f {} >/dev/null 2>&1", locked));
+ }
+ }
+
// Prune git worktrees
cmds.push(format!(
"git -C {} worktree prune >/dev/null 2>&1",
diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs
index fb6c02d6..9e955ff6 100644
--- a/src/vcs/jj.rs
+++ b/src/vcs/jj.rs
@@ -405,6 +405,10 @@ impl Vcs for JjVcs {
Ok(())
}
+ fn remove_workspace_lock(&self, _worktree_path: &Path, _shared_dir: &Path) {
+ // jj has no equivalent worktree lock mechanism
+ }
+
// ── Workspace metadata ───────────────────────────────────────────
fn set_workspace_meta(&self, handle: &str, key: &str, value: &str) -> Result<()> {
@@ -1033,6 +1037,7 @@ impl Vcs for JjVcs {
handle: &str,
keep_branch: bool,
_force: bool,
+ _worktree_path: &Path,
) -> Vec {
let repo_dir = shell_quote(&shared_dir.to_string_lossy());
let mut cmds = Vec::new();
@@ -1246,6 +1251,7 @@ mod tests {
"my-handle",
false, // don't keep branch
false,
+ Path::new("/repo/worktrees/my-handle"),
);
assert_eq!(cmds.len(), 4); // forget + bookmark delete + 2 config unsets
@@ -1268,6 +1274,7 @@ mod tests {
"handle",
true, // keep branch
false,
+ Path::new("/repo/worktrees/handle"),
);
assert_eq!(cmds.len(), 3); // forget + 2 config unsets (no bookmark delete)
@@ -1284,6 +1291,7 @@ mod tests {
"handle",
false,
false,
+ Path::new("/path/worktrees/handle"),
);
// Should use shell quoting for paths with spaces
diff --git a/src/vcs/mod.rs b/src/vcs/mod.rs
index a5de3a25..8fb49dd3 100644
--- a/src/vcs/mod.rs
+++ b/src/vcs/mod.rs
@@ -78,6 +78,15 @@ pub trait Vcs: Send + Sync {
/// Prune stale workspace metadata
fn prune_workspaces(&self, shared_dir: &Path) -> Result<()>;
+ /// Remove stale workspace locks that would prevent pruning.
+ ///
+ /// Must be called while the workspace directory still exists (before rename/move),
+ /// since the implementation may need to read files inside it to resolve metadata paths.
+ ///
+ /// For git: resolves the worktree admin directory and removes any stale `locked` file.
+ /// For jj: no-op.
+ fn remove_workspace_lock(&self, worktree_path: &Path, shared_dir: &Path);
+
// ── Workspace metadata ───────────────────────────────────────────
/// Store per-workspace metadata
@@ -228,6 +237,7 @@ pub trait Vcs: Send + Sync {
handle: &str,
keep_branch: bool,
force: bool,
+ worktree_path: &Path,
) -> Vec;
// ── Status cache ─────────────────────────────────────────────────
diff --git a/src/workflow/cleanup.rs b/src/workflow/cleanup.rs
index 19faf8fd..fc974904 100644
--- a/src/workflow/cleanup.rs
+++ b/src/workflow/cleanup.rs
@@ -137,6 +137,11 @@ pub fn cleanup(
// This avoids code duplication while enforcing the correct operational order.
let perform_fs_git_cleanup = |result: &mut CleanupResult| -> Result<()> {
+ // Remove any stale workspace locks before the worktree is renamed.
+ // Must happen while the worktree directory still exists so the VCS can
+ // read metadata files inside it to resolve the lock path.
+ context.vcs.remove_workspace_lock(worktree_path, &context.shared_dir);
+
// Run pre-remove hooks before removing the worktree directory.
// Skip if the worktree directory doesn't exist (e.g., user manually deleted it).
// Skip if --no-hooks is set (e.g., RPC-triggered merge).
@@ -406,6 +411,7 @@ pub fn cleanup(
handle,
keep_branch,
force,
+ worktree_path,
),
});
debug!(
@@ -720,10 +726,15 @@ mod tests {
handle: &str,
keep_branch: bool,
force: bool,
+ admin_dir: Option<&str>,
) -> Vec {
use crate::shell::shell_quote;
let git_dir_q = shell_quote(git_dir);
let mut cmds = Vec::new();
+ if let Some(dir) = admin_dir {
+ let locked = shell_quote(&format!("{}/locked", dir));
+ cmds.push(format!("rm -f {} >/dev/null 2>&1", locked));
+ }
cmds.push(format!("git -C {} worktree prune >/dev/null 2>&1", git_dir_q));
if !keep_branch {
let branch_q = shell_quote(branch);
@@ -746,6 +757,7 @@ mod tests {
git_dir: &str,
keep_branch: bool,
force: bool,
+ admin_dir: Option<&str>,
) -> DeferredCleanup {
DeferredCleanup {
worktree_path: PathBuf::from(worktree),
@@ -754,7 +766,7 @@ mod tests {
handle: handle.to_string(),
keep_branch,
force,
- vcs_cleanup_commands: git_cleanup_commands(git_dir, branch, handle, keep_branch, force),
+ vcs_cleanup_commands: git_cleanup_commands(git_dir, branch, handle, keep_branch, force, admin_dir),
}
}
@@ -768,6 +780,7 @@ mod tests {
"/repo/.git",
false,
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -793,6 +806,7 @@ mod tests {
"/repo/.git",
true, // keep_branch
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -822,6 +836,7 @@ mod tests {
"/repo/.git",
false,
true, // force
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -846,6 +861,7 @@ mod tests {
"/my repo/.git",
false,
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -874,6 +890,7 @@ mod tests {
"/repo/.git",
false,
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -908,6 +925,7 @@ mod tests {
"/repo/.git",
false,
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -928,6 +946,7 @@ mod tests {
"/repo/.git",
false,
false,
+ None,
);
let script = build_deferred_cleanup_script(&dc);
@@ -939,4 +958,55 @@ mod tests {
);
}
+
+ #[test]
+ fn deferred_cleanup_script_removes_lock_when_admin_dir_set() {
+ let dc = make_deferred_cleanup(
+ "/repo/worktrees/feature",
+ "/repo/worktrees/.trash",
+ "feature",
+ "feature",
+ "/repo/.git",
+ false,
+ false,
+ Some("/repo/.git/worktrees/feature"),
+ );
+
+ let script = build_deferred_cleanup_script(&dc);
+
+ assert!(
+ script.contains("rm -f /repo/.git/worktrees/feature/locked"),
+ "Should remove lock file when admin dir is set: {script}"
+ );
+
+ // Lock removal should happen after mv but before prune
+ let mv_pos = script.find("mv ").unwrap();
+ let lock_pos = script
+ .find("rm -f /repo/.git/worktrees/feature/locked")
+ .unwrap();
+ let prune_pos = script.find("worktree prune").unwrap();
+ assert!(mv_pos < lock_pos, "lock removal should follow mv");
+ assert!(lock_pos < prune_pos, "lock removal should precede prune");
+ }
+
+ #[test]
+ fn deferred_cleanup_script_no_lock_step_without_admin_dir() {
+ let dc = make_deferred_cleanup(
+ "/repo/worktrees/feature",
+ "/repo/worktrees/.trash",
+ "feature",
+ "feature",
+ "/repo/.git",
+ false,
+ false,
+ None,
+ );
+
+ let script = build_deferred_cleanup_script(&dc);
+
+ assert!(
+ !script.contains("/locked"),
+ "Should not have lock removal without admin dir: {script}"
+ );
+ }
}