From e8cd42f48aa49e90dc14c7f58e141e0e7da8799c Mon Sep 17 00:00:00 2001 From: Caleb White Date: Tue, 11 Aug 2026 13:15:06 -0500 Subject: [PATCH] git worktree: add adopt command for existing git worktrees Add `jj git worktree adopt` command that adopts existing Git worktrees as jj workspaces. Three modes are supported: * No arguments: adopts the Git worktree at the current directory. * Named worktrees: `jj git worktree adopt ...` adopts specific Git worktrees by name from any jj workspace or git worktree. * `--all`: adopts all unadopted Git worktrees at once. The command works from within an existing jj workspace or from inside an unadopted git worktree by discovering the parent jj repo via git. Guardrails prevent adopting from the main workspace directory and report clear errors for already-adopted or nonexistent worktrees. --- CHANGELOG.md | 5 + cli/src/cli_util.rs | 12 ++ cli/src/commands/git/mod.rs | 6 + cli/src/commands/git/worktree.rs | 289 +++++++++++++++++++++++++++++++ cli/src/git_util.rs | 71 ++++++++ cli/tests/cli-reference@.md.snap | 33 ++++ cli/tests/test_workspaces.rs | 226 ++++++++++++++++++++++++ 7 files changed, 642 insertions(+) create mode 100644 cli/src/commands/git/worktree.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2537701c8e..5efc8697a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). removes it, allowing colocation to be toggled after workspace creation. +* `jj git worktree adopt` adopts existing Git worktrees as jj + workspaces. With no arguments it adopts the worktree at the current + directory; with names it adopts specific worktrees; with `--all` it + adopts every unadopted worktree at once. + ### 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 7aa4caf026a..2198f7ab6b5 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -576,6 +576,18 @@ impl CommandHelper { Ok(factory) } + pub fn get_working_copy_factory_at( + &self, + workspace_root: &Path, + ) -> Result<&dyn WorkingCopyFactory, CommandError> { + let loader = self.new_workspace_loader_at(workspace_root)?; + let factory: Result<_, WorkspaceLoadError> = + get_working_copy_factory(loader.as_ref(), &self.data.working_copy_factories) + .map_err(|e| e.into()); + let factory = factory.map_err(|err| map_workspace_load_error(err, None))?; + Ok(factory) + } + /// Loads workspace for the current command. #[instrument(skip_all)] pub fn load_workspace(&self) -> Result { diff --git a/cli/src/commands/git/mod.rs b/cli/src/commands/git/mod.rs index e87926aafa3..7b19709635f 100644 --- a/cli/src/commands/git/mod.rs +++ b/cli/src/commands/git/mod.rs @@ -21,6 +21,7 @@ mod init; mod push; mod remote; mod root; +mod worktree; use std::io::Write as _; @@ -56,6 +57,8 @@ use self::remote::RemoteCommand; use self::remote::cmd_git_remote; use self::root::GitRootArgs; use self::root::cmd_git_root; +use self::worktree::GitWorktreeCommand; +use self::worktree::cmd_git_worktree; use crate::cli_util::CommandHelper; use crate::cli_util::WorkspaceCommandHelper; use crate::command_error::CommandError; @@ -87,6 +90,8 @@ pub enum GitCommand { #[command(subcommand)] Remote(RemoteCommand), Root(GitRootArgs), + #[command(subcommand)] + Worktree(GitWorktreeCommand), } pub async fn cmd_git( @@ -104,6 +109,7 @@ pub async fn cmd_git( GitCommand::Push(args) => cmd_git_push(ui, command, args).await, GitCommand::Remote(args) => cmd_git_remote(ui, command, args).await, GitCommand::Root(args) => cmd_git_root(ui, command, args).await, + GitCommand::Worktree(args) => cmd_git_worktree(ui, command, args).await, } } diff --git a/cli/src/commands/git/worktree.rs b/cli/src/commands/git/worktree.rs new file mode 100644 index 00000000000..843cf546191 --- /dev/null +++ b/cli/src/commands/git/worktree.rs @@ -0,0 +1,289 @@ +// Copyright 2026 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::io::Write as _; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use jj_lib::git; +use jj_lib::git::GitSettings; +use jj_lib::ref_name::WorkspaceNameBuf; +use jj_lib::repo::ReadonlyRepo; +use jj_lib::repo::Repo as _; +use jj_lib::working_copy::WorkingCopyFactory; +use jj_lib::workspace::Workspace; +use tracing::instrument; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::command_error::user_error; +use crate::command_error::user_error_with_message; +use crate::git_util::discover_git_worktree_paths; +use crate::ui::Ui; + +/// Adopt existing Git worktrees as jj workspaces +/// +/// With no arguments, adopts the Git worktree at the current directory. +/// With worktree names, adopts those specific worktrees. With `--all`, +/// adopts all unadopted Git worktrees. +#[derive(clap::Args, Clone, Debug)] +pub struct GitWorktreeAdoptArgs { + /// Names of Git worktrees to adopt + #[arg(conflicts_with = "all")] + names: Vec, + + /// Adopt all unadopted Git worktrees + #[arg(long)] + all: bool, +} + +/// Manage Git worktrees +#[derive(clap::Subcommand, Clone, Debug)] +pub enum GitWorktreeCommand { + Adopt(GitWorktreeAdoptArgs), +} + +pub async fn cmd_git_worktree( + ui: &mut Ui, + command: &CommandHelper, + subcommand: &GitWorktreeCommand, +) -> Result<(), CommandError> { + match subcommand { + GitWorktreeCommand::Adopt(args) => cmd_git_worktree_adopt(ui, command, args).await, + } +} + +struct GitLinkedWorktree { + name: WorkspaceNameBuf, + worktree_root: PathBuf, +} + +fn list_git_linked_worktrees( + git_executable: &Path, + main_workspace_root: &Path, +) -> Result, CommandError> { + use std::process::Command; + + use bstr::ByteSlice as _; + + let output = Command::new(git_executable) + .args(["worktree", "list", "--porcelain", "-z"]) + .current_dir(main_workspace_root) + .output() + .map_err(|err| user_error_with_message("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 main_root = dunce::canonicalize(main_workspace_root).ok(); + let mut worktrees = Vec::new(); + for block in output.stdout.split_str(b"\0\0") { + for field in block.split_str(b"\0") { + if let Some(path) = field.strip_prefix(b"worktree ") { + let Ok(path_str) = path.to_str() else { + continue; + }; + let Ok(worktree_root) = dunce::canonicalize(path_str) else { + continue; + }; + if main_root.as_ref() == Some(&worktree_root) { + continue; + } + let Some(name) = worktree_root.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.is_empty() { + continue; + } + worktrees.push(GitLinkedWorktree { + name: name.into(), + worktree_root, + }); + } + } + } + Ok(worktrees) +} + +struct RepoContext<'a> { + repo: Arc, + repo_path: PathBuf, + main_workspace_root: PathBuf, + git_executable: PathBuf, + working_copy_factory: &'a dyn WorkingCopyFactory, +} + +async fn resolve_repo_context<'a>( + ui: &mut Ui, + command: &'a CommandHelper, +) -> Result, CommandError> { + let git_settings = GitSettings::from_settings(command.settings())?; + let git_executable = git_settings.executable_path.clone(); + + if let Ok(workspace) = command.load_workspace() { + let repo = workspace.repo_loader().load_at_head().await?; + let git_backend = git::get_git_backend(repo.store())?; + let main_workspace_root = git_backend + .git_workdir() + .ok_or_else(|| user_error("Cannot adopt: bare Git repository."))? + .to_owned(); + let repo_path = workspace.repo_path().to_owned(); + let working_copy_factory = command.get_working_copy_factory()?; + return Ok(RepoContext { + repo, + repo_path, + main_workspace_root, + git_executable, + working_copy_factory, + }); + } + + let Some(git_paths) = discover_git_worktree_paths(&git_settings, command.cwd())? else { + return Err(user_error("Not inside a jj workspace or Git worktree.")); + }; + let main_workspace_root = match git_paths.common_git_dir.parent() { + Some(path) if path.join(".jj").is_dir() => path, + _ => { + return Err(user_error( + "The Git worktree's main repository is not a colocated jj repo.", + )); + } + }; + let (main_settings, _) = command.settings_for_new_workspace(ui, main_workspace_root)?; + let main_workspace = command.load_workspace_at(main_workspace_root, &main_settings)?; + let working_copy_factory = command.get_working_copy_factory_at(main_workspace_root)?; + let repo = main_workspace.repo_loader().load_at_head().await?; + let repo_path = main_workspace.repo_path().to_owned(); + Ok(RepoContext { + repo, + repo_path, + main_workspace_root: main_workspace_root.to_owned(), + git_executable, + working_copy_factory, + }) +} + +#[instrument(skip_all)] +async fn cmd_git_worktree_adopt( + ui: &mut Ui, + command: &CommandHelper, + args: &GitWorktreeAdoptArgs, +) -> Result<(), CommandError> { + let ctx = resolve_repo_context(ui, command).await?; + + if args.names.is_empty() && !args.all { + return cmd_git_worktree_adopt_cwd(ui, &ctx).await; + } + + let linked_worktrees = + list_git_linked_worktrees(&ctx.git_executable, &ctx.main_workspace_root)?; + + let to_adopt: Vec<&GitLinkedWorktree> = if args.all { + linked_worktrees + .iter() + .filter(|wt| ctx.repo.view().get_wc_commit_id(&wt.name).is_none()) + .collect() + } else { + let mut result = Vec::new(); + for name in &args.names { + let wt = linked_worktrees + .iter() + .find(|wt| wt.name.as_str() == name) + .ok_or_else(|| user_error(format!("Git worktree '{name}' not found.")))?; + if ctx.repo.view().get_wc_commit_id(&wt.name).is_some() { + return Err(user_error(format!("Workspace '{name}' already exists.",))); + } + result.push(wt); + } + result + }; + + if to_adopt.is_empty() { + writeln!(ui.status(), "No unadopted Git worktrees found.")?; + return Ok(()); + } + + let mut repo = ctx.repo.clone(); + for wt in &to_adopt { + let (_workspace, new_repo) = Workspace::init_workspace_with_existing_repo( + &wt.worktree_root, + &ctx.repo_path, + &repo, + ctx.working_copy_factory, + wt.name.clone(), + ) + .await?; + repo = new_repo; + writeln!( + ui.status(), + r#"Created jj workspace for Git worktree at "{}"."#, + wt.worktree_root.display() + )?; + } + Ok(()) +} + +async fn cmd_git_worktree_adopt_cwd( + ui: &mut Ui, + ctx: &RepoContext<'_>, +) -> Result<(), CommandError> { + let cwd = dunce::canonicalize( + std::env::current_dir() + .map_err(|err| user_error_with_message("Failed to get current directory", err))?, + ) + .map_err(|err| user_error_with_message("Failed to resolve current directory", err))?; + let main_root = dunce::canonicalize(&ctx.main_workspace_root) + .map_err(|err| user_error_with_message("Failed to resolve main workspace root", err))?; + if cwd == main_root || cwd.starts_with(&main_root) && !cwd.join(".git").is_file() { + return Err(user_error( + "Not inside a linked Git worktree. Run this from within a Git worktree, or pass \ + worktree names to adopt.", + )); + } + let worktree_root = cwd; + let linked_worktrees = + list_git_linked_worktrees(&ctx.git_executable, &ctx.main_workspace_root)?; + let wt = linked_worktrees + .iter() + .find(|wt| wt.worktree_root == worktree_root) + .ok_or_else(|| { + user_error( + "Not inside a linked Git worktree. Run this from within a Git worktree, or pass \ + worktree names to adopt.", + ) + })?; + if ctx.repo.view().get_wc_commit_id(&wt.name).is_some() { + return Err(user_error(format!( + "Workspace '{}' already exists.", + wt.name.as_str() + ))); + } + let (_workspace, _repo) = Workspace::init_workspace_with_existing_repo( + &wt.worktree_root, + &ctx.repo_path, + &ctx.repo, + ctx.working_copy_factory, + wt.name.clone(), + ) + .await?; + writeln!( + ui.status(), + r#"Created jj workspace for Git worktree at "{}"."#, + wt.worktree_root.display() + )?; + Ok(()) +} diff --git a/cli/src/git_util.rs b/cli/src/git_util.rs index 9870e63fbbb..a775c743bbf 100644 --- a/cli/src/git_util.rs +++ b/cli/src/git_util.rs @@ -20,6 +20,7 @@ use std::io::Write as _; use std::iter; use std::mem; use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::time::Duration; use std::time::Instant; @@ -704,6 +705,76 @@ pub fn remove_git_worktree( Ok(()) } +pub struct GitWorktreePaths { + pub worktree_root: PathBuf, + pub common_git_dir: PathBuf, + pub workspace_name: jj_lib::ref_name::WorkspaceNameBuf, +} + +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 { + return Ok(None); + }; + let Some(git_dir) = git_rev_parse_path(git_settings, cwd, "--git-dir")? else { + return Ok(None); + }; + let Some(common_git_dir) = git_rev_parse_path(git_settings, cwd, "--git-common-dir")? else { + return Ok(None); + }; + if git_dir == common_git_dir { + return Ok(None); + } + let Some(workspace_name) = git_dir.file_name().and_then(|name| name.to_str()) else { + return Ok(None); + }; + if workspace_name.is_empty() { + return Ok(None); + } + Ok(Some(GitWorktreePaths { + worktree_root, + common_git_dir, + workspace_name: workspace_name.into(), + })) +} + +fn git_rev_parse_path( + git_settings: &GitSettings, + cwd: &Path, + arg: &str, +) -> Result, CommandError> { + let Ok(output) = Command::new(&git_settings.executable_path) + .arg("rev-parse") + .arg(arg) + .current_dir(cwd) + .output() + else { + return Ok(None); + }; + if !output.status.success() { + return Ok(None); + } + let Ok(path) = String::from_utf8(output.stdout) else { + return Ok(None); + }; + let path = Path::new(path.trim()); + let path = if path.is_absolute() { + path.to_owned() + } else { + cwd.join(path) + }; + match dunce::canonicalize(&path) { + Ok(path) => Ok(Some(path)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(user_error_with_message( + format!("Failed to resolve git path '{}'", path.display()), + err, + )), + } +} + #[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 3c0849ec356..673a3a6deb5 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -72,6 +72,8 @@ This document contains the help content for the `jj` command-line program. * [`jj git remote rename`↴](#jj-git-remote-rename) * [`jj git remote set-url`↴](#jj-git-remote-set-url) * [`jj git root`↴](#jj-git-root) +* [`jj git worktree`↴](#jj-git-worktree) +* [`jj git worktree adopt`↴](#jj-git-worktree-adopt) * [`jj help`↴](#jj-help) * [`jj interdiff`↴](#jj-interdiff) * [`jj log`↴](#jj-log) @@ -1570,6 +1572,7 @@ See this [comparison], including a [table of commands]. * `push` — Push to a Git remote * `remote` — Manage Git remotes * `root` — Show the underlying Git directory of a repository using the Git backend +* `worktree` — Manage Git worktrees @@ -1964,6 +1967,36 @@ Show the underlying Git directory of a repository using the Git backend +## `jj git worktree` + +Manage Git worktrees + +**Usage:** `jj git worktree ` + +###### **Subcommands:** + +* `adopt` — Adopt existing Git worktrees as jj workspaces + + + +## `jj git worktree adopt` + +Adopt existing Git worktrees as jj workspaces + +With no arguments, adopts the Git worktree at the current directory. With worktree names, adopts those specific worktrees. With `--all`, adopts all unadopted Git worktrees. + +**Usage:** `jj git worktree adopt [OPTIONS] [NAMES]...` + +###### **Arguments:** + +* `` — Names of Git worktrees to adopt + +###### **Options:** + +* `--all` — Adopt all unadopted Git worktrees + + + ## `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 cad4abfad59..1086d4d22a6 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -142,6 +142,232 @@ fn test_workspaces_add_colocated_unborn_git_head() { assert!(!test_env.env_root().join("secondary/.git").exists()); } +#[test] +fn test_git_worktree_adopt() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "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: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!linked_dir.root().join(".jj").exists()); + + let output = linked_dir.run_jj(["git", "worktree", "adopt"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- 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 kkmpptxz 2b17ac71 (empty) (no description set) + [EOF] + "#); +} + +#[test] +fn test_git_worktree_adopt_by_name() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "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: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let output = main_dir.run_jj(["git", "worktree", "adopt", "linked"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- 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 kkmpptxz 2b17ac71 (empty) (no description set) + [EOF] + "#); +} + +#[test] +fn test_git_worktree_adopt_all() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + + main_dir.write_file("file", "contents"); + main_dir.run_jj(["commit", "-m", "initial"]).success(); + for name in ["wt-a", "wt-b"] { + let wt_dir = test_env.work_dir(name); + let output = std::process::Command::new("git") + .args(["worktree", "add", "--detach"]) + .arg(wt_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: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let output = main_dir.run_jj(["git", "worktree", "adopt", "--all"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Created jj workspace for Git worktree at "$TEST_ENV/wt-a". + Created jj workspace for Git worktree at "$TEST_ENV/wt-b". + [EOF] + "#); + + let output = main_dir.run_jj(["workspace", "list"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + default: . rlvkpnrz 504e3d8c (empty) (no description set) + wt-a: ../wt-a kkmpptxz 2b17ac71 (empty) (no description set) + wt-b: ../wt-b pmmvwywv 337ba39f (empty) (no description set) + [EOF] + "#); +} + +#[test] +fn test_git_worktree_adopt_all_skips_existing() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + + main_dir.write_file("file", "contents"); + main_dir.run_jj(["commit", "-m", "initial"]).success(); + for name in ["wt-a", "wt-b"] { + let wt_dir = test_env.work_dir(name); + let output = std::process::Command::new("git") + .args(["worktree", "add", "--detach"]) + .arg(wt_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: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + main_dir + .run_jj(["git", "worktree", "adopt", "wt-a"]) + .success(); + let output = main_dir.run_jj(["git", "worktree", "adopt", "--all"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Created jj workspace for Git worktree at "$TEST_ENV/wt-b". + [EOF] + "#); +} + +#[test] +fn test_git_worktree_adopt_not_found() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + + let output = main_dir.run_jj(["git", "worktree", "adopt", "nonexistent"]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Error: Git worktree 'nonexistent' not found. + [EOF] + [exit status: 1] + "); +} + +#[test] +fn test_git_worktree_adopt_already_exists() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "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()); + + main_dir + .run_jj(["git", "worktree", "adopt", "linked"]) + .success(); + let output = main_dir.run_jj(["git", "worktree", "adopt", "linked"]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Error: Workspace 'linked' already exists. + [EOF] + [exit status: 1] + "); +} + +#[test] +fn test_git_worktree_adopt_all_none_found() { + let test_env = TestEnvironment::default(); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + + let output = main_dir.run_jj(["git", "worktree", "adopt", "--all"]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No unadopted Git worktrees found. + [EOF] + "); +} + #[test] fn test_workspaces_add_with_message() { let test_env = TestEnvironment::default();