From ee91b44470bd324ead6e4b4d34982215ca24cd3c Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 31 Jul 2026 23:10:23 -0500 Subject: [PATCH] git worktree: add sync command Add `jj git worktree sync` command that reconciles jj workspaces with git worktrees in colocated repositories: * Adopts Git worktrees created externally (e.g. via `git worktree add`) by creating corresponding jj workspaces. * Forgets jj workspaces whose Git worktrees have been removed. * Repairs workspace paths for Git worktrees that have been moved. Add `git.auto-sync-worktrees` config (default: false) to opt into running this synchronization automatically on every jj invocation. When disabled (the default), users can run `jj git worktree sync` manually or use `jj git worktree adopt` for individual worktrees. --- CHANGELOG.md | 5 + cli/src/cli_util.rs | 317 +++++++++++++++++++++++++++++-- cli/src/commands/git/worktree.rs | 28 +++ cli/src/config-schema.json | 5 + cli/src/config/misc.toml | 1 + cli/src/git_util.rs | 185 ++++++++++++++++-- cli/tests/cli-reference@.md.snap | 14 ++ cli/tests/test_workspaces.rs | 183 ++++++++++++++++++ 8 files changed, 710 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efc8697a62..8a2375bacd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). directory; with names it adopts specific worktrees; with `--all` it adopts every unadopted worktree at once. +* `jj git worktree sync` synchronizes jj workspaces with Git worktrees in + colocated repositories: adopting external worktrees, forgetting removed + ones, and repairing moved paths. Set `git.auto-sync-worktrees = true` + to run this automatically on every jj invocation. + ### Fixed bugs * The default pager flags now include `-K` (`--quit-on-intr`), so pressing diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 2198f7ab6b5..6939899d06d 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -150,6 +150,10 @@ use jj_lib::workspace::WorkspaceLoadError; use jj_lib::workspace::WorkspaceLoader; use jj_lib::workspace::WorkspaceLoaderFactory; use jj_lib::workspace::get_working_copy_factory; +#[cfg(feature = "git")] +use jj_lib::workspace_store::SimpleWorkspaceStore; +#[cfg(feature = "git")] +use jj_lib::workspace_store::WorkspaceStore as _; use pollster::FutureExt as _; use tracing::instrument; use tracing_chrome::ChromeLayerBuilder; @@ -182,6 +186,22 @@ use crate::diff_util::DiffRenderer; use crate::formatter::FormatRecorder; use crate::formatter::Formatter; use crate::formatter::FormatterExt as _; +#[cfg(feature = "git")] +use crate::git_util::GitWorktreeRepoContext; +#[cfg(feature = "git")] +use crate::git_util::create_git_worktree_in_existing_workspace; +#[cfg(feature = "git")] +use crate::git_util::discover_git_worktree_paths; +#[cfg(feature = "git")] +use crate::git_util::git_head_resolves; +#[cfg(feature = "git")] +use crate::git_util::git_rev_parse_path; +#[cfg(feature = "git")] +use crate::git_util::git_worktree_paths; +#[cfg(feature = "git")] +use crate::git_util::repair_jj_repo_link; +#[cfg(feature = "git")] +use crate::git_util::workspace_abs_path; use crate::merge_tools::DiffEditor; use crate::merge_tools::MergeEditor; use crate::merge_tools::MergeToolConfigError; @@ -473,22 +493,38 @@ impl CommandHelper { &self, ui: &Ui, ) -> Result<(WorkspaceCommandHelper, SnapshotStats, bool), CommandError> { - let workspace = self.load_workspace()?; - let env = self.workspace_environment(ui, &workspace)?; - // Acquire the lock to ensure that the loaded repo points to the head - // operation whose refs should be synchronized with the Git repo. This - // prevents races with other processes during Git HEAD and refs - // import/export. - let git_import_export_lock = self - .is_working_copy_writable() - .then(|| env.lock_git_import_export(&workspace)) - .transpose()?; - let mut workspace_command = self.load_from_workspace(ui, workspace, env).await?; + let auto_sync = self.settings().get_bool("git.auto-sync-worktrees")?; + let (workspace_command, git_import_export_lock) = loop { + let workspace = if auto_sync { + self.load_workspace_or_auto_init_git_worktree(ui).await? + } else { + self.load_workspace()? + }; + let env = self.workspace_environment(ui, &workspace)?; + // Acquire the lock to ensure that the loaded repo points to the head + // operation whose refs should be synchronized with the Git repo. This + // prevents races with other processes during Git HEAD and refs + // import/export. + let git_import_export_lock = self + .is_working_copy_writable() + .then(|| env.lock_git_import_export(&workspace)) + .transpose()?; + let workspace_command = self.load_from_workspace(ui, workspace, env).await?; + if auto_sync && workspace_command.ensure_current_workspace_git_worktree(ui)? { + continue; + } + break (workspace_command, git_import_export_lock); + }; + let mut workspace_command = workspace_command; + let old_repo = workspace_command.repo().clone(); + if auto_sync && self.is_working_copy_writable() { + workspace_command.forget_removed_git_worktrees(ui).await?; + } let Some(git_import_export_lock) = git_import_export_lock else { - return Ok((workspace_command, SnapshotStats::default(), false)); + let changed = old_repo.op_id() != workspace_command.repo().op_id(); + return Ok((workspace_command, SnapshotStats::default(), changed)); }; - let old_repo = workspace_command.repo().clone(); let (workspace_command, stats) = match workspace_command .snapshot_impl(ui, &git_import_export_lock) .await @@ -522,12 +558,96 @@ impl CommandHelper { &self, ui: &Ui, ) -> Result { - let workspace = self.load_workspace()?; - let env = self.workspace_environment(ui, &workspace)?; - self.load_from_workspace(ui, workspace, env).await + let auto_sync = self.settings().get_bool("git.auto-sync-worktrees")?; + let mut workspace_command = loop { + let workspace = if auto_sync { + self.load_workspace_or_auto_init_git_worktree(ui).await? + } else { + self.load_workspace()? + }; + let env = self.workspace_environment(ui, &workspace)?; + let workspace_command = self.load_from_workspace(ui, workspace, env).await?; + if auto_sync && workspace_command.ensure_current_workspace_git_worktree(ui)? { + continue; + } + break workspace_command; + }; + if auto_sync && self.is_working_copy_writable() { + workspace_command.forget_removed_git_worktrees(ui).await?; + } + Ok(workspace_command) + } + + pub async fn load_workspace_or_auto_init_git_worktree( + &self, + ui: &Ui, + ) -> Result { + match self.load_workspace() { + Ok(workspace) => Ok(workspace), + Err(err) => { + if self.data.global_args.repository.is_none() + && let Some(workspace) = self.auto_init_git_worktree_workspace(ui).await? + { + return Ok(workspace); + } + Err(err) + } + } + } + + #[cfg(feature = "git")] + async fn auto_init_git_worktree_workspace( + &self, + ui: &Ui, + ) -> Result, CommandError> { + let git_settings = jj_lib::git::GitSettings::from_settings(self.settings())?; + let Some(git_paths) = discover_git_worktree_paths(&git_settings, self.cwd())? else { + return Ok(None); + }; + let main_workspace_root = match git_paths.common_git_dir.parent() { + Some(path) if path.join(".jj").is_dir() => path, + _ => return Ok(None), + }; + let main_workspace = self.load_workspace_at(main_workspace_root, self.settings())?; + let main_loader = self.new_workspace_loader_at(main_workspace_root)?; + let working_copy_factory = + get_working_copy_factory(main_loader.as_ref(), &self.data.working_copy_factories) + .map_err(|err| { + map_workspace_load_error(WorkspaceLoadError::StoreLoadError(err), None) + })?; + let repo = main_workspace.repo_loader().load_at_head().await?; + if repo + .view() + .get_wc_commit_id(&git_paths.workspace_name) + .is_some() + { + return Ok(None); + } + let (workspace, _repo) = Workspace::init_workspace_with_existing_repo( + &git_paths.worktree_root, + main_workspace.repo_path(), + &repo, + working_copy_factory, + git_paths.workspace_name, + ) + .await?; + writeln!( + ui.status(), + "Created jj workspace for Git worktree at \"{}\".", + git_paths.worktree_root.display() + )?; + Ok(Some(workspace)) + } + + #[cfg(not(feature = "git"))] + async fn auto_init_git_worktree_workspace( + &self, + _ui: &Ui, + ) -> Result, CommandError> { + Ok(None) } - async fn load_from_workspace( + pub async fn load_from_workspace( &self, ui: &Ui, workspace: Workspace, @@ -2340,6 +2460,169 @@ to the current parents may contain changes from multiple commits. } } + #[cfg(feature = "git")] + fn git_worktree_repo_context(&self) -> Result, CommandError> { + if jj_lib::git::get_git_repo(self.repo().store()).is_err() { + return Ok(None); + } + let workspace_store = SimpleWorkspaceStore::load(self.repo_path())?; + let Some(main_workspace_root) = + workspace_abs_path(self.repo_path(), &workspace_store, WorkspaceName::DEFAULT)? + else { + return Ok(None); + }; + if !main_workspace_root.join(".git").exists() { + return Ok(None); + } + let git_executable: PathBuf = self.settings().get("git.executable-path")?; + if git_rev_parse_path(&git_executable, &main_workspace_root, "--git-common-dir")?.is_none() + { + return Ok(None); + } + Ok(Some(GitWorktreeRepoContext { + workspace_store, + main_workspace_root, + git_executable, + })) + } + + #[cfg(feature = "git")] + pub fn ensure_current_workspace_git_worktree(&self, ui: &Ui) -> Result { + if self.env.working_copy_shared_with_git || self.workspace_name() == WorkspaceName::DEFAULT + { + return Ok(false); + } + if self.workspace_root().join(".git").exists() { + return Ok(false); + } + let Some(ctx) = self.git_worktree_repo_context()? else { + return Ok(false); + }; + if !git_head_resolves(&ctx.git_executable, &ctx.main_workspace_root)? { + return Ok(false); + } + create_git_worktree_in_existing_workspace( + &ctx.git_executable, + &ctx.main_workspace_root, + self.workspace_root(), + )?; + writeln!( + ui.status(), + "Created Git worktree for the current workspace." + )?; + Ok(true) + } + + #[cfg(not(feature = "git"))] + pub fn ensure_current_workspace_git_worktree(&self, _ui: &Ui) -> Result { + Ok(false) + } + + #[cfg(feature = "git")] + pub async fn forget_removed_git_worktrees(&mut self, _ui: &Ui) -> Result<(), CommandError> { + let Some(ctx) = self.git_worktree_repo_context()? else { + return Ok(()); + }; + let live_worktree_paths = + git_worktree_paths(&ctx.git_executable, &ctx.main_workspace_root)?; + self.repair_moved_git_worktree_paths(&ctx.workspace_store, &live_worktree_paths)?; + let removed_workspaces = self + .repo() + .view() + .wc_commit_ids() + .keys() + .filter(|name| name.as_str() != WorkspaceName::DEFAULT.as_str()) + .filter_map(|name| { + let path = workspace_abs_path(self.repo_path(), &ctx.workspace_store, name) + .ok() + .flatten()?; + if !path.exists() + || (path.join(".git").is_file() && !live_worktree_paths.contains(&path)) + { + Some(name.clone()) + } else { + None + } + }) + .collect_vec(); + if removed_workspaces.is_empty() { + return Ok(()); + } + + let description = if let [workspace_name] = removed_workspaces.as_slice() { + format!( + "forget removed Git worktree workspace {}", + workspace_name.as_symbol() + ) + } else { + format!( + "forget removed Git worktree workspaces {}", + removed_workspaces + .iter() + .map(|workspace_name| workspace_name.as_symbol()) + .join(", ") + ) + }; + let mut tx = start_repo_transaction( + self.repo(), + self.workspace_name(), + self.env.command.string_args(), + ); + for workspace_name in &removed_workspaces { + tx.repo_mut().remove_workspace(workspace_name).await?; + } + rebase_mutable_descendants(&self.env, &mut tx).await?; + ctx.workspace_store.forget( + &removed_workspaces + .iter() + .map(|name| name.as_ref()) + .collect_vec(), + )?; + let repo = tx.commit(description).await?; + self.user_repo = ReadonlyUserRepo::new(repo); + Ok(()) + } + + #[cfg(feature = "git")] + fn repair_moved_git_worktree_paths( + &self, + workspace_store: &SimpleWorkspaceStore, + live_worktree_paths: &HashSet, + ) -> Result<(), CommandError> { + for path in live_worktree_paths { + if path == self.workspace_root() || !path.join(".jj").is_dir() { + continue; + } + repair_jj_repo_link(path, self.repo_path())?; + let Ok(workspace) = self.env.command.load_workspace_at(path, self.settings()) else { + continue; + }; + if workspace.repo_path() != self.repo_path() + || self + .repo() + .view() + .get_wc_commit_id(workspace.workspace_name()) + .is_none() + { + continue; + } + let old_path = workspace_abs_path( + self.repo_path(), + workspace_store, + workspace.workspace_name(), + )?; + if old_path.as_deref() != Some(path) && !old_path.is_some_and(|path| path.exists()) { + workspace_store.add(workspace.workspace_name(), path)?; + } + } + Ok(()) + } + + #[cfg(not(feature = "git"))] + pub async fn forget_removed_git_worktrees(&mut self, _ui: &Ui) -> Result<(), CommandError> { + Ok(()) + } + async fn finish_transaction( &mut self, ui: &Ui, diff --git a/cli/src/commands/git/worktree.rs b/cli/src/commands/git/worktree.rs index 843cf546191..c6c908c74c1 100644 --- a/cli/src/commands/git/worktree.rs +++ b/cli/src/commands/git/worktree.rs @@ -49,10 +49,23 @@ pub struct GitWorktreeAdoptArgs { all: bool, } +/// Synchronize jj workspaces with Git worktrees +/// +/// In a colocated Git repository, this command reconciles jj workspace +/// state with Git worktree state. It adopts externally-created Git +/// worktrees as jj workspaces, forgets workspaces whose Git worktrees +/// have been removed, and repairs paths for moved worktrees. +/// +/// Set `git.auto-sync-worktrees = true` to run this synchronization +/// automatically on every jj invocation. +#[derive(clap::Args, Clone, Debug)] +pub struct GitWorktreeSyncArgs {} + /// Manage Git worktrees #[derive(clap::Subcommand, Clone, Debug)] pub enum GitWorktreeCommand { Adopt(GitWorktreeAdoptArgs), + Sync(GitWorktreeSyncArgs), } pub async fn cmd_git_worktree( @@ -62,6 +75,7 @@ pub async fn cmd_git_worktree( ) -> Result<(), CommandError> { match subcommand { GitWorktreeCommand::Adopt(args) => cmd_git_worktree_adopt(ui, command, args).await, + GitWorktreeCommand::Sync(args) => cmd_git_worktree_sync(ui, command, args).await, } } @@ -287,3 +301,17 @@ async fn cmd_git_worktree_adopt_cwd( )?; Ok(()) } + +#[instrument(skip_all)] +async fn cmd_git_worktree_sync( + ui: &mut Ui, + command: &CommandHelper, + _args: &GitWorktreeSyncArgs, +) -> Result<(), CommandError> { + let workspace = command.load_workspace_or_auto_init_git_worktree(ui).await?; + let env = command.workspace_environment(ui, &workspace)?; + let mut workspace_command = command.load_from_workspace(ui, workspace, env).await?; + workspace_command.ensure_current_workspace_git_worktree(ui)?; + workspace_command.forget_removed_git_worktrees(ui).await?; + Ok(()) +} diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json index 4ff0790b875..bfa77cb78e0 100644 --- a/cli/src/config-schema.json +++ b/cli/src/config-schema.json @@ -549,6 +549,11 @@ "description": "Path to the git executable", "default": "git" }, + "auto-sync-worktrees": { + "type": "boolean", + "description": "Automatically synchronize jj workspaces with Git worktrees in colocated repositories", + "default": false + }, "colocate": { "type": "boolean", "description": "Whether to colocate the working copy with the git repository", diff --git a/cli/src/config/misc.toml b/cli/src/config/misc.toml index 70ed210c17e..a78dc0917d1 100644 --- a/cli/src/config/misc.toml +++ b/cli/src/config/misc.toml @@ -25,6 +25,7 @@ disabled-branches = [] # no builtin aliases [git] +auto-sync-worktrees = false colocate = true object-hash = "sha1" private-commits = "none()" diff --git a/cli/src/git_util.rs b/cli/src/git_util.rs index a775c743bbf..e173b00233d 100644 --- a/cli/src/git_util.rs +++ b/cli/src/git_util.rs @@ -14,7 +14,9 @@ //! Git utilities shared by various commands. +use std::collections::HashSet; use std::error; +use std::fs; use std::io; use std::io::Write as _; use std::iter; @@ -45,10 +47,13 @@ use jj_lib::git::GitSettings; use jj_lib::git::GitSidebandLineTerminator; use jj_lib::git::GitSubprocessCallback; use jj_lib::op_store::RemoteRefState; +use jj_lib::ref_name::WorkspaceName; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo; use jj_lib::settings::RemoteSettingsMap; use jj_lib::workspace::Workspace; +use jj_lib::workspace_store::SimpleWorkspaceStore; +use jj_lib::workspace_store::WorkspaceStore as _; use unicode_width::UnicodeWidthStr as _; use crate::cleanup_guard::CleanupGuard; @@ -74,14 +79,23 @@ pub fn is_colocated_git_workspace(workspace: &Workspace) -> bool { if git_workdir == workspace.workspace_root() { return true; } - let dot_git = workspace.workspace_root().join(".git"); - // A .git file (gitlink) indicates a git worktree, making this a colocated - // workspace. - if dot_git.is_file() { - return true; + if workspace.workspace_root().join(".git").is_file() { + let Ok(worktree_repo) = gix::open(workspace.workspace_root()) else { + return false; + }; + let Ok(worktree_common_dir) = dunce::canonicalize(worktree_repo.common_dir()) else { + return false; + }; + let Ok(backend_common_dir) = dunce::canonicalize(git_backend.git_repo().common_dir()) + else { + return false; + }; + return worktree_repo.git_dir() != worktree_repo.common_dir() + && worktree_common_dir == backend_common_dir; } // Colocated workspace should have ".git" directory or symlink. Compare // its parent as the git_workdir might be resolved from the real ".git" path. + let dot_git = workspace.workspace_root().join(".git"); let Ok(dot_git_path) = dunce::canonicalize(dot_git) else { return false; }; @@ -715,13 +729,14 @@ pub fn discover_git_worktree_paths( git_settings: &GitSettings, cwd: &Path, ) -> Result, CommandError> { - let Some(worktree_root) = git_rev_parse_path(git_settings, cwd, "--show-toplevel")? else { + let git_executable = &git_settings.executable_path; + let Some(worktree_root) = git_rev_parse_path(git_executable, cwd, "--show-toplevel")? else { return Ok(None); }; - let Some(git_dir) = git_rev_parse_path(git_settings, cwd, "--git-dir")? else { + let Some(git_dir) = git_rev_parse_path(git_executable, cwd, "--git-dir")? else { return Ok(None); }; - let Some(common_git_dir) = git_rev_parse_path(git_settings, cwd, "--git-common-dir")? else { + let Some(common_git_dir) = git_rev_parse_path(git_executable, cwd, "--git-common-dir")? else { return Ok(None); }; if git_dir == common_git_dir { @@ -740,12 +755,12 @@ pub fn discover_git_worktree_paths( })) } -fn git_rev_parse_path( - git_settings: &GitSettings, +pub(crate) fn git_rev_parse_path( + git_executable: &Path, cwd: &Path, arg: &str, ) -> Result, CommandError> { - let Ok(output) = Command::new(&git_settings.executable_path) + let Ok(output) = Command::new(git_executable) .arg("rev-parse") .arg(arg) .current_dir(cwd) @@ -775,6 +790,154 @@ fn git_rev_parse_path( } } +pub(crate) struct GitWorktreeRepoContext { + pub(crate) workspace_store: SimpleWorkspaceStore, + pub(crate) main_workspace_root: PathBuf, + pub(crate) git_executable: PathBuf, +} + +pub(crate) fn git_head_resolves(git_executable: &Path, cwd: &Path) -> Result { + let output = std::process::Command::new(git_executable) + .args(["rev-parse", "--verify", "--quiet", "HEAD"]) + .current_dir(cwd) + .output() + .map_err(|err| user_error(format!("Failed to run `git rev-parse`: {err}")))?; + Ok(output.status.success()) +} + +pub(crate) fn workspace_abs_path( + repo_path: &Path, + workspace_store: &SimpleWorkspaceStore, + workspace_name: &WorkspaceName, +) -> Result, CommandError> { + let Some(path) = workspace_store.get_workspace_path(workspace_name)? else { + return Ok(None); + }; + let path = if path.is_absolute() { + path + } else { + repo_path.join(path) + }; + match dunce::canonicalize(&path) { + Ok(path) => Ok(Some(path)), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Some(path)), + Err(err) => Err(user_error_with_message( + format!("Failed to resolve workspace path '{}'", path.display()), + err, + )), + } +} + +pub(crate) fn repair_jj_repo_link( + workspace_root: &Path, + repo_path: &Path, +) -> Result<(), CommandError> { + let jj_dir = workspace_root.join(".jj"); + let repo_file_path = jj_dir.join("repo"); + if !repo_file_path.is_file() { + return Ok(()); + } + let jj_dir_abs = dunce::canonicalize(&jj_dir).map_err(|err| { + user_error_with_message(format!("Failed to resolve '{}'", jj_dir.display()), err) + })?; + let repo_dir = dunce::canonicalize(repo_path).map_err(|err| { + user_error_with_message(format!("Failed to resolve '{}'", repo_path.display()), err) + })?; + let path_to_store = file_util::relative_path(&jj_dir_abs, &repo_dir); + let path_to_store = if path_to_store.is_relative() { + file_util::slash_path(&path_to_store).into_owned() + } else { + path_to_store + }; + let repo_dir_bytes = file_util::path_to_bytes(&path_to_store) + .map_err(|err| user_error_with_message("Failed to encode jj repo path", err))?; + if fs::read(&repo_file_path).ok().as_deref() == Some(repo_dir_bytes) { + return Ok(()); + } + fs::write(&repo_file_path, repo_dir_bytes) + .map_err(|err| user_error_with_message("Failed to repair jj workspace link", err))?; + Ok(()) +} + +pub(crate) fn git_worktree_paths( + git_executable: &Path, + main_workspace_root: &Path, +) -> Result, CommandError> { + let output = std::process::Command::new(git_executable) + .args(["worktree", "list", "--porcelain", "-z"]) + .current_dir(main_workspace_root) + .output() + .map_err(|err| user_error(format!("Failed to run `git worktree list`: {err}")))?; + if !output.status.success() { + return Err(user_error(format!( + "Failed to list Git worktrees: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + let mut paths = HashSet::new(); + for field in output.stdout.split_str(b"\0") { + let Some(path) = field.strip_prefix(b"worktree ") else { + continue; + }; + let Ok(path) = path.to_str() else { + continue; + }; + if let Ok(path) = dunce::canonicalize(path) { + paths.insert(path); + } + } + Ok(paths) +} + +pub(crate) fn create_git_worktree_in_existing_workspace( + git_executable: &Path, + main_workspace_root: &Path, + workspace_root: &Path, +) -> Result<(), CommandError> { + let parent = workspace_root.parent().unwrap_or(workspace_root); + let temp_dir = tempfile::Builder::new() + .prefix(".jj-git-worktree-") + .tempdir_in(parent) + .map_err(|err| user_error_with_message("Failed to create temporary Git worktree", err))?; + let output = std::process::Command::new(git_executable) + .args(["worktree", "add", "--detach", "--no-checkout"]) + .arg(temp_dir.path()) + .arg("HEAD") + .current_dir(main_workspace_root) + .output() + .map_err(|err| user_error(format!("Failed to run `git worktree add`: {err}")))?; + if !output.status.success() { + return Err(user_error(format!( + "Failed to create Git worktree: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + let temp_dot_git = temp_dir.path().join(".git"); + let git_file = fs::read_to_string(&temp_dot_git) + .map_err(|err| user_error_with_message("Failed to read temporary Git worktree", err))?; + let Some(git_dir) = git_file.trim().strip_prefix("gitdir: ") else { + return Err(user_error( + "Temporary Git worktree has unexpected .git file", + )); + }; + let git_dir = Path::new(git_dir); + let git_dir = if git_dir.is_absolute() { + git_dir.to_owned() + } else { + temp_dir.path().join(git_dir) + }; + let dot_git = workspace_root.join(".git"); + fs::rename(&temp_dot_git, &dot_git) + .map_err(|err| user_error_with_message("Failed to install Git worktree file", err))?; + fs::write(git_dir.join("gitdir"), dot_git.to_string_lossy().as_bytes()) + .map_err(|err| user_error_with_message("Failed to update Git worktree metadata", err))?; + temp_dir + .close() + .map_err(|err| user_error_with_message("Failed to remove temporary Git worktree", err))?; + Ok(()) +} + #[cfg(test)] mod tests { use std::path::MAIN_SEPARATOR; diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 673a3a6deb5..49d69d98534 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -74,6 +74,7 @@ This document contains the help content for the `jj` command-line program. * [`jj git root`↴](#jj-git-root) * [`jj git worktree`↴](#jj-git-worktree) * [`jj git worktree adopt`↴](#jj-git-worktree-adopt) +* [`jj git worktree sync`↴](#jj-git-worktree-sync) * [`jj help`↴](#jj-help) * [`jj interdiff`↴](#jj-interdiff) * [`jj log`↴](#jj-log) @@ -1976,6 +1977,7 @@ Manage Git worktrees ###### **Subcommands:** * `adopt` — Adopt existing Git worktrees as jj workspaces +* `sync` — Synchronize jj workspaces with Git worktrees @@ -1997,6 +1999,18 @@ With no arguments, adopts the Git worktree at the current directory. With worktr +## `jj git worktree sync` + +Synchronize jj workspaces with Git worktrees + +In a colocated Git repository, this command reconciles jj workspace state with Git worktree state. It adopts externally-created Git worktrees as jj workspaces, forgets workspaces whose Git worktrees have been removed, and repairs paths for moved worktrees. + +Set `git.auto-sync-worktrees = true` to run this synchronization automatically on every jj invocation. + +**Usage:** `jj git worktree sync` + + + ## `jj help` Print this message or the help of the given subcommand(s) diff --git a/cli/tests/test_workspaces.rs b/cli/tests/test_workspaces.rs index 1086d4d22a6..db628e1d272 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -368,6 +368,189 @@ fn test_git_worktree_adopt_all_none_found() { "); } +#[test] +fn test_git_worktree_auto_workspace() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-sync-worktrees = true"); + test_env.add_config("git.colocate = true"); + test_env.run_jj_in(".", ["git", "init", "main"]).success(); + let main_dir = test_env.work_dir("main"); + let linked_dir = test_env.work_dir("linked"); + + main_dir.write_file("file", "contents"); + main_dir.run_jj(["commit", "-m", "initial"]).success(); + let output = std::process::Command::new("git") + .args(["worktree", "add", "--detach"]) + .arg(linked_dir.root()) + .arg("HEAD") + .current_dir(main_dir.root()) + .output() + .expect("git worktree add failed to spawn"); + assert!( + output.status.success(), + "git worktree add failed with {}: +{} +{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!linked_dir.root().join(".jj").exists()); + + let output = linked_dir.run_jj(["status"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + The working copy has no changes. + Working copy (@) : pmmvwywv 058f604d (empty) (no description set) + Parent commit (@-): qpvuntsm 7b22a8cb initial + [EOF] + ------- stderr ------- + Created jj workspace for Git worktree at "$TEST_ENV/linked". + [EOF] + "#); + assert!(linked_dir.root().join(".jj").is_dir()); + + let output = main_dir.run_jj(["workspace", "list"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + default: . rlvkpnrz 504e3d8c (empty) (no description set) + linked: ../linked pmmvwywv 058f604d (empty) (no description set) + [EOF] + "#); + + let output = std::process::Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(linked_dir.root()) + .current_dir(main_dir.root()) + .output() + .expect("git worktree remove failed to spawn"); + assert!( + output.status.success(), + "git worktree remove failed with {}: +{} +{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let output = main_dir.run_jj(["workspace", "list"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + default: . rlvkpnrz 504e3d8c (empty) (no description set) + [EOF] + "#); +} + +#[test] +fn test_git_worktree_custom_workspace_name_not_forgotten() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-sync-worktrees = true"); + test_env.add_config("git.colocate = true"); + test_env.run_jj_in(".", ["git", "init", "main"]).success(); + let main_dir = test_env.work_dir("main"); + + main_dir.write_file("file", "contents"); + main_dir.run_jj(["commit", "-m", "initial"]).success(); + main_dir + .run_jj(["workspace", "add", "--name", "foo", "../bar"]) + .success(); + + let output = main_dir.run_jj(["workspace", "list"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + default: . rlvkpnrz 504e3d8c (empty) (no description set) + foo: ../bar pmmvwywv 058f604d (empty) (no description set) + [EOF] + "#); +} + +#[test] +fn test_git_worktree_move_repairs_workspace_path() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-sync-worktrees = true"); + test_env.add_config("git.colocate = true"); + test_env.run_jj_in(".", ["git", "init", "main"]).success(); + let main_dir = test_env.work_dir("main"); + let secondary_dir = test_env.work_dir("secondary"); + let moved_dir = test_env.work_dir("nested/moved"); + + main_dir.write_file("file", "contents"); + main_dir.run_jj(["commit", "-m", "initial"]).success(); + main_dir + .run_jj(["workspace", "add", "../secondary"]) + .success(); + main_dir.create_dir("../nested"); + let output = std::process::Command::new("git") + .args(["worktree", "move"]) + .arg(secondary_dir.root()) + .arg(moved_dir.root()) + .current_dir(main_dir.root()) + .output() + .expect("git worktree move failed to spawn"); + assert!( + output.status.success(), + "git worktree move failed with {}: +{} +{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let output = main_dir.run_jj(["workspace", "list"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + default: . rlvkpnrz 504e3d8c (empty) (no description set) + secondary: ../nested/moved pmmvwywv 058f604d (empty) (no description set) + [EOF] + "#); + + let output = moved_dir.run_jj(["status"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + The working copy has no changes. + Working copy (@) : pmmvwywv 058f604d (empty) (no description set) + Parent commit (@-): qpvuntsm 7b22a8cb initial + [EOF] + "#); +} + +#[test] +fn test_git_worktree_created_after_unborn_head() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-sync-worktrees = true"); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + let secondary_dir = test_env.work_dir("secondary"); + let git_repo = git::open(main_dir.root()); + + main_dir + .run_jj(["workspace", "add", "../secondary"]) + .success(); + assert!(!secondary_dir.root().join(".git").exists()); + + git::set_symbolic_reference(&git_repo, "HEAD", "refs/heads/master"); + git::add_commit( + &git_repo, + "refs/heads/master", + "file", + b"contents", + "initial", + &[], + ); + + let output = secondary_dir.run_jj(["status"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + Working copy changes: + D file + Working copy (@) : kkmpptxz 7ab2fc04 (no description set) + Parent commit (@-): slsumksp 97358f54 master | initial + [EOF] + ------- stderr ------- + Created Git worktree for the current workspace. + Done importing changes from the underlying Git repo. + [EOF] + "#); + assert!(secondary_dir.root().join(".git").is_file()); +} + #[test] fn test_workspaces_add_with_message() { let test_env = TestEnvironment::default();