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> { - let main_branch = git::get_default_branch().unwrap_or_else(|_| "main".to_string()); +fn is_unmerged(vcs: &dyn vcs::Vcs, branch: &str) -> Result> { + let main_branch = vcs.get_default_branch().unwrap_or_else(|_| "main".to_string()); - let base = git::get_branch_base(branch) + let base = vcs.get_branch_base(branch) .ok() .unwrap_or_else(|| main_branch.clone()); - let base_commit = match git::get_merge_base(&base) { + let base_commit = match vcs.get_merge_base(&base) { Ok(b) => b, Err(_) => { // If we can't determine base, try falling back to main - match git::get_merge_base(&main_branch) { + match vcs.get_merge_base(&main_branch) { Ok(b) => b, Err(_) => return Ok(None), // Can't determine, assume safe } } }; - let unmerged_branches = git::get_unmerged_branches(&base_commit)?; + let unmerged_branches = vcs.get_unmerged_branches(&base_commit)?; if unmerged_branches.contains(branch) { Ok(Some(base)) } else { @@ -172,9 +174,10 @@ fn is_unmerged(branch: &str) -> Result> { /// Remove all managed worktrees (except main) fn run_all(force: bool, keep_branch: bool) -> Result<()> { - let worktrees = git::list_worktrees()?; - let main_branch = git::get_default_branch()?; - let main_worktree_root = git::get_main_worktree_root()?; + let vcs = vcs::detect_vcs()?; + let worktrees = vcs.list_workspaces()?; + let main_branch = vcs.get_default_branch()?; + let main_worktree_root = vcs.get_main_workspace_root()?; let mut to_remove: Vec<(PathBuf, String, String)> = Vec::new(); let mut skipped_uncommitted: Vec = Vec::new(); @@ -192,18 +195,18 @@ fn run_all(force: bool, keep_branch: bool) -> Result<()> { } // Check for uncommitted changes - if !force && path.exists() && git::has_uncommitted_changes(&path).unwrap_or(false) { + if !force && path.exists() && vcs.has_uncommitted_changes(&path).unwrap_or(false) { skipped_uncommitted.push(branch); continue; } // Check for unmerged commits (only when deleting the branch) if !force && !keep_branch { - let base = git::get_branch_base(&branch) + let base = vcs.get_branch_base(&branch) .ok() .unwrap_or_else(|| main_branch.clone()); - if let Ok(merge_base) = git::get_merge_base(&base) - && let Ok(unmerged_branches) = git::get_unmerged_branches(&merge_base) + if let Ok(merge_base) = vcs.get_merge_base(&base) + && let Ok(unmerged_branches) = vcs.get_unmerged_branches(&merge_base) && unmerged_branches.contains(&branch) { skipped_unmerged.push(branch); @@ -322,14 +325,17 @@ fn run_all(force: bool, keep_branch: bool) -> Result<()> { /// Remove worktrees whose upstream remote branch has been deleted fn run_gone(force: bool, keep_branch: bool) -> Result<()> { + let vcs = vcs::detect_vcs()?; + // Fetch with prune to update remote-tracking refs - spinner::with_spinner("Fetching from remote", git::fetch_prune)?; + let vcs_clone = vcs.clone(); + spinner::with_spinner("Fetching from remote", move || vcs_clone.fetch_prune())?; - let worktrees = git::list_worktrees()?; - let main_branch = git::get_default_branch()?; - let main_worktree_root = git::get_main_worktree_root()?; + let worktrees = vcs.list_workspaces()?; + let main_branch = vcs.get_default_branch()?; + let main_worktree_root = vcs.get_main_workspace_root()?; - let gone_branches = git::get_gone_branches().unwrap_or_default(); + let gone_branches = vcs.get_gone_branches().unwrap_or_default(); // Find worktrees whose upstream is gone let mut to_remove: Vec<(PathBuf, String, String)> = Vec::new(); @@ -352,7 +358,7 @@ fn run_gone(force: bool, keep_branch: bool) -> Result<()> { } // Check for uncommitted changes - if !force && path.exists() && git::has_uncommitted_changes(&path).unwrap_or(false) { + if !force && path.exists() && vcs.has_uncommitted_changes(&path).unwrap_or(false) { skipped_uncommitted.push(branch); continue; } diff --git a/src/command/run.rs b/src/command/run.rs index 8e3c9e74..adb8fa35 100644 --- a/src/command/run.rs +++ b/src/command/run.rs @@ -39,7 +39,8 @@ pub fn run( let mux = create_backend(detect_backend()); // Resolve worktree to agent pane (consistent with send/capture) - let (worktree_path, agent) = workflow::resolve_worktree_agent(worktree_name, mux.as_ref())?; + let vcs = crate::vcs::detect_vcs()?; + let (worktree_path, agent) = workflow::resolve_worktree_agent(worktree_name, mux.as_ref(), vcs.as_ref())?; // Build command string (preserve argument boundaries via shell escaping) let command = command_parts diff --git a/src/command/sandbox.rs b/src/command/sandbox.rs index 64d97994..59269228 100644 --- a/src/command/sandbox.rs +++ b/src/command/sandbox.rs @@ -123,9 +123,10 @@ fn run_agent(command: Vec) -> Result<()> { let cwd = std::env::current_dir().context("Failed to get current directory")?; // Validate git repo early -- sandbox needs git dirs for mounts - let worktree_root = crate::git::get_repo_root() + let worktree_root = crate::vcs::detect_vcs() + .and_then(|v| v.get_repo_root()) .context( - "Not inside a git repository. workmux sandbox agent requires a git repo for mounting.", + "Not inside a git or jj repository. workmux sandbox agent requires a repo for mounting.", )? .canonicalize() .unwrap_or_else(|_| cwd.clone()); diff --git a/src/command/send.rs b/src/command/send.rs index a56d1ae2..91bf9b21 100644 --- a/src/command/send.rs +++ b/src/command/send.rs @@ -9,7 +9,8 @@ use crate::workflow; pub fn run(name: &str, text: Option<&str>, file: Option<&str>) -> Result<()> { let cfg = config::Config::load(None).unwrap_or_default(); 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())?; // Determine content: positional arg > --file > stdin let content = if let Some(t) = text { diff --git a/src/command/set_base.rs b/src/command/set_base.rs index befbff77..b317aa54 100644 --- a/src/command/set_base.rs +++ b/src/command/set_base.rs @@ -1,12 +1,14 @@ -use crate::git; +use crate::vcs; use anyhow::{Context, Result, anyhow}; pub fn run(base: &str) -> Result<()> { - if !git::branch_exists(base)? { + let vcs = vcs::detect_vcs()?; + + if !vcs.branch_exists(base)? { return Err(anyhow!("Base reference '{}' does not exist", base)); } - let branch = git::get_current_branch().context("Failed to get current branch")?; + let branch = vcs.get_current_branch().context("Failed to get current branch")?; if branch.is_empty() { return Err(anyhow!("Not on a branch (detached HEAD?)")); @@ -16,7 +18,7 @@ pub fn run(base: &str) -> Result<()> { return Err(anyhow!("Cannot set base branch to the current branch")); } - git::set_branch_base(&branch, base) + vcs.set_branch_base(&branch, base) .with_context(|| format!("Failed to set base branch for '{}'", branch))?; println!("Set base branch for '{}' to '{}'", branch, base); diff --git a/src/command/status.rs b/src/command/status.rs index d848fad7..09e63c2c 100644 --- a/src/command/status.rs +++ b/src/command/status.rs @@ -7,8 +7,8 @@ use tabled::{ settings::{Padding, Style, object::Columns}, }; -use crate::git; use crate::multiplexer::{AgentStatus, create_backend, detect_backend}; +use crate::vcs; use crate::state::StateStore; use crate::util; use crate::workflow; @@ -91,20 +91,22 @@ pub fn run(worktrees: &[String], json: bool, show_git: bool) -> Result<()> { return Ok(()); } + let vcs = vcs::detect_vcs()?; + // Get all worktrees for mapping (propagate errors) - let all_worktrees = git::list_worktrees()?; + let all_worktrees = vcs.list_workspaces()?; // Get unmerged info if --git flag let main_branch = if show_git { - git::get_default_branch().ok() + vcs.get_default_branch().ok() } else { None }; let unmerged_branches = if show_git { main_branch .as_deref() - .and_then(|main| git::get_merge_base(main).ok()) - .and_then(|base| git::get_unmerged_branches(&base).ok()) + .and_then(|main| vcs.get_merge_base(main).ok()) + .and_then(|base| vcs.get_unmerged_branches(&base).ok()) .unwrap_or_default() } else { std::collections::HashSet::new() @@ -132,8 +134,8 @@ pub fn run(worktrees: &[String], json: bool, show_git: bool) -> Result<()> { let git_info = if show_git { Some(GitInfo { - has_staged: git::has_staged_changes(wt_path).unwrap_or(false), - has_unstaged: git::has_unstaged_changes(wt_path).unwrap_or(false), + has_staged: vcs.has_staged_changes(wt_path).unwrap_or(false), + has_unstaged: vcs.has_unstaged_changes(wt_path).unwrap_or(false), has_unmerged_commits: unmerged_branches.contains(branch), }) } else { diff --git a/src/command/wait.rs b/src/command/wait.rs index 6061a514..b5cca790 100644 --- a/src/command/wait.rs +++ b/src/command/wait.rs @@ -4,8 +4,8 @@ use std::time::{Duration, Instant}; use anyhow::{Result, anyhow}; -use crate::git; use crate::multiplexer::{AgentStatus, create_backend, detect_backend}; +use crate::vcs; use crate::state::StateStore; use crate::util; use crate::workflow; @@ -30,13 +30,14 @@ pub fn run( ) -> Result<()> { let target = parse_status(target_status)?; let mux = create_backend(detect_backend()); + let vcs = vcs::detect_vcs()?; let start = Instant::now(); // Resolve worktree paths upfront let worktree_paths: Vec<_> = worktree_names .iter() .map(|name| { - let (path, _branch) = git::find_worktree(name)?; + let (path, _branch) = vcs.find_workspace(name)?; Ok((name.clone(), path)) }) .collect::>>()?; diff --git a/src/config.rs b/src/config.rs index e281f858..61e77c1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::{cmd, git, nerdfont}; +use crate::{cmd, nerdfont, vcs}; use which::{which, which_in}; /// Default script for cleaning up node_modules directories before worktree deletion. @@ -892,9 +892,12 @@ pub struct ConfigLocation { pub fn find_project_config(start_dir: &Path) -> anyhow::Result> { let config_names = [".workmux.yaml", ".workmux.yml"]; - let repo_root = match git::get_repo_root_for(start_dir) { - Ok(root) => root, - Err(_) => return Ok(None), + let repo_root = match vcs::try_detect_vcs() { + Some(v) => match v.get_repo_root_for(start_dir) { + Ok(root) => root, + Err(_) => return Ok(None), + }, + None => return Ok(None), }; // Canonicalize both paths to handle symlinks and ensure consistent comparison @@ -938,7 +941,9 @@ pub fn find_project_config(start_dir: &Path) -> anyhow::Result Result<(PathBuf, Vec)> { - let (worktree_path, _branch) = git::find_worktree(name)?; + let (worktree_path, _branch) = vcs.find_workspace(name)?; let canon_wt_path = canon_or_self(&worktree_path); let agent_panes = StateStore::new().and_then(|store| store.load_reconciled_agents(mux))?; @@ -36,8 +37,8 @@ pub fn resolve_worktree_agents( /// Resolve a worktree name to exactly one agent pane (the first/primary). /// /// Returns an error if no agent is running in the worktree. -pub fn resolve_worktree_agent(name: &str, mux: &dyn Multiplexer) -> Result<(PathBuf, AgentPane)> { - let (path, agents) = resolve_worktree_agents(name, mux)?; +pub fn resolve_worktree_agent(name: &str, mux: &dyn Multiplexer, vcs: &dyn Vcs) -> Result<(PathBuf, AgentPane)> { + let (path, agents) = resolve_worktree_agents(name, mux, vcs)?; let agent = agents .into_iter() .next() diff --git a/src/workflow/cleanup.rs b/src/workflow/cleanup.rs index ec806732..19faf8fd 100644 --- a/src/workflow/cleanup.rs +++ b/src/workflow/cleanup.rs @@ -7,61 +7,14 @@ use std::{thread, time::Duration}; use crate::config::MuxMode; use crate::multiplexer::{Multiplexer, util::prefixed}; use crate::shell::shell_quote; -use crate::{cmd, git}; +use crate::cmd; use tracing::{debug, info, warn}; -// Re-export for use by other modules in the workflow -pub use git::get_worktree_mode; - use super::context::WorkflowContext; use super::types::{CleanupResult, DeferredCleanup}; const WINDOW_CLOSE_DELAY_MS: u64 = 300; -/// 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)) -} /// Best-effort recursive deletion of directory contents. /// Used to ensure files are removed even if the directory itself is locked (e.g., CWD). @@ -143,7 +96,7 @@ pub fn cleanup( no_hooks: bool, ) -> Result { // Determine if this worktree was created as a session or window - let mode = get_worktree_mode(handle); + let mode = context.vcs.get_workspace_mode(handle); let is_session_mode = mode == MuxMode::Session; let kind = crate::multiplexer::handle::mode_label(mode); @@ -183,8 +136,6 @@ pub fn cleanup( // Helper closure to perform the actual filesystem and git cleanup. // This avoids code duplication while enforcing the correct operational order. let perform_fs_git_cleanup = |result: &mut CleanupResult| -> Result<()> { - // Resolve the admin dir before the rename so we can unlock it later. - let worktree_admin_dir = resolve_worktree_admin_dir(worktree_path, &context.git_common_dir); // Run pre-remove hooks before removing the worktree directory. // Skip if the worktree directory doesn't exist (e.g., user manually deleted it). @@ -293,29 +244,14 @@ pub fn cleanup( } } - // 2. Remove any worktree lock before pruning. - // Git creates a "locked" file during `git worktree add` (with content "initializing") - // and removes it on completion. If creation was interrupted, this file persists and - // prevents `git worktree prune` from cleaning up the metadata. - if let Some(ref admin_dir) = worktree_admin_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") - } - } - } - - // 3. Prune worktrees to clean up git's metadata. - // Git will see the original path as missing since we renamed it. - git::prune_worktrees_in(&context.git_common_dir).context("Failed to prune worktrees")?; - debug!("cleanup:git worktrees pruned"); + // 2. Prune workspaces to clean up VCS metadata. + // VCS will see the original path as missing since we renamed it. + context.vcs.prune_workspaces(&context.shared_dir).context("Failed to prune workspaces")?; + debug!("cleanup:workspaces pruned"); // 4. Delete the local branch (unless keeping it). if !keep_branch { - git::delete_branch_in(branch_name, force, &context.git_common_dir) + context.vcs.delete_branch(branch_name, force, &context.shared_dir) .context("Failed to delete local branch")?; result.local_branch_deleted = true; info!(branch = branch_name, "cleanup:local branch deleted"); @@ -457,10 +393,6 @@ pub fn cleanup( ); let trash_path = parent.join(&trash_name); - // Resolve the admin dir before the worktree is renamed. - let worktree_admin_dir = - resolve_worktree_admin_dir(worktree_path, &context.git_common_dir); - result.deferred_cleanup = Some(DeferredCleanup { worktree_path: worktree_path.to_path_buf(), trash_path, @@ -468,8 +400,13 @@ pub fn cleanup( handle: handle.to_string(), keep_branch, force, - git_common_dir: context.git_common_dir.clone(), - worktree_admin_dir, + vcs_cleanup_commands: context.vcs.build_cleanup_commands( + &context.shared_dir, + branch_name, + handle, + keep_branch, + force, + ), }); debug!( worktree = %worktree_path.display(), @@ -544,7 +481,7 @@ pub fn cleanup( // Only remove immediately when not deferring -- deferred cleanup includes this // in the shell script so metadata survives if the deferred script fails. if result.deferred_cleanup.is_none() - && let Err(e) = git::remove_worktree_meta(handle) + && let Err(e) = context.vcs.remove_workspace_meta(handle) { warn!(handle = handle, error = %e, "cleanup:failed to remove worktree metadata"); } @@ -566,36 +503,13 @@ pub fn cleanup( fn build_deferred_cleanup_script(dc: &DeferredCleanup) -> String { let wt = shell_quote(&dc.worktree_path.to_string_lossy()); let trash = shell_quote(&dc.trash_path.to_string_lossy()); - let git_dir = shell_quote(&dc.git_common_dir.to_string_lossy()); let mut cmds = Vec::new(); // 1. Rename worktree to trash cmds.push(format!("mv {} {} >/dev/null 2>&1", wt, trash)); - // 2. Remove worktree lock if present (git worktree prune skips locked entries) - if let Some(ref admin_dir) = dc.worktree_admin_dir - && 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)); - } - // 3. Prune git worktrees - cmds.push(format!("git -C {} worktree prune >/dev/null 2>&1", git_dir)); - // 4. Delete branch (if not keeping) - if !dc.keep_branch { - let branch = shell_quote(&dc.branch_name); - let force_flag = if dc.force { "-D" } else { "-d" }; - cmds.push(format!( - "git -C {} branch {} {} >/dev/null 2>&1", - git_dir, force_flag, branch - )); - } - // 5. Remove worktree metadata from git config - let handle = shell_quote(&dc.handle); - cmds.push(format!( - "git -C {} config --local --remove-section workmux.worktree.{} >/dev/null 2>&1", - git_dir, handle - )); - // 6. Delete trash + // 2-4. VCS-specific cleanup (prune, branch delete, config remove) + cmds.extend(dc.vcs_cleanup_commands.iter().cloned()); + // 5. Delete trash cmds.push(format!("rm -rf {} >/dev/null 2>&1", trash)); format!("; {}", cmds.join("; ")) @@ -797,6 +711,33 @@ mod tests { use super::*; use std::path::PathBuf; + /// Build VCS cleanup commands matching what GitVcs.build_cleanup_commands() produces. + /// This allows tests to verify the deferred cleanup script structure without + /// depending on a real VCS instance. + fn git_cleanup_commands( + git_dir: &str, + branch: &str, + handle: &str, + keep_branch: bool, + force: bool, + ) -> Vec { + use crate::shell::shell_quote; + let git_dir_q = shell_quote(git_dir); + let mut cmds = Vec::new(); + cmds.push(format!("git -C {} worktree prune >/dev/null 2>&1", git_dir_q)); + if !keep_branch { + let branch_q = shell_quote(branch); + let flag = if force { "-D" } else { "-d" }; + cmds.push(format!("git -C {} branch {} {} >/dev/null 2>&1", git_dir_q, flag, branch_q)); + } + let handle_q = shell_quote(handle); + cmds.push(format!( + "git -C {} config --local --remove-section workmux.worktree.{} >/dev/null 2>&1", + git_dir_q, handle_q + )); + cmds + } + fn make_deferred_cleanup( worktree: &str, trash: &str, @@ -813,8 +754,7 @@ mod tests { handle: handle.to_string(), keep_branch, force, - git_common_dir: PathBuf::from(git_dir), - worktree_admin_dir: None, + vcs_cleanup_commands: git_cleanup_commands(git_dir, branch, handle, keep_branch, force), } } @@ -999,53 +939,4 @@ mod tests { ); } - #[test] - fn deferred_cleanup_script_removes_lock_when_admin_dir_set() { - let mut dc = make_deferred_cleanup( - "/repo/worktrees/feature", - "/repo/worktrees/.trash", - "feature", - "feature", - "/repo/.git", - false, - false, - ); - dc.worktree_admin_dir = Some(PathBuf::from("/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, - ); - - let script = build_deferred_cleanup_script(&dc); - - assert!( - !script.contains("/locked"), - "Should not have lock removal without admin dir: {script}" - ); - } } diff --git a/src/workflow/context.rs b/src/workflow/context.rs index 32c5cf0e..6a4581d7 100644 --- a/src/workflow/context.rs +++ b/src/workflow/context.rs @@ -1,9 +1,10 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result}; use std::path::PathBuf; use std::sync::Arc; use crate::multiplexer::Multiplexer; -use crate::{config, git}; +use crate::vcs::{self, Vcs}; +use crate::config; use tracing::debug; /// Shared context for workflow operations @@ -12,11 +13,12 @@ use tracing::debug; /// needed by workflow modules, reducing code duplication. pub struct WorkflowContext { pub main_worktree_root: PathBuf, - pub git_common_dir: PathBuf, + pub shared_dir: PathBuf, pub main_branch: String, pub prefix: String, pub config: config::Config, pub mux: Arc, + pub vcs: Arc, /// Relative path from repo root to config directory. /// Empty if config is at repo root or using defaults. pub config_rel_dir: PathBuf, @@ -28,7 +30,7 @@ pub struct WorkflowContext { impl WorkflowContext { /// Create a new workflow context /// - /// Performs the git repository check and gathers all commonly needed data. + /// Performs the VCS repository check and gathers all commonly needed data. /// Does NOT check if multiplexer is running or change the current directory - those /// are optional operations that can be performed via helper methods. pub fn new( @@ -36,20 +38,18 @@ impl WorkflowContext { mux: Arc, config_location: Option, ) -> Result { - if !git::is_git_repo()? { - return Err(anyhow!("Not in a git repository")); - } + let vcs = vcs::detect_vcs()?; let main_worktree_root = - git::get_main_worktree_root().context("Could not find the main git worktree")?; + vcs.get_main_workspace_root().context("Could not find the main worktree")?; - let git_common_dir = - git::get_git_common_dir().context("Could not find the git common directory")?; + let shared_dir = + vcs.get_shared_dir().context("Could not find the shared VCS directory")?; let main_branch = if let Some(ref branch) = config.main_branch { branch.clone() } else { - git::get_default_branch().context("Failed to determine the main branch")? + vcs.get_default_branch().context("Failed to determine the main branch")? }; let prefix = config.window_prefix().to_string(); @@ -61,10 +61,11 @@ impl WorkflowContext { debug!( main_worktree_root = %main_worktree_root.display(), - git_common_dir = %git_common_dir.display(), + shared_dir = %shared_dir.display(), main_branch = %main_branch, prefix = %prefix, - backend = mux.name(), + vcs_backend = vcs.name(), + mux_backend = mux.name(), config_rel_dir = %config_rel_dir.display(), config_source_dir = %config_source_dir.display(), "workflow_context:created" @@ -72,11 +73,12 @@ impl WorkflowContext { Ok(Self { main_worktree_root, - git_common_dir, + shared_dir, main_branch, prefix, config, mux, + vcs, config_rel_dir, config_source_dir, }) @@ -87,7 +89,7 @@ impl WorkflowContext { /// Call this at the start of workflows that require a multiplexer. pub fn ensure_mux_running(&self) -> Result<()> { if !self.mux.is_running()? { - return Err(anyhow!( + return Err(anyhow::anyhow!( "{} is not running. Please start a {} session first.", self.mux.name(), self.mux.name() @@ -96,13 +98,6 @@ impl WorkflowContext { Ok(()) } - /// Ensure tmux is running (backward-compat alias for ensure_mux_running) - #[deprecated(note = "Use ensure_mux_running() instead")] - #[allow(dead_code)] - pub fn ensure_tmux_running(&self) -> Result<()> { - self.ensure_mux_running() - } - /// Change working directory to main worktree root /// /// This is necessary for destructive operations (merge, remove) to prevent diff --git a/src/workflow/create.rs b/src/workflow/create.rs index 644dcbc0..54824a65 100644 --- a/src/workflow/create.rs +++ b/src/workflow/create.rs @@ -4,25 +4,26 @@ use std::path::Path; use crate::config::MuxMode; use crate::multiplexer::MuxHandle; use crate::{git, spinner}; +use crate::vcs::Vcs; use tracing::{debug, info, warn}; -/// Check if a path is registered as a git worktree. +/// Check if a path is registered as a workspace with the VCS. /// Uses canonicalize() to handle symlinks, case sensitivity, and relative paths. -fn is_registered_worktree(path: &Path) -> Result { +fn is_registered_workspace(vcs: &dyn Vcs, path: &Path) -> Result { // Canonicalize the input path for reliable comparison let abs_path = match std::fs::canonicalize(path) { Ok(p) => p, - Err(_) => return Ok(false), // Can't canonicalize = not a valid worktree + Err(_) => return Ok(false), // Can't canonicalize = not a valid workspace }; - let worktrees = git::list_worktrees()?; - for (wt_path, _) in worktrees { - // Canonicalize git's reported path as well - if let Ok(abs_wt) = std::fs::canonicalize(&wt_path) { - if abs_wt == abs_path { + let workspaces = vcs.list_workspaces()?; + for (ws_path, _) in workspaces { + // Canonicalize the VCS-reported path as well + if let Ok(abs_ws) = std::fs::canonicalize(&ws_path) { + if abs_ws == abs_path { return Ok(true); } - } else if wt_path == path { + } else if ws_path == path { // Fallback to string comparison if canonicalization fails return Ok(true); } @@ -88,7 +89,7 @@ pub fn create(context: &WorkflowContext, args: CreateArgs) -> Result Result Result Result Result Result Result { warn!(error = %e, "create_with_changes: worktree creation failed, popping stash"); // Best effort to restore the stash - if this fails, user still has stash@{0} - let _ = git::stash_pop(&original_worktree_path); + let _ = context.vcs.stash_pop(&original_worktree_path); return Err(e).context( "Failed to create new worktree. Stashed changes have been restored if possible.", ); @@ -451,11 +454,11 @@ pub fn create_with_changes( ); // 3. Apply stash in new worktree - match git::stash_pop(new_worktree_path) { + match context.vcs.stash_pop(new_worktree_path) { Ok(_) => { // 4. Success: Clean up original worktree info!("create_with_changes: stash applied successfully, cleaning original worktree"); - git::reset_hard(&original_worktree_path)?; + context.vcs.reset_hard(&original_worktree_path)?; info!( branch = branch_name, diff --git a/src/workflow/list.rs b/src/workflow/list.rs index 076345e6..98997de2 100644 --- a/src/workflow/list.rs +++ b/src/workflow/list.rs @@ -6,7 +6,8 @@ use crate::config::MuxMode; use crate::multiplexer::{Multiplexer, util}; use crate::state::StateStore; use crate::util::canon_or_self; -use crate::{config, git, github, spinner}; +use crate::vcs::Vcs; +use crate::{config, github, spinner}; use super::types::{AgentStatusSummary, WorktreeInfo}; @@ -54,14 +55,15 @@ fn filter_worktrees( pub fn list( config: &config::Config, mux: &dyn Multiplexer, + vcs: &dyn Vcs, fetch_pr_status: bool, filter: &[String], ) -> Result> { - if !git::is_git_repo()? { - return Err(anyhow!("Not in a git repository")); + if !vcs.is_repo()? { + return Err(anyhow!("Not in a {} repository", vcs.name())); } - let worktrees_data = git::list_worktrees()?; + let worktrees_data = vcs.list_workspaces()?; if worktrees_data.is_empty() { return Ok(Vec::new()); @@ -88,14 +90,14 @@ pub fn list( }; // Get the main branch for unmerged checks - let main_branch = git::get_default_branch().ok(); + let main_branch = vcs.get_default_branch().ok(); // Get all unmerged branches in one go for efficiency // Prefer checking against remote tracking branch for more accurate results let unmerged_branches = main_branch .as_deref() - .and_then(|main| git::get_merge_base(main).ok()) - .and_then(|base| git::get_unmerged_branches(&base).ok()) + .and_then(|main| vcs.get_merge_base(main).ok()) + .and_then(|base| vcs.get_unmerged_branches(&base).ok()) .unwrap_or_default(); // Use an empty set on failure // Batch fetch all PRs if requested (single API call) @@ -123,8 +125,8 @@ pub fn list( .map(|a| (canon_or_self(&a.path), a.status)) .collect(); - // Batch-load all worktree modes in a single git config call - let worktree_modes = git::get_all_worktree_modes(); + // Batch-load all workspace modes in a single VCS config call + let worktree_modes = vcs.get_all_workspace_modes(); let prefix = config.window_prefix(); let worktrees: Vec = worktrees_data diff --git a/src/workflow/merge.rs b/src/workflow/merge.rs index 1a0aeabf..9b401154 100644 --- a/src/workflow/merge.rs +++ b/src/workflow/merge.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result, anyhow}; -use crate::{cmd, git}; +use crate::cmd; use tracing::{debug, info}; -use super::cleanup::{self, get_worktree_mode}; +use super::cleanup; use super::context::WorkflowContext; use super::types::MergeResult; @@ -38,8 +38,8 @@ pub fn merge( context.chdir_to_main_worktree()?; // Smart resolution: try handle first, then branch name - let (worktree_path, branch_to_merge) = git::find_worktree(name) - .with_context(|| format!("No worktree found with name '{}'", name))?; + let (worktree_path, branch_to_merge) = context.vcs.find_workspace(name) + .with_context(|| format!("No workspace found with name '{}'", name))?; // The handle is the basename of the worktree directory (used for tmux operations) let handle = worktree_path @@ -53,7 +53,7 @@ pub fn merge( })?; // Capture mode BEFORE cleanup (cleanup removes the metadata) - let mode = get_worktree_mode(handle); + let mode = context.vcs.get_workspace_mode(handle); debug!( name = name, @@ -70,10 +70,10 @@ pub fn merge( let detected_base: Option = if into_branch.is_some() { None // User explicitly specified target, no auto-detection needed } else { - match git::get_branch_base(&branch_to_merge) { + match context.vcs.get_branch_base(&branch_to_merge) { Ok(base) => { // Verify the base branch still exists - if git::branch_exists(&base)? { + if context.vcs.branch_exists(&base)? { info!( branch = %branch_to_merge, base = %base, @@ -108,7 +108,7 @@ pub fn merge( // Resolve the worktree path and window handle for the TARGET branch. // We prioritize finding an existing worktree for the target branch to support // workflows where 'main' is checked out in a linked worktree (issue #29). - let (target_worktree_path, target_window_name) = match git::get_worktree_path(target_branch) { + let (target_worktree_path, target_window_name) = match context.vcs.get_workspace_path(target_branch) { Ok(path) => { // Target is checked out in a worktree (could be main root or a linked worktree) if path == context.main_worktree_root { @@ -141,8 +141,8 @@ pub fn merge( // Handle changes in the source worktree // Only check for unstaged/untracked when worktree will be deleted (!keep) // With --keep, the worktree persists so no data loss risk - let has_unstaged = !keep && git::has_unstaged_changes(&worktree_path)?; - let has_untracked = !keep && git::has_untracked_files(&worktree_path)?; + let has_unstaged = !keep && context.vcs.has_unstaged_changes(&worktree_path)?; + let has_untracked = !keep && context.vcs.has_untracked_files(&worktree_path)?; if (has_unstaged || has_untracked) && !ignore_uncommitted { let mut issues = Vec::new(); @@ -159,11 +159,11 @@ pub fn merge( )); } - let had_staged_changes = git::has_staged_changes(&worktree_path)?; + let had_staged_changes = context.vcs.has_staged_changes(&worktree_path)?; if had_staged_changes && !ignore_uncommitted { - // Commit using git's editor (respects $EDITOR or git config) + // Commit using the user's editor info!(path = %worktree_path.display(), "merge:committing staged changes"); - git::commit_with_editor(&worktree_path).context("Failed to commit staged changes")?; + context.vcs.commit_with_editor(&worktree_path).context("Failed to commit staged changes")?; } if branch_to_merge == target_branch { @@ -180,7 +180,7 @@ pub fn merge( // Safety check: Abort if the target worktree has uncommitted tracked changes. // Untracked files are allowed; git will fail safely if they collide with merged files. - if git::has_tracked_changes(&target_worktree_path)? { + if context.vcs.has_tracked_changes(&target_worktree_path)? { return Err(anyhow!( "Target worktree ({}) has uncommitted changes. Please commit or stash them before merging.", target_worktree_path.display() @@ -190,7 +190,7 @@ pub fn merge( // Explicitly switch the target worktree to the target branch. // This ensures that if we are reusing the main worktree for a feature branch merge, // it is checked out to the correct branch. - git::switch_branch_in_worktree(&target_worktree_path, target_branch)?; + context.vcs.switch_branch(&target_worktree_path, target_branch)?; // Run pre-merge hooks after all validations pass but before any merge operations begin. // Skip hooks if --no-verify or --no-hooks flag is passed. @@ -259,7 +259,7 @@ pub fn merge( base = target_branch, "merge:rebase start" ); - git::rebase_branch_onto_base(&worktree_path, target_branch).with_context(|| { + context.vcs.rebase_onto_base(&worktree_path, target_branch).with_context(|| { format!( "Rebase failed, likely due to conflicts.\n\n\ Please resolve them manually inside the worktree at '{}'.\n\ @@ -269,29 +269,29 @@ pub fn merge( })?; // After a successful rebase, merge into target. This will be a fast-forward. - git::merge_in_worktree(&target_worktree_path, &branch_to_merge) + context.vcs.merge_in_workspace(&target_worktree_path, &branch_to_merge) .context("Failed to merge rebased branch. This should have been a fast-forward.")?; info!(branch = %branch_to_merge, "merge:fast-forward complete"); } else if squash { // Perform the squash merge. This stages all changes from the feature branch but does not commit. - if let Err(e) = git::merge_squash_in_worktree(&target_worktree_path, &branch_to_merge) { + if let Err(e) = context.vcs.merge_squash(&target_worktree_path, &branch_to_merge) { info!(branch = %branch_to_merge, error = %e, "merge:squash merge failed, resetting target worktree"); // Best effort to reset; ignore failure as the user message is the priority. - let _ = git::reset_hard(&target_worktree_path); + let _ = context.vcs.reset_hard(&target_worktree_path); return Err(conflict_err(&branch_to_merge)); } // Prompt the user to provide a commit message for the squashed changes. println!("Staged squashed changes. Please provide a commit message in your editor."); - git::commit_with_editor(&target_worktree_path) + context.vcs.commit_with_editor(&target_worktree_path) .context("Failed to commit squashed changes. You may need to commit them manually.")?; info!(branch = %branch_to_merge, "merge:squash merge committed"); } else { // Default merge commit workflow - if let Err(e) = git::merge_in_worktree(&target_worktree_path, &branch_to_merge) { + if let Err(e) = context.vcs.merge_in_workspace(&target_worktree_path, &branch_to_merge) { info!(branch = %branch_to_merge, error = %e, "merge:standard merge failed, aborting merge in target worktree"); // Best effort to abort; ignore failure as the user message is the priority. - let _ = git::abort_merge_in_worktree(&target_worktree_path); + let _ = context.vcs.abort_merge(&target_worktree_path); return Err(conflict_err(&branch_to_merge)); } info!(branch = %branch_to_merge, "merge:standard merge complete"); diff --git a/src/workflow/open.rs b/src/workflow/open.rs index 73ea43f9..489ceb62 100644 --- a/src/workflow/open.rs +++ b/src/workflow/open.rs @@ -1,12 +1,9 @@ use anyhow::{Context, Result, anyhow}; use regex::Regex; -use crate::git; use crate::multiplexer::MuxHandle; use crate::multiplexer::util::prefixed; use tracing::info; - -use super::cleanup::get_worktree_mode; use super::context::WorkflowContext; use super::setup; use super::types::{CreateResult, SetupOptions}; @@ -48,9 +45,9 @@ pub fn open( // This command requires the worktree to already exist // Smart resolution: try handle first, then branch name - let (worktree_path, branch_name) = git::find_worktree(name).with_context(|| { + let (worktree_path, branch_name) = context.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 ) })?; @@ -63,7 +60,7 @@ pub fn open( .to_string(); // Determine the target mode from stored metadata (or default to Window) - let stored_mode = get_worktree_mode(&base_handle); + let stored_mode = context.vcs.get_workspace_mode(&base_handle); let target = MuxHandle::new( context.mux.as_ref(), stored_mode, diff --git a/src/workflow/remove.rs b/src/workflow/remove.rs index abc0ea0c..baf031c1 100644 --- a/src/workflow/remove.rs +++ b/src/workflow/remove.rs @@ -1,10 +1,9 @@ use anyhow::{Context, Result, anyhow}; -use crate::git; use crate::sandbox; use tracing::{debug, info}; -use super::cleanup::{self, get_worktree_mode}; +use super::cleanup; use super::context::WorkflowContext; use super::types::RemoveResult; @@ -19,8 +18,8 @@ pub fn remove( // Get worktree path and branch - this also validates that the worktree exists // Smart resolution: try handle first, then branch name - let (worktree_path, branch_name) = git::find_worktree(handle) - .with_context(|| format!("No worktree found with name '{}'", handle))?; + let (worktree_path, branch_name) = context.vcs.find_workspace(handle) + .with_context(|| format!("No workspace found with name '{}'", handle))?; // Extract actual handle from worktree path (directory name) // User may have provided branch name (with slashes) but window names use handle (with dashes) @@ -37,7 +36,7 @@ pub fn remove( debug!(handle = actual_handle, branch = branch_name, path = %worktree_path.display(), "remove:worktree resolved"); // Capture mode BEFORE cleanup (cleanup removes the metadata) - let mode = get_worktree_mode(actual_handle); + let mode = context.vcs.get_workspace_mode(actual_handle); // Safety Check: Prevent deleting the main worktree itself, regardless of branch. let is_main_worktree = match ( @@ -74,7 +73,7 @@ pub fn remove( )); } - if worktree_path.exists() && git::has_uncommitted_changes(&worktree_path)? && !force { + if worktree_path.exists() && context.vcs.has_uncommitted_changes(&worktree_path)? && !force { return Err(anyhow!( "Worktree has uncommitted changes. Use --force to delete anyway." )); diff --git a/src/workflow/types.rs b/src/workflow/types.rs index 98fa0da7..40f9c6b1 100644 --- a/src/workflow/types.rs +++ b/src/workflow/types.rs @@ -47,10 +47,8 @@ pub struct DeferredCleanup { pub handle: String, pub keep_branch: bool, pub force: bool, - pub git_common_dir: PathBuf, - /// Path to the git worktree admin directory (e.g., $GIT_COMMON_DIR/worktrees//). - /// Used to remove lock files before pruning, since `git worktree prune` skips locked entries. - pub worktree_admin_dir: Option, + /// Pre-computed VCS-specific cleanup commands (prune, branch delete, config remove). + pub vcs_cleanup_commands: Vec, } /// Result of cleanup operations From 786fa98e9237df9ed71197118dbf8b0468dba571 Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Feb 2026 01:41:37 +0000 Subject: [PATCH 3/8] Implement JjVcs workspace, branch, and status operations Implement the core jj operations needed for workmux add/list/open: Repo detection: - get_repo_root/get_repo_root_for via .jj/ directory walking - get_shared_dir returns repo root (where .jj/ lives) - has_commits always true (jj has root commit) Workspace lifecycle: - create_workspace: jj workspace add + jj bookmark create - list_workspaces: parse jj workspace list + metadata lookup - find_workspace: match by handle, bookmark, or workspace name - prune_workspaces: forget workspaces whose paths no longer exist Metadata storage: - set/get_workspace_meta via jj config set/get --repo - get_all_workspace_modes by parsing .jj/repo/config.toml - remove_workspace_meta by editing config.toml directly Branch/bookmark operations: - get_default_branch: probe main/master bookmarks - branch_exists via jj bookmark list with template - get_current_branch via jj log -r @ -T bookmarks - list_checkout_branches, delete_branch, get_merge_base - get_unmerged_branches via revset queries Status operations: - has_uncommitted_changes via jj diff --stat - has_staged/unstaged both map to has_uncommitted (no staging area) - has_untracked_files always false (jj auto-tracks) - get_status with conflict detection via conflicts() revset Also implements: - Base branch tracking via jj config (workmux.base.) - Deferred cleanup commands (workspace forget, bookmark delete) - Unit tests for workspace list and diff stat parsing --- src/vcs/jj.rs | 850 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 772 insertions(+), 78 deletions(-) diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index 59034662..de4821cd 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -1,14 +1,15 @@ -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use tracing::debug; +use crate::cmd::Cmd; use crate::config::MuxMode; +use crate::shell::shell_quote; -use super::{Vcs, VcsStatus}; +use super::{Vcs, VcsStatus, WorkspaceNotFound}; /// Jujutsu (jj) implementation of the Vcs trait. -/// -/// Stub implementation - methods will be filled in during Phases 3-5. pub struct JjVcs; impl JjVcs { @@ -22,6 +23,60 @@ fn jj_todo(operation: &str) -> anyhow::Error { anyhow!("jj support not yet implemented: {}", operation) } +/// Run a jj command, optionally in a specific workdir. +/// Adds `--quiet` to suppress jj's informational messages. +fn jj_cmd<'a>(workdir: Option<&'a Path>) -> Cmd<'a> { + let cmd = Cmd::new("jj").arg("--quiet"); + match workdir { + Some(path) => cmd.workdir(path), + None => cmd, + } +} + +/// Find the jj repo root by walking up from CWD looking for .jj/. +fn find_jj_root() -> Result { + let cwd = std::env::current_dir()?; + for dir in cwd.ancestors() { + if dir.join(".jj").is_dir() { + return Ok(dir.to_path_buf()); + } + } + Err(anyhow!("Not in a jj repository")) +} + +/// Find the jj repo root starting from a specific directory. +fn find_jj_root_for(start: &Path) -> Result { + for dir in start.ancestors() { + if dir.join(".jj").is_dir() { + return Ok(dir.to_path_buf()); + } + } + Err(anyhow!("Not in a jj repository: {}", start.display())) +} + +/// Parse `jj workspace list` output. +/// Format: `: ` +fn parse_workspace_list(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let name = line.split(':').next()?.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + }) + .collect() +} + +/// Read metadata directly from .jj/repo/config.toml (for batch operations). +/// Returns the raw content of the config file, or empty string if not found. +fn read_jj_repo_config(repo_root: &Path) -> String { + let config_path = repo_root.join(".jj").join("repo").join("config.toml"); + std::fs::read_to_string(config_path).unwrap_or_default() +} + impl Vcs for JjVcs { fn name(&self) -> &str { "jj" @@ -30,7 +85,6 @@ impl Vcs for JjVcs { // ── 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() { @@ -41,166 +95,701 @@ impl Vcs for JjVcs { } fn has_commits(&self) -> Result { - Err(jj_todo("has_commits")) + // jj always has at least a root commit + Ok(true) } fn get_repo_root(&self) -> Result { - Err(jj_todo("get_repo_root")) + find_jj_root() } - fn get_repo_root_for(&self, _dir: &Path) -> Result { - Err(jj_todo("get_repo_root_for")) + fn get_repo_root_for(&self, dir: &Path) -> Result { + find_jj_root_for(dir) } fn get_main_workspace_root(&self) -> Result { - Err(jj_todo("get_main_workspace_root")) + // The main workspace root is the directory containing the real .jj/ + // (not a symlink). Walk up from CWD to find it. + find_jj_root() } fn get_shared_dir(&self) -> Result { - Err(jj_todo("get_shared_dir")) + // For jj, the shared directory is the repo root (where .jj/ lives). + // This is used for running cleanup commands from a stable directory. + find_jj_root() } - fn is_path_ignored(&self, _repo_path: &Path, _file_path: &str) -> bool { - false // TODO: implement jj ignore checking + fn is_path_ignored(&self, repo_path: &Path, file_path: &str) -> bool { + // Check if the path matches .gitignore patterns (jj respects them) + std::process::Command::new("jj") + .args(["file", "show", file_path]) + .current_dir(repo_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| !s.success()) + .unwrap_or(false) } // ── Workspace lifecycle ────────────────────────────────────────── - fn workspace_exists(&self, _branch_name: &str) -> Result { - Err(jj_todo("workspace_exists")) + fn workspace_exists(&self, branch_name: &str) -> Result { + // Check if any workspace has a bookmark matching this branch name + match self.get_workspace_path(branch_name) { + Ok(_) => Ok(true), + Err(e) => { + if e.is::() { + Ok(false) + } else { + Err(e) + } + } + } } fn create_workspace( &self, - _path: &Path, - _branch: &str, - _create_branch: bool, - _base: Option<&str>, + path: &Path, + branch: &str, + create_branch: bool, + base: Option<&str>, _track_upstream: bool, ) -> Result<()> { - Err(jj_todo("create_workspace")) + let path_str = path + .to_str() + .ok_or_else(|| anyhow!("Invalid workspace path"))?; + + // Use the directory name as the workspace name + let handle = path + .file_name() + .ok_or_else(|| anyhow!("Invalid workspace path: no directory name"))? + .to_string_lossy(); + + if create_branch { + // Create workspace from base (or @) + let base_rev = base.unwrap_or("@"); + + // First create the workspace + jj_cmd(None) + .args(&["workspace", "add", path_str, "--name", &handle, "--revision", base_rev]) + .run() + .context("Failed to create jj workspace")?; + + // Create a bookmark pointing to the new workspace's working copy + jj_cmd(Some(path)) + .args(&["bookmark", "create", branch, "-r", "@"]) + .run() + .with_context(|| format!("Failed to create bookmark '{}'", branch))?; + } else { + // Branch already exists - create workspace and edit the bookmark's change + jj_cmd(None) + .args(&["workspace", "add", path_str, "--name", &handle]) + .run() + .context("Failed to create jj workspace")?; + + // Edit the existing bookmark's change in the new workspace + jj_cmd(Some(path)) + .args(&["edit", branch]) + .run() + .with_context(|| format!("Failed to edit bookmark '{}' in workspace", branch))?; + } + + // Store the path in workmux metadata for later lookup + self.set_workspace_meta(&handle, "path", path_str)?; + + Ok(()) } fn list_workspaces(&self) -> Result> { - Err(jj_todo("list_workspaces")) + let root = find_jj_root()?; + + // Get workspace names from jj + let output = jj_cmd(Some(&root)) + .args(&["workspace", "list"]) + .run_and_capture_stdout() + .context("Failed to list jj workspaces")?; + + let workspace_names = parse_workspace_list(&output); + + let mut result = Vec::new(); + for name in &workspace_names { + // Get the stored path from metadata, or derive from workspace name + let path = self + .get_workspace_meta(name, "path") + .map(PathBuf::from) + .unwrap_or_else(|| { + if name == "default" { + root.clone() + } else { + root.parent() + .unwrap_or(&root) + .join(name) + } + }); + + // Get the bookmark associated with this workspace + // Query the bookmarks on the workspace's working copy + let bookmark = if path.exists() { + jj_cmd(Some(&path)) + .args(&["log", "-r", "@", "--no-graph", "-T", "bookmarks"]) + .run_and_capture_stdout() + .ok() + .and_then(|s| { + let trimmed = s.trim().to_string(); + // jj may output multiple bookmarks separated by spaces; + // take the first non-empty one + trimmed + .split_whitespace() + .next() + .map(|b| b.trim_end_matches('*').to_string()) + }) + .unwrap_or_else(|| name.clone()) + } else { + name.clone() + }; + + result.push((path, bookmark)); + } + + Ok(result) } - fn find_workspace(&self, _name: &str) -> Result<(PathBuf, String)> { - Err(jj_todo("find_workspace")) + fn find_workspace(&self, name: &str) -> Result<(PathBuf, String)> { + let workspaces = self.list_workspaces()?; + + // First: try to match by handle (directory name) + for (path, branch) in &workspaces { + if let Some(dir_name) = path.file_name() + && dir_name.to_string_lossy() == name + { + return Ok((path.clone(), branch.clone())); + } + } + + // Second: try to match by bookmark/branch name + for (path, branch) in &workspaces { + if branch == name { + return Ok((path.clone(), branch.clone())); + } + } + + // Third: try to match by workspace name + let root = find_jj_root()?; + let stored_path = self + .get_workspace_meta(name, "path") + .map(PathBuf::from); + + if let Some(path) = stored_path { + // Get the bookmark for this workspace + let bookmark = if path.exists() { + jj_cmd(Some(&path)) + .args(&["log", "-r", "@", "--no-graph", "-T", "bookmarks"]) + .run_and_capture_stdout() + .ok() + .and_then(|s| { + s.trim() + .split_whitespace() + .next() + .map(|b| b.trim_end_matches('*').to_string()) + }) + .unwrap_or_else(|| name.to_string()) + } else { + name.to_string() + }; + return Ok((path, bookmark)); + } + + // Check if the workspace name exists in jj + let output = jj_cmd(Some(&root)) + .args(&["workspace", "list"]) + .run_and_capture_stdout() + .unwrap_or_default(); + let ws_names = parse_workspace_list(&output); + if ws_names.contains(&name.to_string()) { + // Workspace exists but no stored path - derive it + let path = if name == "default" { + root.clone() + } else { + root.parent().unwrap_or(&root).join(name) + }; + return Ok((path, name.to_string())); + } + + Err(WorkspaceNotFound(name.to_string()).into()) } - fn get_workspace_path(&self, _branch: &str) -> Result { - Err(jj_todo("get_workspace_path")) + fn get_workspace_path(&self, branch: &str) -> Result { + let workspaces = self.list_workspaces()?; + + for (path, ws_branch) in workspaces { + if ws_branch == branch { + return Ok(path); + } + } + + Err(WorkspaceNotFound(branch.to_string()).into()) } - fn prune_workspaces(&self, _shared_dir: &Path) -> Result<()> { - Err(jj_todo("prune_workspaces")) + fn prune_workspaces(&self, shared_dir: &Path) -> Result<()> { + // In jj, forgetting stale workspaces serves the same purpose as git worktree prune. + // List workspaces and forget any whose paths no longer exist on disk. + let output = jj_cmd(Some(shared_dir)) + .args(&["workspace", "list"]) + .run_and_capture_stdout() + .unwrap_or_default(); + + let ws_names = parse_workspace_list(&output); + for name in ws_names { + if name == "default" { + continue; // Never prune the default workspace + } + + let path = self + .get_workspace_meta(&name, "path") + .map(PathBuf::from) + .unwrap_or_else(|| { + shared_dir.parent().unwrap_or(shared_dir).join(&name) + }); + + if !path.exists() { + debug!(workspace = %name, "jj:pruning stale workspace"); + let _ = jj_cmd(Some(shared_dir)) + .args(&["workspace", "forget", &name]) + .run(); + } + } + + Ok(()) } // ── Workspace metadata ─────────────────────────────────────────── - fn set_workspace_meta(&self, _handle: &str, _key: &str, _value: &str) -> Result<()> { - Err(jj_todo("set_workspace_meta")) + fn set_workspace_meta(&self, handle: &str, key: &str, value: &str) -> Result<()> { + let root = find_jj_root()?; + let config_key = format!("workmux.worktree.{}.{}", handle, key); + jj_cmd(Some(&root)) + .args(&["config", "set", "--repo", &config_key, value]) + .run() + .with_context(|| format!("Failed to set jj config {}", config_key))?; + Ok(()) } - fn get_workspace_meta(&self, _handle: &str, _key: &str) -> Option { - None // TODO: implement jj config reading + fn get_workspace_meta(&self, handle: &str, key: &str) -> Option { + let root = find_jj_root().ok()?; + let config_key = format!("workmux.worktree.{}.{}", handle, key); + jj_cmd(Some(&root)) + .args(&["config", "get", &config_key]) + .run_and_capture_stdout() + .ok() + .filter(|s| !s.is_empty()) } - fn get_workspace_mode(&self, _handle: &str) -> MuxMode { - MuxMode::Window // Default until jj metadata is implemented + fn get_workspace_mode(&self, handle: &str) -> MuxMode { + match self.get_workspace_meta(handle, "mode") { + Some(mode) if mode == "session" => MuxMode::Session, + _ => MuxMode::Window, + } } fn get_all_workspace_modes(&self) -> HashMap { - HashMap::new() // TODO: implement jj config batch reading + let root = match find_jj_root() { + Ok(r) => r, + Err(_) => return HashMap::new(), + }; + + // Parse the config file directly for batch reading + let config_content = read_jj_repo_config(&root); + let mut modes = HashMap::new(); + + // Look for lines matching workmux.worktree..mode pattern + // The TOML structure is nested tables, but we can grep for the relevant lines + // by looking for key = "value" under [workmux.worktree.] sections + let mut current_handle: Option = None; + for line in config_content.lines() { + let trimmed = line.trim(); + + // Match [workmux.worktree.] table headers + if let Some(rest) = trimmed.strip_prefix("[workmux.worktree.") { + if let Some(handle) = rest.strip_suffix(']') { + current_handle = Some(handle.to_string()); + } else { + current_handle = None; + } + } else if let Some(ref handle) = current_handle { + // Match mode = "session" or mode = "window" + if let Some(rest) = trimmed.strip_prefix("mode") { + let rest = rest.trim(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim().trim_matches('"'); + let mode = if value == "session" { + MuxMode::Session + } else { + MuxMode::Window + }; + modes.insert(handle.clone(), mode); + } + } + } else if trimmed.starts_with('[') { + // New section that isn't workmux.worktree + current_handle = None; + } + } + + modes } - fn remove_workspace_meta(&self, _handle: &str) -> Result<()> { - Err(jj_todo("remove_workspace_meta")) + fn remove_workspace_meta(&self, handle: &str) -> Result<()> { + let root = find_jj_root()?; + + // Read the config file, remove the [workmux.worktree.] section, write back + let config_path = root.join(".jj").join("repo").join("config.toml"); + let content = std::fs::read_to_string(&config_path).unwrap_or_default(); + + if content.is_empty() { + return Ok(()); + } + + let section_header = format!("[workmux.worktree.{}]", handle); + let mut new_lines = Vec::new(); + let mut in_target_section = false; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == section_header { + in_target_section = true; + continue; + } + if in_target_section && trimmed.starts_with('[') { + // We've hit the next section + in_target_section = false; + } + if !in_target_section { + new_lines.push(line); + } + } + + let new_content = new_lines.join("\n"); + // Only write if we actually removed something + if new_content.len() != content.len() { + std::fs::write(&config_path, new_content) + .context("Failed to update jj repo config")?; + } + + Ok(()) } // ── Branch/bookmark operations ─────────────────────────────────── fn get_default_branch(&self) -> Result { - Err(jj_todo("get_default_branch")) + self.get_default_branch_in(None) } - fn get_default_branch_in(&self, _workdir: Option<&Path>) -> Result { - Err(jj_todo("get_default_branch_in")) + fn get_default_branch_in(&self, workdir: Option<&Path>) -> Result { + let root = match workdir { + Some(d) => find_jj_root_for(d).unwrap_or_else(|_| find_jj_root().unwrap_or_default()), + None => find_jj_root()?, + }; + + // Try to detect trunk bookmark: check for main, then master + if self.branch_exists_in("main", Some(&root))? { + debug!("jj:default branch 'main'"); + return Ok("main".to_string()); + } + + if self.branch_exists_in("master", Some(&root))? { + debug!("jj:default branch 'master'"); + return Ok("master".to_string()); + } + + // Try checking jj's revset alias for trunk() + if let Ok(output) = jj_cmd(Some(&root)) + .args(&["config", "get", "revset-aliases.trunk()"]) + .run_and_capture_stdout() + { + if !output.is_empty() { + debug!(trunk_alias = %output, "jj:default branch from trunk() alias"); + return Ok(output); + } + } + + Err(anyhow!( + "Could not determine the default branch. \ + Please specify it in .workmux.yaml using the 'main_branch' key." + )) } - fn branch_exists(&self, _name: &str) -> Result { - Err(jj_todo("branch_exists")) + fn branch_exists(&self, name: &str) -> Result { + self.branch_exists_in(name, None) } - fn branch_exists_in(&self, _name: &str, _workdir: Option<&Path>) -> Result { - Err(jj_todo("branch_exists_in")) + fn branch_exists_in(&self, name: &str, workdir: Option<&Path>) -> Result { + let root = match workdir { + Some(d) => find_jj_root_for(d).unwrap_or_else(|_| find_jj_root().unwrap_or_default()), + None => find_jj_root()?, + }; + + // Use jj bookmark list with exact name filter + let output = jj_cmd(Some(&root)) + .args(&["bookmark", "list", "--all", "-T", "name ++ \"\\n\""]) + .run_and_capture_stdout() + .unwrap_or_default(); + + Ok(output.lines().any(|line| line.trim() == name)) } fn get_current_branch(&self) -> Result { - Err(jj_todo("get_current_branch")) + let output = jj_cmd(None) + .args(&["log", "-r", "@", "--no-graph", "-T", "bookmarks"]) + .run_and_capture_stdout() + .context("Failed to get current bookmark")?; + + let trimmed = output.trim(); + if trimmed.is_empty() { + return Err(anyhow!("No bookmark on current change")); + } + + // Take the first bookmark, strip trailing '*' (indicates local-only) + let bookmark = trimmed + .split_whitespace() + .next() + .unwrap_or(trimmed) + .trim_end_matches('*'); + + Ok(bookmark.to_string()) } fn list_checkout_branches(&self) -> Result> { - Err(jj_todo("list_checkout_branches")) + let output = jj_cmd(None) + .args(&["bookmark", "list", "--all", "-T", "name ++ \"\\n\""]) + .run_and_capture_stdout() + .context("Failed to list jj bookmarks")?; + + Ok(output + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()) + } + + fn delete_branch(&self, name: &str, _force: bool, shared_dir: &Path) -> Result<()> { + // jj bookmark delete has no force distinction + jj_cmd(Some(shared_dir)) + .args(&["bookmark", "delete", name]) + .run() + .with_context(|| format!("Failed to delete bookmark '{}'", name))?; + Ok(()) } - 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 { + // For jj, check if the bookmark exists locally + if self.branch_exists(main_branch)? { + return Ok(main_branch.to_string()); + } - fn get_merge_base(&self, _main_branch: &str) -> Result { - Err(jj_todo("get_merge_base")) + // Check for remote tracking bookmark + let remote_main = format!("{}@origin", main_branch); + let output = jj_cmd(None) + .args(&["bookmark", "list", "--all", "-T", "name ++ \"\\n\""]) + .run_and_capture_stdout() + .unwrap_or_default(); + + if output.lines().any(|l| l.trim() == remote_main) { + Ok(remote_main) + } else { + Ok(main_branch.to_string()) + } } - fn get_unmerged_branches(&self, _base: &str) -> Result> { - Err(jj_todo("get_unmerged_branches")) + fn get_unmerged_branches(&self, base: &str) -> Result> { + // List all local bookmarks and check which ones are not ancestors of base + let output = jj_cmd(None) + .args(&["bookmark", "list", "-T", "name ++ \"\\n\""]) + .run_and_capture_stdout() + .unwrap_or_default(); + + let mut unmerged = HashSet::new(); + for line in output.lines() { + let bookmark = line.trim(); + if bookmark.is_empty() || bookmark == base { + continue; + } + + // Check if this bookmark's changes are all ancestors of base + // Using revset: if bookmark is merged into base, `bookmark ~ ::base` is empty + let revset = format!("{} ~ ::{}", bookmark, base); + if let Ok(result) = jj_cmd(None) + .args(&["log", "-r", &revset, "--no-graph", "-T", "change_id"]) + .run_and_capture_stdout() + { + if !result.trim().is_empty() { + unmerged.insert(bookmark.to_string()); + } + } + } + + Ok(unmerged) } fn get_gone_branches(&self) -> Result> { - Err(jj_todo("get_gone_branches")) + // In jj, "gone" branches are local bookmarks whose remote tracking + // bookmark has been deleted. List bookmarks and check for this. + // For now, return empty set - jj handles this differently via + // `jj bookmark list --conflicted` and remote tracking. + Ok(HashSet::new()) } // ── Base branch tracking ───────────────────────────────────────── - fn set_branch_base(&self, _branch: &str, _base: &str) -> Result<()> { - Err(jj_todo("set_branch_base")) + fn set_branch_base(&self, branch: &str, base: &str) -> Result<()> { + let root = find_jj_root()?; + let config_key = format!("workmux.base.{}", branch); + jj_cmd(Some(&root)) + .args(&["config", "set", "--repo", &config_key, base]) + .run() + .context("Failed to set workmux base config")?; + Ok(()) } - fn get_branch_base(&self, _branch: &str) -> Result { - Err(jj_todo("get_branch_base")) + fn get_branch_base(&self, branch: &str) -> Result { + self.get_branch_base_in(branch, None) } - fn get_branch_base_in(&self, _branch: &str, _workdir: Option<&Path>) -> Result { - Err(jj_todo("get_branch_base_in")) + fn get_branch_base_in(&self, branch: &str, workdir: Option<&Path>) -> Result { + let root = match workdir { + Some(d) => find_jj_root_for(d).unwrap_or_else(|_| find_jj_root().unwrap_or_default()), + None => find_jj_root()?, + }; + + let config_key = format!("workmux.base.{}", branch); + let output = jj_cmd(Some(&root)) + .args(&["config", "get", &config_key]) + .run_and_capture_stdout() + .context("Failed to get workmux base config")?; + + if output.is_empty() { + return Err(anyhow!("No workmux-base found for branch '{}'", branch)); + } + + Ok(output) } // ── Status ─────────────────────────────────────────────────────── - fn get_status(&self, _worktree: &Path) -> VcsStatus { - VcsStatus::default() // TODO: implement jj status + fn get_status(&self, worktree: &Path) -> VcsStatus { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .ok(); + + // Get the current bookmark name + let branch = jj_cmd(Some(worktree)) + .args(&["log", "-r", "@", "--no-graph", "-T", "bookmarks"]) + .run_and_capture_stdout() + .ok() + .and_then(|s| { + s.trim() + .split_whitespace() + .next() + .map(|b| b.trim_end_matches('*').to_string()) + }); + + // Check if working copy has changes (jj diff --stat) + let is_dirty = jj_cmd(Some(worktree)) + .args(&["diff", "--stat"]) + .run_and_capture_stdout() + .ok() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + let branch_ref = match &branch { + Some(b) => b.clone(), + None => { + return VcsStatus { + is_dirty, + cached_at: now, + branch: None, + ..Default::default() + }; + } + }; + + // Get base branch + let base_branch = self + .get_branch_base_in(&branch_ref, Some(worktree)) + .ok() + .or_else(|| self.get_default_branch_in(Some(worktree)).ok()) + .unwrap_or_else(|| "main".to_string()); + + // Check for conflicts + let has_conflict = jj_cmd(Some(worktree)) + .args(&["log", "-r", "conflicts()", "--no-graph", "-T", "change_id"]) + .run_and_capture_stdout() + .ok() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + // Get diff stats vs base + let (lines_added, lines_removed) = jj_cmd(Some(worktree)) + .args(&["diff", "--stat", "-r", &format!("{}..@", base_branch)]) + .run_and_capture_stdout() + .ok() + .map(|s| parse_jj_diff_stat_totals(&s)) + .unwrap_or((0, 0)); + + // Get uncommitted diff stats (working copy changes) + let (uncommitted_added, uncommitted_removed) = jj_cmd(Some(worktree)) + .args(&["diff", "--stat"]) + .run_and_capture_stdout() + .ok() + .map(|s| parse_jj_diff_stat_totals(&s)) + .unwrap_or((0, 0)); + + VcsStatus { + ahead: 0, // jj doesn't have ahead/behind in the same way + behind: 0, + has_conflict, + is_dirty, + lines_added, + lines_removed, + uncommitted_added, + uncommitted_removed, + cached_at: now, + base_branch, + branch: Some(branch_ref), + has_upstream: false, // jj tracks this differently + } } - fn has_uncommitted_changes(&self, _worktree: &Path) -> Result { - Err(jj_todo("has_uncommitted_changes")) + fn has_uncommitted_changes(&self, worktree: &Path) -> Result { + // In jj, the working copy is always a commit. "Uncommitted changes" + // means the working copy has modifications (jj diff shows output). + let output = jj_cmd(Some(worktree)) + .args(&["diff", "--stat"]) + .run_and_capture_stdout()?; + Ok(!output.trim().is_empty()) } - fn has_tracked_changes(&self, _worktree: &Path) -> Result { - Err(jj_todo("has_tracked_changes")) + fn has_tracked_changes(&self, worktree: &Path) -> Result { + // jj auto-tracks all files, so this is the same as has_uncommitted_changes + self.has_uncommitted_changes(worktree) } fn has_untracked_files(&self, _worktree: &Path) -> Result { - Err(jj_todo("has_untracked_files")) + // jj auto-tracks all files (respecting .gitignore), so there are no + // "untracked" files in the git sense. + Ok(false) } - 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_staged_changes(&self, worktree: &Path) -> Result { + // jj has no staging area - "staged" maps to "has changes" + self.has_uncommitted_changes(worktree) } - 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")) + fn has_unstaged_changes(&self, worktree: &Path) -> Result { + // jj has no staging area - "unstaged" maps to "has changes" + self.has_uncommitted_changes(worktree) } // ── Merge operations ───────────────────────────────────────────── @@ -285,19 +874,49 @@ impl Vcs for JjVcs { fn build_cleanup_commands( &self, - _shared_dir: &Path, - _branch: &str, - _handle: &str, - _keep_branch: bool, + shared_dir: &Path, + branch: &str, + handle: &str, + keep_branch: bool, _force: bool, ) -> Vec { - Vec::new() // TODO: implement jj cleanup commands + let repo_dir = shell_quote(&shared_dir.to_string_lossy()); + let mut cmds = Vec::new(); + + // Forget the workspace + let handle_q = shell_quote(handle); + cmds.push(format!( + "jj --quiet -R {} workspace forget {} >/dev/null 2>&1", + repo_dir, handle_q + )); + + // Delete bookmark (if not keeping) + if !keep_branch { + let branch_q = shell_quote(branch); + cmds.push(format!( + "jj --quiet -R {} bookmark delete {} >/dev/null 2>&1", + repo_dir, branch_q + )); + } + + // Remove workmux metadata from config + // We can't easily remove a TOML section from a shell script, + // so use jj config unset for individual known keys + for key in &["mode", "path"] { + let config_key = format!("workmux.worktree.{}.{}", handle, key); + cmds.push(format!( + "jj --quiet -R {} config unset --repo {} >/dev/null 2>&1", + repo_dir, + shell_quote(&config_key) + )); + } + + cmds } // ── Status cache ───────────────────────────────────────────────── fn load_status_cache(&self) -> HashMap { - // Reuse the same cache infrastructure as git crate::git::load_status_cache() } @@ -305,3 +924,78 @@ impl Vcs for JjVcs { crate::git::save_status_cache(statuses) } } + +/// Parse the totals line from `jj diff --stat` output. +/// The last line looks like: ` 3 files changed, 10 insertions(+), 5 deletions(-)` +/// Returns (insertions, deletions). +fn parse_jj_diff_stat_totals(output: &str) -> (usize, usize) { + let last_line = output.lines().last().unwrap_or(""); + + let mut insertions = 0; + let mut deletions = 0; + + // Parse "N insertions(+)" and "N deletions(-)" + for part in last_line.split(',') { + let part = part.trim(); + if part.contains("insertion") { + if let Some(n) = part.split_whitespace().next() { + insertions = n.parse().unwrap_or(0); + } + } else if part.contains("deletion") { + if let Some(n) = part.split_whitespace().next() { + deletions = n.parse().unwrap_or(0); + } + } + } + + (insertions, deletions) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_workspace_list_single() { + let output = "default: sqpusytp 28c83b43 (empty) (no description set)\n"; + let names = parse_workspace_list(output); + assert_eq!(names, vec!["default"]); + } + + #[test] + fn test_parse_workspace_list_multiple() { + let output = "default: sqpusytp 28c83b43 (empty) (no description set)\n\ + feature: rlvkpnrz 3d0dead0 implement feature\n"; + let names = parse_workspace_list(output); + assert_eq!(names, vec!["default", "feature"]); + } + + #[test] + fn test_parse_workspace_list_empty() { + let names = parse_workspace_list(""); + assert!(names.is_empty()); + } + + #[test] + fn test_parse_jj_diff_stat_totals_full() { + let output = " src/main.rs | 10 +++++-----\n src/lib.rs | 5 +++--\n 2 files changed, 8 insertions(+), 7 deletions(-)\n"; + assert_eq!(parse_jj_diff_stat_totals(output), (8, 7)); + } + + #[test] + fn test_parse_jj_diff_stat_totals_insertions_only() { + let output = " src/new.rs | 20 ++++++++++++++++++++\n 1 file changed, 20 insertions(+)\n"; + assert_eq!(parse_jj_diff_stat_totals(output), (20, 0)); + } + + #[test] + fn test_parse_jj_diff_stat_totals_empty() { + assert_eq!(parse_jj_diff_stat_totals(""), (0, 0)); + } + + #[test] + fn test_parse_jj_diff_stat_totals_no_changes() { + let output = "0 files changed\n"; + assert_eq!(parse_jj_diff_stat_totals(output), (0, 0)); + } +} From 5dc78805f4f732baed49622e62f9eaca9e2ad896 Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Feb 2026 01:42:20 +0000 Subject: [PATCH 4/8] Implement JjVcs merge and cleanup operations Implement the merge/cleanup operations needed for workmux merge/remove: Merge operations: - commit_with_editor: jj commit (prompts for description) - merge_in_workspace: jj new @ (creates merge commit) - rebase_onto_base: jj rebase -s @ -d - merge_squash: jj squash --from --into @ - switch_branch: jj edit - reset_hard: jj restore (restores working copy to parent) - abort_merge: jj undo (undoes last operation) - stash_push/pop remain no-ops (jj auto-commits working copy) --- src/vcs/jj.rs | 73 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index de4821cd..e81e151e 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -794,24 +794,59 @@ impl Vcs for JjVcs { // ── Merge operations ───────────────────────────────────────────── - fn commit_with_editor(&self, _worktree: &Path) -> Result<()> { - Err(jj_todo("commit_with_editor")) + fn commit_with_editor(&self, worktree: &Path) -> Result<()> { + // `jj commit` creates a new change on top of the current one, + // prompting for a description via the editor. + let status = std::process::Command::new("jj") + .arg("commit") + .current_dir(worktree) + .status() + .context("Failed to run jj commit")?; + + if !status.success() { + return Err(anyhow!("Commit was aborted or failed")); + } + + Ok(()) } - fn merge_in_workspace(&self, _worktree: &Path, _branch: &str) -> Result<()> { - Err(jj_todo("merge_in_workspace")) + fn merge_in_workspace(&self, worktree: &Path, branch: &str) -> Result<()> { + // In jj, merge creates a new change with multiple parents: + // `jj new @ ` creates a merge commit + jj_cmd(Some(worktree)) + .args(&["new", "@", branch]) + .run() + .context("Failed to create merge commit")?; + Ok(()) } - fn rebase_onto_base(&self, _worktree: &Path, _base: &str) -> Result<()> { - Err(jj_todo("rebase_onto_base")) + fn rebase_onto_base(&self, worktree: &Path, base: &str) -> Result<()> { + // `jj rebase -s @ -d ` rebases the current change onto base + jj_cmd(Some(worktree)) + .args(&["rebase", "-s", "@", "-d", base]) + .run() + .with_context(|| format!("Failed to rebase onto '{}'", base))?; + Ok(()) } - fn merge_squash(&self, _worktree: &Path, _branch: &str) -> Result<()> { - Err(jj_todo("merge_squash")) + fn merge_squash(&self, worktree: &Path, branch: &str) -> Result<()> { + // In jj, squash merges the content from source into the current change. + // First rebase the branch onto @, then squash. + // Alternative: `jj squash --from --into @` + jj_cmd(Some(worktree)) + .args(&["squash", "--from", branch, "--into", "@"]) + .run() + .context("Failed to perform squash merge")?; + Ok(()) } - fn switch_branch(&self, _worktree: &Path, _branch: &str) -> Result<()> { - Err(jj_todo("switch_branch")) + fn switch_branch(&self, worktree: &Path, branch: &str) -> Result<()> { + // `jj edit ` switches the working copy to the bookmark's change + jj_cmd(Some(worktree)) + .args(&["edit", branch]) + .run() + .with_context(|| format!("Failed to edit bookmark '{}'", branch))?; + Ok(()) } fn stash_push(&self, _msg: &str, _untracked: bool, _patch: bool) -> Result<()> { @@ -824,12 +859,22 @@ impl Vcs for JjVcs { Ok(()) } - fn reset_hard(&self, _worktree: &Path) -> Result<()> { - Err(jj_todo("reset_hard")) + fn reset_hard(&self, worktree: &Path) -> Result<()> { + // `jj restore` restores the working copy to match the parent change + jj_cmd(Some(worktree)) + .args(&["restore"]) + .run() + .context("Failed to restore working copy")?; + Ok(()) } - fn abort_merge(&self, _worktree: &Path) -> Result<()> { - Err(jj_todo("abort_merge")) + fn abort_merge(&self, worktree: &Path) -> Result<()> { + // `jj undo` undoes the last operation (e.g., a merge) + jj_cmd(Some(worktree)) + .args(&["undo"]) + .run() + .context("Failed to undo operation")?; + Ok(()) } // ── Remotes ────────────────────────────────────────────────────── From 5457b83678c72dbecf1f558c81996325dbe16a6d Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Feb 2026 01:43:26 +0000 Subject: [PATCH 5/8] Implement JjVcs remote operations and edge cases Implement remote operations for jj repos with git backend: Remote operations: - list_remotes: jj git remote list (parse name/url pairs) - remote_exists, add_remote: jj git remote add - set_remote_url: remove + re-add (jj has no set-url) - get_remote_url: parse jj git remote list output - fetch_remote: jj git fetch --remote - fetch_prune: jj git fetch (auto-prunes) - ensure_fork_remote: construct fork URL from origin, reuses git-url-parse for URL manipulation - get_repo_owner: parse owner from origin remote URL Helper functions: - parse_owner_from_url: extract owner from HTTPS/SSH git URLs - construct_fork_url: build fork URL preserving host and protocol --- src/vcs/jj.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 15 deletions(-) diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index e81e151e..dd4c2f1e 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -70,6 +70,49 @@ fn parse_workspace_list(output: &str) -> Vec { .collect() } +/// Parse repository owner from a git remote URL. +/// Supports both HTTPS and SSH formats. +fn parse_owner_from_url(url: &str) -> Option<&str> { + if let Some(https_part) = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + { + https_part.split('/').nth(1) + } else if url.starts_with("git@") { + url.split(':') + .nth(1) + .and_then(|path| path.split('/').next()) + } else { + None + } +} + +/// Construct a fork URL by replacing the owner in an origin URL. +fn construct_fork_url(origin_url: &str, fork_owner: &str) -> Result { + use git_url_parse::GitUrl; + use git_url_parse::types::provider::GenericProvider; + + let parsed_url = GitUrl::parse(origin_url).with_context(|| { + format!("Failed to parse origin URL for fork remote: {}", origin_url) + })?; + + let host = parsed_url.host().unwrap_or("github.com"); + let scheme = parsed_url.scheme().unwrap_or("ssh"); + + let provider: GenericProvider = parsed_url + .provider_info() + .context("Failed to extract provider info from origin URL")?; + let repo_name = provider.repo(); + + let fork_url = match scheme { + "https" => format!("https://{}/{}/{}.git", host, fork_owner, repo_name), + "http" => format!("http://{}/{}/{}.git", host, fork_owner, repo_name), + _ => format!("git@{}:{}/{}.git", host, fork_owner, repo_name), + }; + + Ok(fork_url) +} + /// Read metadata directly from .jj/repo/config.toml (for batch operations). /// Returns the raw content of the config file, or empty string if not found. fn read_jj_repo_config(repo_root: &Path) -> String { @@ -880,39 +923,105 @@ impl Vcs for JjVcs { // ── Remotes ────────────────────────────────────────────────────── fn list_remotes(&self) -> Result> { - Err(jj_todo("list_remotes")) + let output = jj_cmd(None) + .args(&["git", "remote", "list"]) + .run_and_capture_stdout() + .context("Failed to list jj git remotes")?; + + Ok(output + .lines() + .filter_map(|line| { + // Format: " " + line.split_whitespace().next().map(String::from) + }) + .collect()) } - fn remote_exists(&self, _name: &str) -> Result { - Err(jj_todo("remote_exists")) + fn remote_exists(&self, name: &str) -> Result { + Ok(self.list_remotes()?.iter().any(|n| n == name)) } - fn fetch_remote(&self, _remote: &str) -> Result<()> { - Err(jj_todo("fetch_remote")) + fn fetch_remote(&self, remote: &str) -> Result<()> { + jj_cmd(None) + .args(&["git", "fetch", "--remote", remote]) + .run() + .with_context(|| format!("Failed to fetch from remote '{}'", remote))?; + Ok(()) } fn fetch_prune(&self) -> Result<()> { - Err(jj_todo("fetch_prune")) + // jj git fetch auto-prunes deleted remote branches + jj_cmd(None) + .args(&["git", "fetch"]) + .run() + .context("Failed to fetch")?; + Ok(()) } - fn add_remote(&self, _name: &str, _url: &str) -> Result<()> { - Err(jj_todo("add_remote")) + fn add_remote(&self, name: &str, url: &str) -> Result<()> { + jj_cmd(None) + .args(&["git", "remote", "add", name, url]) + .run() + .with_context(|| format!("Failed to add remote '{}' with URL '{}'", name, url))?; + Ok(()) } - fn set_remote_url(&self, _name: &str, _url: &str) -> Result<()> { - Err(jj_todo("set_remote_url")) + fn set_remote_url(&self, name: &str, url: &str) -> Result<()> { + // jj doesn't have a direct "set-url" - remove and re-add + let _ = jj_cmd(None) + .args(&["git", "remote", "remove", name]) + .run(); + self.add_remote(name, url) } - fn get_remote_url(&self, _remote: &str) -> Result { - Err(jj_todo("get_remote_url")) + fn get_remote_url(&self, remote: &str) -> Result { + let output = jj_cmd(None) + .args(&["git", "remote", "list"]) + .run_and_capture_stdout() + .context("Failed to list remotes")?; + + for line in output.lines() { + let mut parts = line.splitn(2, char::is_whitespace); + if let (Some(name), Some(url)) = (parts.next(), parts.next()) { + if name == remote { + return Ok(url.trim().to_string()); + } + } + } + + Err(anyhow!("Remote '{}' not found", remote)) } - fn ensure_fork_remote(&self, _owner: &str) -> Result { - Err(jj_todo("ensure_fork_remote")) + fn ensure_fork_remote(&self, fork_owner: &str) -> Result { + // Reuse same logic as git: check if fork owner matches origin owner + let current_owner = self.get_repo_owner().unwrap_or_default(); + if !current_owner.is_empty() && fork_owner == current_owner { + return Ok("origin".to_string()); + } + + let remote_name = format!("fork-{}", fork_owner); + let origin_url = self.get_remote_url("origin")?; + + // Construct fork URL based on origin URL format + let fork_url = construct_fork_url(&origin_url, fork_owner)?; + + if self.remote_exists(&remote_name)? { + let current_url = self.get_remote_url(&remote_name)?; + if current_url != fork_url { + self.set_remote_url(&remote_name, &fork_url)?; + } + } else { + self.add_remote(&remote_name, &fork_url)?; + } + + Ok(remote_name) } fn get_repo_owner(&self) -> Result { - Err(jj_todo("get_repo_owner")) + let url = self.get_remote_url("origin")?; + parse_owner_from_url(&url) + .ok_or_else(|| anyhow!("Could not parse repository owner from origin URL: {}", url)) + .map(|s| s.to_string()) } // ── Deferred cleanup ───────────────────────────────────────────── From 1bc65836352d2d5f3be2e3fc8ef7579aaff26f0f Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Feb 2026 01:44:36 +0000 Subject: [PATCH 6/8] Add tests for jj support Add comprehensive unit tests for jj VCS implementation: Parser tests: - Workspace list parsing (single, multiple, empty, descriptions, hyphenated names) - Diff stat totals parsing (full, insertions-only, deletions-only, empty, single file, no changes) URL parsing tests: - HTTPS, SSH, HTTP formats - GitHub Enterprise domains - Invalid URLs, local paths Cleanup command tests: - Full cleanup (workspace forget + bookmark delete + config unsets) - Keep-branch mode (no bookmark delete) - Special characters in paths (shell quoting) Metadata tests: - TOML config parsing for get_all_workspace_modes - Verifies section header matching and mode extraction Behavioral tests: - VCS name returns "jj" - has_commits always returns true (jj has root commit) - stash_push/pop are no-ops (jj auto-commits) - has_untracked_files always false (jj auto-tracks) 26 jj-specific tests total. --- src/vcs/jj.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index dd4c2f1e..fb6c02d6 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -1109,6 +1109,8 @@ fn parse_jj_diff_stat_totals(output: &str) -> (usize, usize) { mod tests { use super::*; + // ── Workspace list parsing ─────────────────────────────────────── + #[test] fn test_parse_workspace_list_single() { let output = "default: sqpusytp 28c83b43 (empty) (no description set)\n"; @@ -1130,6 +1132,22 @@ mod tests { assert!(names.is_empty()); } + #[test] + fn test_parse_workspace_list_with_description() { + let output = "default: sqpusytp 28c83b43 some: description with: colons\n"; + let names = parse_workspace_list(output); + assert_eq!(names, vec!["default"]); + } + + #[test] + fn test_parse_workspace_list_hyphenated_name() { + let output = "my-feature: sqpusytp 28c83b43 (empty) (no description set)\n"; + let names = parse_workspace_list(output); + assert_eq!(names, vec!["my-feature"]); + } + + // ── Diff stat parsing ──────────────────────────────────────────── + #[test] fn test_parse_jj_diff_stat_totals_full() { let output = " src/main.rs | 10 +++++-----\n src/lib.rs | 5 +++--\n 2 files changed, 8 insertions(+), 7 deletions(-)\n"; @@ -1142,6 +1160,12 @@ mod tests { assert_eq!(parse_jj_diff_stat_totals(output), (20, 0)); } + #[test] + fn test_parse_jj_diff_stat_totals_deletions_only() { + let output = " src/old.rs | 15 ---------------\n 1 file changed, 15 deletions(-)\n"; + assert_eq!(parse_jj_diff_stat_totals(output), (0, 15)); + } + #[test] fn test_parse_jj_diff_stat_totals_empty() { assert_eq!(parse_jj_diff_stat_totals(""), (0, 0)); @@ -1152,4 +1176,201 @@ mod tests { let output = "0 files changed\n"; assert_eq!(parse_jj_diff_stat_totals(output), (0, 0)); } + + #[test] + fn test_parse_jj_diff_stat_totals_single_file() { + let output = " Cargo.toml | 1 +\n 1 file changed, 1 insertion(+)\n"; + assert_eq!(parse_jj_diff_stat_totals(output), (1, 0)); + } + + // ── URL parsing ────────────────────────────────────────────────── + + #[test] + fn test_parse_owner_https() { + assert_eq!( + parse_owner_from_url("https://github.com/owner/repo.git"), + Some("owner") + ); + } + + #[test] + fn test_parse_owner_ssh() { + assert_eq!( + parse_owner_from_url("git@github.com:owner/repo.git"), + Some("owner") + ); + } + + #[test] + fn test_parse_owner_http() { + assert_eq!( + parse_owner_from_url("http://github.com/owner/repo"), + Some("owner") + ); + } + + #[test] + fn test_parse_owner_enterprise_https() { + assert_eq!( + parse_owner_from_url("https://github.enterprise.com/org/project.git"), + Some("org") + ); + } + + #[test] + fn test_parse_owner_enterprise_ssh() { + assert_eq!( + parse_owner_from_url("git@github.enterprise.net:team/project.git"), + Some("team") + ); + } + + #[test] + fn test_parse_owner_invalid() { + assert_eq!(parse_owner_from_url("not-a-valid-url"), None); + } + + #[test] + fn test_parse_owner_local_path() { + assert_eq!(parse_owner_from_url("/local/path/to/repo"), None); + } + + // ── Cleanup commands ───────────────────────────────────────────── + + #[test] + fn test_build_cleanup_commands_full() { + let jj = JjVcs::new(); + let cmds = jj.build_cleanup_commands( + Path::new("/repo"), + "feature-branch", + "my-handle", + false, // don't keep branch + false, + ); + + assert_eq!(cmds.len(), 4); // forget + bookmark delete + 2 config unsets + assert!(cmds[0].contains("workspace forget")); + assert!(cmds[0].contains("my-handle")); + assert!(cmds[1].contains("bookmark delete")); + assert!(cmds[1].contains("feature-branch")); + assert!(cmds[2].contains("config unset")); + assert!(cmds[2].contains("workmux.worktree.my-handle.mode")); + assert!(cmds[3].contains("config unset")); + assert!(cmds[3].contains("workmux.worktree.my-handle.path")); + } + + #[test] + fn test_build_cleanup_commands_keep_branch() { + let jj = JjVcs::new(); + let cmds = jj.build_cleanup_commands( + Path::new("/repo"), + "feature", + "handle", + true, // keep branch + false, + ); + + assert_eq!(cmds.len(), 3); // forget + 2 config unsets (no bookmark delete) + assert!(cmds[0].contains("workspace forget")); + assert!(!cmds.iter().any(|c| c.contains("bookmark delete"))); + } + + #[test] + fn test_build_cleanup_commands_special_chars() { + let jj = JjVcs::new(); + let cmds = jj.build_cleanup_commands( + Path::new("/path/with spaces"), + "feature/slash", + "handle", + false, + false, + ); + + // Should use shell quoting for paths with spaces + assert!(cmds[0].contains("'/path/with spaces'") || cmds[0].contains("with spaces")); + } + + // ── Metadata config parsing ────────────────────────────────────── + + #[test] + fn test_get_all_workspace_modes_parses_toml() { + // This tests the TOML parsing logic in get_all_workspace_modes + // by verifying the parsing patterns work correctly + + let config = "\ +[workmux.worktree.handle1] +mode = \"session\" +path = \"/some/path\" + +[workmux.worktree.handle2] +mode = \"window\" +path = \"/other/path\" + +[other.section] +key = \"value\" +"; + // Simulate the parsing logic directly + let mut modes = HashMap::new(); + let mut current_handle: Option = None; + + for line in config.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("[workmux.worktree.") { + if let Some(handle) = rest.strip_suffix(']') { + current_handle = Some(handle.to_string()); + } else { + current_handle = None; + } + } else if let Some(ref handle) = current_handle { + if let Some(rest) = trimmed.strip_prefix("mode") { + let rest = rest.trim(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim().trim_matches('"'); + let mode = if value == "session" { + MuxMode::Session + } else { + MuxMode::Window + }; + modes.insert(handle.clone(), mode); + } + } + } else if trimmed.starts_with('[') { + current_handle = None; + } + } + + assert_eq!(modes.len(), 2); + assert_eq!(modes["handle1"], MuxMode::Session); + assert_eq!(modes["handle2"], MuxMode::Window); + } + + // ── VCS name ───────────────────────────────────────────────────── + + #[test] + fn test_jj_vcs_name() { + let jj = JjVcs::new(); + assert_eq!(jj.name(), "jj"); + } + + #[test] + fn test_jj_has_commits_always_true() { + let jj = JjVcs::new(); + assert!(jj.has_commits().unwrap()); + } + + #[test] + fn test_jj_stash_is_noop() { + let jj = JjVcs::new(); + assert!(jj.stash_push("test", false, false).is_ok()); + assert!(jj.stash_pop(Path::new("/tmp")).is_ok()); + } + + #[test] + fn test_jj_untracked_files_always_false() { + // jj auto-tracks all files, so untracked is always false + // (This would fail if called on a non-jj repo, but the method + // itself just returns false without calling jj) + let jj = JjVcs::new(); + assert!(!jj.has_untracked_files(Path::new("/tmp")).unwrap()); + } } From a3e5d5b2e18b070d47db5f6615910e669e41b276 Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Feb 2026 07:49:15 +0000 Subject: [PATCH 7/8] Update documentation to reflect jj (Jujutsu) VCS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add jj workspace mentions alongside git worktree references across all docs, skills, and README. workmux now supports both Git and jj as VCS backends via the Vcs trait, so the documentation should reflect this. Key changes: - Add jj as alternative VCS in taglines, descriptions, and requirements - Rename "Git worktree caveats" page to "Worktree caveats" with git-specific sections marked - Add VCS detection step (Step 0) to merge and rebase skills - Add jj command equivalents to merge strategies and remove --gone flag - Broaden VCS-specific wording (e.g., "gitignored" → "ignored") --- README.md | 10 +++++++--- docs/.vitepress/config.mts | 2 +- docs/guide/configuration.md | 2 +- docs/guide/git-worktree-caveats.md | 20 +++++++++++--------- docs/guide/index.md | 8 +++++--- docs/guide/quick-start.md | 2 +- docs/guide/workflows.md | 5 ++++- docs/index.md | 4 ++-- docs/reference/commands/add.md | 6 +++--- docs/reference/commands/list.md | 4 ++-- docs/reference/commands/merge.md | 4 ++-- docs/reference/commands/remove.md | 2 +- skills/merge/SKILL.md | 11 +++++++++++ skills/rebase/SKILL.md | 7 +++++++ skills/worktree/SKILL.md | 4 ++-- 15 files changed, 60 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4e7356d6..68ed664a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- 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
-

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}" + ); + } }