diff --git a/CHANGELOG.md b/CHANGELOG.md index 592c9c12aec..17bc7b78930 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 dec8aab9da7..2a2fd3a2681 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 9a34ec79825..7ff1b6be0fc 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();