diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c8a47efe..4b74e2c3253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). jj workspace can have its own Git HEAD. Existing repositories are migrated automatically. +* `jj workspace add` supports `--colocate`/`--no-colocate` flags to control + whether a Git worktree is created alongside the workspace. The default + colocates when the current workspace is colocated and the `git.colocate` + config is `true`. `jj workspace forget` removes the corresponding Git + worktree when one exists. + ### Fixed bugs * The default pager flags now include `-K` (`--quit-on-intr`), so pressing diff --git a/cli/src/commands/workspace/add.rs b/cli/src/commands/workspace/add.rs index bbd0205086b..24231428665 100644 --- a/cli/src/commands/workspace/add.rs +++ b/cli/src/commands/workspace/add.rs @@ -19,6 +19,8 @@ use itertools::Itertools as _; use jj_lib::commit::CommitIteratorExt as _; use jj_lib::file_util; use jj_lib::file_util::IoResultExt as _; +#[cfg(feature = "git")] +use jj_lib::git::GitSettings; use jj_lib::ref_name::WorkspaceNameBuf; use jj_lib::repo::Repo as _; use jj_lib::rewrite::merge_commit_trees; @@ -32,6 +34,8 @@ use crate::command_error::internal_error_with_message; use crate::command_error::user_error; use crate::description_util::add_trailers; use crate::description_util::join_message_paragraphs; +#[cfg(feature = "git")] +use crate::git_util::create_git_worktree; use crate::ui::Ui; /// How to handle sparse patterns when creating a new workspace. @@ -81,6 +85,20 @@ pub struct WorkspaceAddArgs { #[arg(long = "message", short, value_name = "MESSAGE")] message_paragraphs: Vec, + /// Create a corresponding Git worktree for this workspace + /// + /// By default, a Git worktree is created when the current workspace is + /// colocated and the [git.colocate config] is `true`. + /// + /// [git.colocate config]: + /// https://docs.jj-vcs.dev/latest/config/#default-colocation + #[arg(long, conflicts_with = "no_colocate")] + colocate: bool, + + /// Do not create a Git worktree for this workspace + #[arg(long, conflicts_with = "colocate")] + no_colocate: bool, + /// How to handle sparse patterns when creating a new workspace. #[arg(long, value_enum, default_value_t = SparseInheritance::Copy)] sparse_patterns: SparseInheritance, @@ -114,12 +132,54 @@ pub async fn cmd_workspace_add( name = workspace_name.as_symbol() ))); } - if !destination_path.exists() { - fs::create_dir(&destination_path).context(&destination_path)?; - } else if !file_util::is_empty_dir(&destination_path)? { - return Err(user_error( - "Destination path exists and is not an empty directory", - )); + #[cfg(feature = "git")] + let created_git_worktree = { + if (args.colocate || args.no_colocate) + && !jj_lib::git::get_git_backend(repo.store()).is_ok() + { + return Err(user_error( + "--colocate/--no-colocate requires a Git backend", + )); + } + let should_colocate = if args.colocate { + true + } else if args.no_colocate { + false + } else { + old_workspace_command.working_copy_shared_with_git() + && old_workspace_command.settings().get_bool("git.colocate")? + }; + if should_colocate { + let git_settings = GitSettings::from_settings(old_workspace_command.settings())?; + let git_head = repo.view().git_head(old_workspace_command.workspace_name()); + if git_head.is_absent() { + return Err(user_error( + "Cannot create colocated Git worktree because Git HEAD does not point to a \ + commit yet. Create a commit first, then retry.", + )); + } + create_git_worktree( + ui, + &git_settings, + old_workspace_command.workspace_root(), + &destination_path, + )?; + true + } else { + false + } + }; + #[cfg(not(feature = "git"))] + let created_git_worktree = false; + + if !created_git_worktree { + if !destination_path.exists() { + fs::create_dir(&destination_path).context(&destination_path)?; + } else if !file_util::is_empty_dir(&destination_path)? { + return Err(user_error( + "Destination path exists and is not an empty directory", + )); + } } let working_copy_factory = command.get_working_copy_factory()?; diff --git a/cli/src/commands/workspace/forget.rs b/cli/src/commands/workspace/forget.rs index 3e19183d1ec..67905466bea 100644 --- a/cli/src/commands/workspace/forget.rs +++ b/cli/src/commands/workspace/forget.rs @@ -14,6 +14,8 @@ use clap_complete::ArgValueCandidates; use itertools::Itertools as _; +#[cfg(feature = "git")] +use jj_lib::git::GitSettings; use jj_lib::ref_name::WorkspaceNameBuf; use jj_lib::workspace_store::SimpleWorkspaceStore; use jj_lib::workspace_store::WorkspaceStore as _; @@ -22,6 +24,8 @@ use tracing::instrument; use crate::cli_util::CommandHelper; use crate::command_error::CommandError; use crate::complete; +#[cfg(feature = "git")] +use crate::git_util::remove_git_worktree; use crate::ui::Ui; /// Stop tracking a workspace's working-copy commit in the repo @@ -74,6 +78,18 @@ pub async fn cmd_workspace_forget( let workspace_store = SimpleWorkspaceStore::load(workspace_command.repo_path())?; + #[cfg(feature = "git")] + let workspace_paths = { + let repo_path = workspace_command.repo_path(); + forget_ws + .iter() + .filter_map(|ws| { + let rel_path = workspace_store.get_workspace_path(ws).ok().flatten()?; + dunce::canonicalize(repo_path.join(rel_path)).ok() + }) + .collect_vec() + }; + // bundle every workspace forget into a single transaction, so that e.g. // undo correctly restores all of them at once. let mut tx = workspace_command.start_transaction(); @@ -94,5 +110,14 @@ pub async fn cmd_workspace_forget( }; tx.finish(ui, description).await?; + + #[cfg(feature = "git")] + { + let git_settings = GitSettings::from_settings(workspace_command.settings())?; + for path in &workspace_paths { + remove_git_worktree(ui, &git_settings, workspace_command.workspace_root(), path)?; + } + } + Ok(()) } diff --git a/cli/src/git_util.rs b/cli/src/git_util.rs index 53e369bd27b..cf043e31084 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::process::Command; use std::time::Duration; use std::time::Instant; @@ -28,6 +29,8 @@ use crossterm::terminal::Clear; use crossterm::terminal::ClearType; use indoc::writedoc; use itertools::Itertools as _; +use jj_lib::file_util; +use jj_lib::file_util::IoResultExt as _; use jj_lib::git; use jj_lib::git::FailedRefExportReason; use jj_lib::git::GitExportStats; @@ -53,6 +56,7 @@ use crate::cli_util::print_updated_commits; use crate::command_error::CommandError; use crate::command_error::cli_error; use crate::command_error::user_error; +use crate::command_error::user_error_with_message; use crate::formatter::Formatter; use crate::formatter::FormatterExt as _; use crate::revset_util::parse_remote_auto_track_bookmarks_map; @@ -565,6 +569,135 @@ pub fn print_push_stats(ui: &Ui, stats: &GitPushStats) -> io::Result<()> { Ok(()) } +pub fn create_git_worktree( + ui: &Ui, + git_settings: &GitSettings, + main_workspace_root: &Path, + destination: &Path, +) -> Result<(), CommandError> { + // Use relative paths to match jj's convention for portable repositories. + // Silently ignored by git versions that don't support it. + let relative_paths_config = ["-c", "worktree.useRelativePaths=true"]; + + let dest_exists = destination.exists() && !file_util::is_empty_dir(destination)?; + if dest_exists { + // `git worktree add` refuses to create a worktree in a non-empty + // directory. Work around this by creating in a temporary sibling + // directory, moving the .git gitlink, and repairing the paths. + let tmp = + tempfile::TempDir::new_in(destination.parent().unwrap_or(std::path::Path::new("."))) + .map_err(|err| { + user_error_with_message( + "Failed to create temporary directory for Git worktree", + err, + ) + })?; + let tmp_path = tmp.keep(); + + run_git_worktree_add( + git_settings, + &relative_paths_config, + main_workspace_root, + &tmp_path, + )?; + + std::fs::rename(tmp_path.join(".git"), destination.join(".git")).context(destination)?; + std::fs::remove_dir_all(&tmp_path).ok(); + + let output = Command::new(&git_settings.executable_path) + .args(relative_paths_config) + .args(["worktree", "repair", "--"]) + .arg(destination) + .current_dir(main_workspace_root) + .output() + .map_err(|err| user_error_with_message("Failed to run `git worktree repair`", err))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(user_error(format!( + "Failed to repair Git worktree paths: {stderr}" + ))); + } + } else { + run_git_worktree_add( + git_settings, + &relative_paths_config, + main_workspace_root, + destination, + )?; + } + + writeln!(ui.status(), "Created Git worktree for the new workspace.")?; + Ok(()) +} + +fn run_git_worktree_add( + git_settings: &GitSettings, + extra_config: &[&str], + main_workspace_root: &Path, + destination: &Path, +) -> Result<(), CommandError> { + let output = Command::new(&git_settings.executable_path) + .args(extra_config) + .args(["worktree", "add", "--detach", "--no-checkout", "--"]) + .arg(destination) + .arg("HEAD") + .current_dir(main_workspace_root) + .output() + .map_err(|err| user_error_with_message("Failed to run `git worktree add`", err))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(user_error(format!( + "Failed to create Git worktree: {stderr}" + ))); + } + Ok(()) +} + +pub fn remove_git_worktree( + ui: &Ui, + git_settings: &GitSettings, + main_workspace_root: &Path, + worktree_path: &Path, +) -> Result<(), CommandError> { + let dot_git = worktree_path.join(".git"); + if !dot_git.is_file() { + return Ok(()); + } + // Remove the .git gitlink file, then prune the worktree metadata. + // We don't use `git worktree remove` because it deletes the directory + // contents, and jj workspace forget should preserve workspace files. + std::fs::remove_file(&dot_git).ok(); + + let output = Command::new(&git_settings.executable_path) + .args(["worktree", "prune"]) + .current_dir(main_workspace_root) + .output(); + match output { + Ok(o) if o.status.success() => { + writeln!( + ui.status(), + r#"Removed Git worktree for "{}"."#, + worktree_path.display() + )?; + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + writeln!( + ui.warning_default(), + r#"Failed to prune Git worktree for "{}": {stderr}"#, + worktree_path.display() + )?; + } + Err(err) => { + writeln!( + ui.warning_default(), + "Failed to run `git worktree prune`: {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 d03d1c88b3b..3c0849ec356 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -3800,6 +3800,12 @@ By default, the new workspace inherits the sparse patterns of the current worksp If any revisions are specified, the new workspace will be created, and the new working-copy commit will be created with all these revisions as parents, i.e. the working-copy commit will exist as if you had run `jj new r1 r2 r3 ...`. * `-m`, `--message ` — The change description to use +* `--colocate` — Create a corresponding Git worktree for this workspace + + By default, a Git worktree is created when the current workspace is colocated and the [git.colocate config] is `true`. + + [git.colocate config]: https://docs.jj-vcs.dev/latest/config/#default-colocation +* `--no-colocate` — Do not create a Git worktree for this workspace * `--sparse-patterns ` — How to handle sparse patterns when creating a new workspace Default value: `copy` diff --git a/cli/tests/test_workspaces.rs b/cli/tests/test_workspaces.rs index be7256dea40..39505e7580c 100644 --- a/cli/tests/test_workspaces.rs +++ b/cli/tests/test_workspaces.rs @@ -123,6 +123,25 @@ fn test_workspaces_add_second_and_third_workspace() { assert!(!test_env.env_root().join("tertiary").exists()); } +#[test] +fn test_workspaces_add_colocated_unborn_git_head() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.colocate = true"); + test_env + .run_jj_in(".", ["git", "init", "--colocate", "main"]) + .success(); + let main_dir = test_env.work_dir("main"); + + let output = main_dir.run_jj(["workspace", "add", "../secondary"]); + insta::assert_snapshot!(output, @r#" + ------- stderr ------- + Error: Cannot create colocated Git worktree because Git HEAD does not point to a commit yet. Create a commit first, then retry. + [EOF] + [exit status: 1] + "#); + assert!(!test_env.env_root().join("secondary/.git").exists()); +} + #[test] fn test_workspaces_add_with_message() { let test_env = TestEnvironment::default(); @@ -2101,6 +2120,118 @@ fn test_workspaces_rename_workspace_from_before_workspace_store() { "); } +#[test] +fn test_workspaces_add_colocated_no_colocate_flag() { + 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(); + + let output = main_dir.run_jj(["workspace", "add", "--no-colocate", "../secondary"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Created workspace in "../secondary" + Working copy (@) now at: pmmvwywv 058f604d (empty) (no description set) + Parent commit (@-) : qpvuntsm 7b22a8cb initial + Added 1 files, modified 0 files, removed 0 files + [EOF] + "#); + assert!(!test_env.env_root().join("secondary/.git").exists()); +} + +#[test] +fn test_workspaces_add_colocated_default_creates_worktree() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.colocate = true"); + 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(); + + let output = main_dir.run_jj(["workspace", "add", "../secondary"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Created Git worktree for the new workspace. + Created workspace in "../secondary" + Working copy (@) now at: pmmvwywv 058f604d (empty) (no description set) + Parent commit (@-) : qpvuntsm 7b22a8cb initial + Added 1 files, modified 0 files, removed 0 files + [EOF] + "#); + assert!(test_env.env_root().join("secondary/.git").is_file()); +} + +#[test] +fn test_workspaces_forget_colocated_removes_worktree() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.colocate = true"); + 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(); + main_dir + .run_jj(["workspace", "add", "../secondary"]) + .success(); + + assert!(test_env.env_root().join("secondary/.git").is_file()); + + let output = main_dir.run_jj(["workspace", "forget", "secondary"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Removed Git worktree for "$TEST_ENV/secondary". + [EOF] + "#); + assert!(!test_env.env_root().join("secondary/.git").exists()); + assert!(test_env.env_root().join("secondary").is_dir()); + assert!(test_env.env_root().join("secondary/file").is_file()); + + let git_output = std::process::Command::new("git") + .args(["worktree", "list"]) + .current_dir(test_env.env_root().join("main")) + .output() + .unwrap(); + let worktree_list = String::from_utf8_lossy(&git_output.stdout); + assert!( + !worktree_list.contains("secondary"), + "git should no longer list the secondary worktree, got: {worktree_list}" + ); +} + +#[test] +fn test_workspaces_add_colocate_config_false_with_flag_creates_worktree() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.colocate = false"); + 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(); + + let output = main_dir.run_jj(["workspace", "add", "--colocate", "../secondary"]); + insta::assert_snapshot!(output.normalize_backslash(), @r#" + ------- stderr ------- + Created Git worktree for the new workspace. + Created workspace in "../secondary" + Working copy (@) now at: pmmvwywv 058f604d (empty) (no description set) + Parent commit (@-) : qpvuntsm 7b22a8cb initial + Added 1 files, modified 0 files, removed 0 files + [EOF] + "#); + assert!(test_env.env_root().join("secondary/.git").is_file()); +} + #[must_use] fn get_log_output(work_dir: &TestWorkDir) -> CommandOutput { let template = r#"