From 2923211ba186a5d07264cc4aea69feff16d437f6 Mon Sep 17 00:00:00 2001 From: Cormac Relf Date: Sun, 10 Nov 2024 21:25:02 +1100 Subject: [PATCH] workspace: Add worktree support to MaybeColocatedGitRepo This adds logic to the colocation-detection code to support finding a worktree with a "gitfile" as well as a full-on .git directory. Done by just trusting gix to open the workspace root/.git. It handles symlinks like the old code did, and now also handles worktrees. This means GitBackend is now opened at the worktree repo, which in turn means that colocated workspaces are somewhat independent. You can move @ in a workspace and JJ will write HEAD = @- to the git worktree in that workspace. And you can run mutating git commands in a workspace, and JJ will import the new HEAD only in that workspace. There are some new tests for what happens when you `jj git init --git-repo=...` either in or pointing at an existing worktree. I do not expect these to be common workflows, but there is new behaviour here that we need to track. There are also FIXMEs in the tests for places where we need to store one HEAD per colocated workspace in the view, as well as having independent import/export. These view changes are unwieldy and will come later. --- cli/tests/test_git_colocated.rs | 351 ++++++++++++++++++++++++++++++++ cli/tests/test_git_init.rs | 231 +++++++++++++++++++++ lib/src/git.rs | 59 +++--- 3 files changed, 611 insertions(+), 30 deletions(-) diff --git a/cli/tests/test_git_colocated.rs b/cli/tests/test_git_colocated.rs index e3b607c49..f0629dac0 100644 --- a/cli/tests/test_git_colocated.rs +++ b/cli/tests/test_git_colocated.rs @@ -975,3 +975,354 @@ fn stopgap_workspace_colocate( .assert() .success(); } + +#[test] +fn test_colocated_workspace_in_bare_repo() { + // TODO: Remove when this stops requiring git (stopgap_workspace_colocate) + if Command::new("git").arg("--version").status().is_err() { + eprintln!("Skipping because git command might fail to run"); + return; + } + + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + let second_path = test_env.env_root().join("second"); + // + // git init without --colocate creates a bare repo + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + std::fs::write(repo_path.join("file"), "contents").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "initial commit"]); + let (initial_commit, _) = test_env.jj_cmd_ok( + &repo_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &repo_path, false, "../second", &initial_commit); + + insta::assert_snapshot!(get_log_output(&test_env, &second_path), @r#" + @ baf7f13355a30ddd3aa6476317fcbc9c65239b0c second@ + │ ○ 45c9d8477181a2b9c077ff1b724694fe0969b301 default@ + ├─╯ + ○ 046d74c8ab0a4730e58488508a5398b7a91e54a2 git_head() initial commit + ◆ 0000000000000000000000000000000000000000 + "#); + + test_env.jj_cmd_ok( + &second_path, + &["commit", "-m", "commit in second workspace"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &second_path), @r#" + @ fca81879c29229d0097cb7d32fc8a661ee80c6e4 second@ + ○ 220827d1ceb632ec7dd4cb2f5110b496977d14c2 git_head() commit in second workspace + │ ○ 45c9d8477181a2b9c077ff1b724694fe0969b301 default@ + ├─╯ + ○ 046d74c8ab0a4730e58488508a5398b7a91e54a2 initial commit + ◆ 0000000000000000000000000000000000000000 + "#); + + // FIXME: There should still be no git HEAD in the default workspace, which + // is not colocated. However, git_head() is a property of the view. And + // currently, all colocated workspaces read and write from the same + // entry of the common view. + // + // let stdout = test_env.jj_cmd_success(&repo_path, &["log", "--no-graph", + // "-r", "git_head()"]); insta::assert_snapshot!(stdout, @r#""#); + + let stdout = test_env.jj_cmd_success( + &second_path, + &["op", "log", "-Tself.description().first_line()"], + ); + insta::assert_snapshot!(stdout, @r#" + @ commit baf7f13355a30ddd3aa6476317fcbc9c65239b0c + ○ import git head + ○ create initial working-copy commit in workspace second + ○ add workspace 'second' + ○ commit 4e8f9d2be039994f589b4e57ac5e9488703e604d + ○ snapshot working copy + ○ add workspace 'default' + ○ + "#); +} + +#[test] +fn test_colocated_workspace_moved_original_on_disk() { + if Command::new("git").arg("--version").status().is_err() { + eprintln!("Skipping because git command might fail to run"); + return; + } + + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + let second_path = test_env.env_root().join("second"); + let new_repo_path = test_env.env_root().join("repo-moved"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "--colocate", "repo"]); + std::fs::write(repo_path.join("file"), "contents").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "initial commit"]); + let (initial_commit, _) = test_env.jj_cmd_ok( + &repo_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &repo_path, true, "../second", &initial_commit); + + // Break our worktree by moving the original repo on disk + std::fs::rename(&repo_path, &new_repo_path).unwrap(); + // imagine JJ were able to do this + std::fs::write( + second_path.join(".jj/repo"), + new_repo_path + .join(".jj/repo") + .as_os_str() + .as_encoded_bytes(), + ) + .unwrap(); + + let (_, stderr) = test_env.jj_cmd_ok(&second_path, &["status"]); + // hack for windows paths + let gitfile_contents = std::fs::read_to_string(second_path.join(".git")) + .unwrap() + .strip_prefix("gitdir: ") + .unwrap() + .trim() + .to_owned(); + let stderr = stderr.replace(&gitfile_contents, "$TEST_ENV/repo/.git/worktrees/second"); + insta::assert_snapshot!(stderr, @r" + Warning: Workspace is a broken Git worktree + The .git file points at: $TEST_ENV/repo/.git/worktrees/second + Hint: If this is meant to be a colocated JJ workspace, you may like to try `git -C $TEST_ENV/repo-moved worktree repair` + "); + + Command::new("git") + .args(["worktree", "repair"]) + .current_dir(&new_repo_path) + .assert() + .success(); + insta::assert_snapshot!(get_log_output(&test_env, &second_path), @r#" + @ 05530a3e0f9d581260343e273d66c381e76957df second@ + │ ○ 45c9d8477181a2b9c077ff1b724694fe0969b301 default@ + ├─╯ + ○ 046d74c8ab0a4730e58488508a5398b7a91e54a2 git_head() initial commit + ◆ 0000000000000000000000000000000000000000 + "#); +} + +#[test] +fn test_colocated_workspace_wrong_gitdir() { + // TODO: Remove when this stops requiring git (stopgap_workspace_colocate) + if Command::new("git").arg("--version").status().is_err() { + eprintln!("Skipping because git command might fail to run"); + return; + } + + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + let second_path = test_env.env_root().join("second"); + let other_path = test_env.env_root().join("other"); + let other_second_path = test_env.env_root().join("other_second"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "--colocate", "repo"]); + std::fs::write(repo_path.join("file"), "contents").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "initial commit"]); + let (initial_commit, _) = test_env.jj_cmd_ok( + &repo_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &repo_path, true, "../second", &initial_commit); + + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "--colocate", "other"]); + std::fs::write(other_path.join("file"), "contents2").unwrap(); + test_env.jj_cmd_ok(&other_path, &["commit", "-m", "initial commit"]); + let (ic_other, _) = test_env.jj_cmd_ok( + &other_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &other_path, true, "../other_second", &ic_other); + + // Break one of our worktrees + std::fs::copy(other_second_path.join(".git"), second_path.join(".git")).unwrap(); + + let (_, stderr) = test_env.jj_cmd_ok(&second_path, &["status"]); + insta::assert_snapshot!(stderr, @"Warning: Workspace is also a Git worktree that is not managed by JJ"); +} + +#[test] +fn test_colocated_workspace_invalid_gitdir() { + // TODO: Remove when this stops requiring git (stopgap_workspace_colocate) + if Command::new("git").arg("--version").status().is_err() { + eprintln!("Skipping because git command might fail to run"); + return; + } + + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + let second_path = test_env.env_root().join("second"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "--colocate", "repo"]); + std::fs::write(repo_path.join("file"), "contents").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "initial commit"]); + let (initial_commit, _) = test_env.jj_cmd_ok( + &repo_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &repo_path, true, "../second", &initial_commit); + + // Break one of our worktrees + std::fs::write(second_path.join(".git"), "invalid").unwrap(); + + let (_, stderr) = test_env.jj_cmd_ok(&second_path, &["status"]); + insta::assert_snapshot!(stderr, @r" + Warning: Workspace is a broken Git worktree + The .git file points at: invalid + Hint: If this is meant to be a colocated JJ workspace, you may like to try `git -C $TEST_ENV/repo worktree repair` + "); +} + +#[test] +fn test_colocated_workspace_independent_heads() { + // TODO: Remove when this stops requiring git (stopgap_workspace_colocate) + if Command::new("git").arg("--version").status().is_err() { + eprintln!("Skipping because git command might fail to run"); + return; + } + + let test_env = TestEnvironment::default(); + let repo_path = test_env.env_root().join("repo"); + let second_path = test_env.env_root().join("second"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "--colocate", "repo"]); + // create a commit so that git can have a HEAD + std::fs::write(repo_path.join("file"), "contents").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "initial commit"]); + let (initial_commit, _) = test_env.jj_cmd_ok( + &repo_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + // TODO: replace with workspace add, when it can create worktrees + stopgap_workspace_colocate(&test_env, &repo_path, true, "../second", &initial_commit); + + { + let first_git = git2::Repository::open(&repo_path).unwrap(); + assert!(first_git.head_detached().unwrap()); + let first_head = first_git.head().unwrap(); + + let commit = first_head.peel_to_commit().unwrap().id(); + assert_eq!(commit.to_string(), initial_commit); + + let second_git = git2::Repository::open(&second_path).unwrap(); + assert!(second_git.head_detached().unwrap()); + let second_head = second_git.head().unwrap(); + + let commit = second_head.peel_to_commit().unwrap().id(); + assert_eq!(commit.to_string(), initial_commit); + } + + // now commit again in the second worktree, and make sure the original + // repo's head does not move. + // + // This tests that we are writing HEAD to the corresponding worktree, + // rather than unconditionally to the default workspace. + std::fs::write(repo_path.join("file2"), "contents").unwrap(); + test_env.jj_cmd_ok(&second_path, &["commit", "-m", "followup commit"]); + let (followup_commit, _) = test_env.jj_cmd_ok( + &second_path, + &["log", "--no-graph", "-T", "commit_id", "-r", "@-"], + ); + + { + // git HEAD should not move in the default workspace + let first_git = git2::Repository::open(&repo_path).unwrap(); + assert!(first_git.head_detached().unwrap()); + let first_head = first_git.head().unwrap(); + // still initial + assert_eq!( + first_head.peel_to_commit().unwrap().id().to_string(), + initial_commit, + "default workspace's git HEAD should not have moved from {initial_commit}" + ); + + let second_git = git2::Repository::open(&second_path).unwrap(); + assert!(second_git.head_detached().unwrap()); + let second_head = second_git.head().unwrap(); + assert_eq!( + second_head.peel_to_commit().unwrap().id().to_string(), + followup_commit, + "second workspace's git HEAD should have advanced to {followup_commit}" + ); + } + + // Finally, test imports. Test that a commit written to HEAD in one workspace + // does not get imported by the other workspace. + + // Write in default, expect second not to import it + let new_commit = test_independent_import(&test_env, &repo_path, &second_path, &followup_commit); + // Write in second, expect default not to import it + test_independent_import(&test_env, &second_path, &repo_path, &new_commit); + + fn test_independent_import( + test_env: &TestEnvironment, + commit_in: &Path, + no_import_in_workspace: &Path, + workspace_at: &str, + ) -> String { + // Commit in one workspace + let mut repo = gix::open(commit_in).unwrap(); + { + use gix::config::tree::*; + let mut config = repo.config_snapshot_mut(); + let (name, email) = ("JJ test", "jj@example.com"); + config.set_value(&Author::NAME, name).unwrap(); + config.set_value(&Author::EMAIL, email).unwrap(); + config.set_value(&Committer::NAME, name).unwrap(); + config.set_value(&Committer::EMAIL, email).unwrap(); + } + let tree = repo.head_tree_id().unwrap(); + let current = repo.head_commit().unwrap().id; + let new_commit = repo + .commit( + "HEAD", + format!("empty commit in {}", commit_in.display()), + tree, + [current], + ) + .unwrap() + .to_string(); + + let (check_git_head, stderr) = test_env.jj_cmd_ok( + no_import_in_workspace, + &["log", "--no-graph", "-r", "git_head()", "-T", "commit_id"], + ); + // Asserting stderr is empty => no import occurred + assert_eq!( + stderr, + "", + "Should not have imported HEAD in workspace {}", + no_import_in_workspace.display() + ); + // And the commit_id should be pointing to what it was before + assert_eq!( + check_git_head, + workspace_at, + "should still be at {workspace_at} in workspace {}", + no_import_in_workspace.display() + ); + + // Now we import the new HEAD in the commit_in workspace, so it's up to date. + let (check_git_head, stderr) = test_env.jj_cmd_ok( + commit_in, + &["log", "--no-graph", "-r", "git_head()", "-T", "commit_id"], + ); + assert_eq!( + stderr, + "Reset the working copy parent to the new Git HEAD.\n", + "should have imported HEAD in workspace {}", + commit_in.display() + ); + assert_eq!( + check_git_head, + new_commit, + "should have advanced to {new_commit} in workspace {}", + commit_in.display() + ); + new_commit + } +} diff --git a/cli/tests/test_git_init.rs b/cli/tests/test_git_init.rs index f9e0da59a..8fd5decf6 100644 --- a/cli/tests/test_git_init.rs +++ b/cli/tests/test_git_init.rs @@ -731,6 +731,237 @@ fn test_git_init_external_but_git_dir_exists() { "###); } +fn create_commit(git_repo: &git2::Repository, msg: &str, parents: &[&git2::Commit]) -> git2::Oid { + let author = git2::Signature::new("JJ test", "jj@example.com", &git2::Time::new(0, 0)).unwrap(); + let empty_tree = git_repo.treebuilder(None).unwrap().write().unwrap(); + let oid = git_repo + .commit( + Some("HEAD"), + &author, + &author, + msg, + &git_repo.find_tree(empty_tree).unwrap(), + parents, + ) + .unwrap(); + oid +} + +#[test] +fn test_git_init_external_pointing_at_worktree_from_outside() { + let test_env = TestEnvironment::default(); + let git_repo_path = test_env.env_root().join("git-repo"); + let worktree_path = test_env.env_root().join("worktree"); + let workspace_root = test_env.env_root().join("repo"); + let git_repo = git2::Repository::init(&git_repo_path).unwrap(); + // Must create a commit so we can create a worktree + let initial_commit = create_commit(&git_repo, "initial commit", &[]); + let _worktree = git_repo + .worktree("jj-worktree", &worktree_path, None) + .unwrap(); + + // now commit in the worktree, so we know where we are importing from + let worktree_repo = git2::Repository::open(&worktree_path).unwrap(); + let _initial_commit = create_commit( + &worktree_repo, + "second commit", + &[&worktree_repo.find_commit(initial_commit).unwrap()], + ) + .to_string(); + + std::fs::create_dir(&workspace_root).unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok( + &workspace_root, + &["git", "init", "--git-repo", worktree_path.to_str().unwrap()], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Working copy now at: sqpuoqvx 58a55009 (empty) (no description set) + Parent commit : kyuukqus 49c63010 jj-worktree | (empty) second commit + Initialized repo in "." + "#); + + assert_eq!( + PathBuf::from(read_git_target(&workspace_root)) + .canonicalize() + .unwrap(), + worktree_path.join(".git") + ); + + // This is similar to a normal `jj git init --git-repo=` -- we import the + // commits, but in this case our HEAD@git comes from the worktree. + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ 58a55009f1c1 + ○ 49c630103a76 jj-worktree git_head() second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); + + // The git HEAD should not advance, because this is not colocated + test_env.jj_cmd_ok(&workspace_root, &["new"]); + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ f133c02dde73 + ○ 58a55009f1c1 + ○ 49c630103a76 jj-worktree git_head() second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); +} + +#[test] +fn test_git_init_external_in_worktree_pointing_worktree() { + let test_env = TestEnvironment::default(); + let git_repo_path = test_env.env_root().join("git-repo"); + let workspace_root = test_env.env_root().join("repo"); + let git_repo = git2::Repository::init(&git_repo_path).unwrap(); + // Must create a commit so we can create a worktree + let initial_commit = create_commit(&git_repo, "initial commit", &[]); + let _worktree = git_repo + .worktree("jj-worktree", &workspace_root, None) + .unwrap(); + assert!(workspace_root.join(".git").is_file()); + + // now commit in the worktree, so we know where we are importing from + let worktree_repo = git2::Repository::open(&workspace_root).unwrap(); + let _initial_commit = create_commit( + &worktree_repo, + "second commit", + &[&worktree_repo.find_commit(initial_commit).unwrap()], + ) + .to_string(); + + let (stdout, stderr) = test_env.jj_cmd_ok(&workspace_root, &["git", "init", "--git-repo", "."]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Initialized repo in "." + "#); + + assert_eq!(read_git_target(&workspace_root), "../../../.git"); + + // The local ".git" repository is related, so commits should be imported + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ 58a55009f1c1 + ○ 49c630103a76 jj-worktree git_head() second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); + + // Check that Git HEAD is advanced because this is colocated + test_env.jj_cmd_ok(&workspace_root, &["new"]); + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ f133c02dde73 + ○ 58a55009f1c1 git_head() + ○ 49c630103a76 jj-worktree second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); + + let stdout = test_env.jj_cmd_success(&workspace_root, OP_LOG_COMPACT); + insta::assert_snapshot!(stdout, @r#" + @ 0c47efd49cba new empty commit + │ args: jj new + ○ 5fd008dad5b5 import git head + │ args: jj git init --git-repo . + ○ ec635623258d import git refs + │ args: jj git init --git-repo . + ○ eac759b9ab75 add workspace 'default' + ○ 000000000000 + "#); +} + +const OP_LOG_COMPACT: &[&str] = &[ + "op", + "log", + "-Tself.id().short() ++ ' ' ++ separate(\"\\n\", self.description().first_line(), self.tags())", +]; + +/// This one is a bit weird, but technically you can do it. Should be roughly +/// equivalent to the --git-repo=. case, but with a different git_target file. +#[test] +fn test_git_init_external_in_worktree_pointing_commondir() { + let test_env = TestEnvironment::default(); + let git_repo_path = test_env.env_root().join("git-repo"); + let workspace_root = test_env.env_root().join("repo"); + let git_repo = git2::Repository::init(&git_repo_path).unwrap(); + // Must create a commit so we can create a worktree + let initial_commit = create_commit(&git_repo, "initial commit", &[]); + let _worktree = git_repo + .worktree("jj-worktree", &workspace_root, None) + .unwrap(); + assert!(workspace_root.join(".git").is_file()); + + // now commit in the worktree, so we know where we are importing from + let worktree_repo = git2::Repository::open(&workspace_root).unwrap(); + let _initial_commit = create_commit( + &worktree_repo, + "second commit", + &[&worktree_repo.find_commit(initial_commit).unwrap()], + ) + .to_string(); + + let (stdout, stderr) = test_env.jj_cmd_ok( + &workspace_root, + &["git", "init", "--git-repo", "../git-repo"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Initialized repo in "." + "#); + + assert_eq!( + PathBuf::from(read_git_target(&workspace_root)) + .canonicalize() + .unwrap(), + git_repo_path.join(".git") + ); + + // The local ".git" repository is related, so commits should be imported, + // specifically from the worktree, not the original repo. + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ 58a55009f1c1 + ○ 49c630103a76 jj-worktree git_head() second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); + + // Check that Git HEAD is advanced because this is colocated + test_env.jj_cmd_ok(&workspace_root, &["new"]); + let (stdout, stderr) = get_log_output_with_stderr(&test_env, &workspace_root); + insta::assert_snapshot!(stdout, @r#" + @ f133c02dde73 + ○ 58a55009f1c1 git_head() + ○ 49c630103a76 jj-worktree second commit + ○ 70618e4d103f master initial commit + ◆ 000000000000 + "#); + insta::assert_snapshot!(stderr, @""); + + let stdout = test_env.jj_cmd_success(&workspace_root, OP_LOG_COMPACT); + insta::assert_snapshot!(stdout, @r#" + @ 79619167098b new empty commit + │ args: jj new + ○ db043dbad297 import git head + │ args: jj git init --git-repo ../git-repo + ○ 7296ad45aee9 import git refs + │ args: jj git init --git-repo ../git-repo + ○ eac759b9ab75 add workspace 'default' + ○ 000000000000 + "#); +} + #[test] fn test_git_init_colocated_via_flag_git_dir_exists() { let test_env = TestEnvironment::default(); diff --git a/lib/src/git.rs b/lib/src/git.rs index 0492891de..d10090c65 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -1863,46 +1863,45 @@ impl MaybeColocatedGitRepo { Self { colocated, ..self } } + /// Try to open `/.git` as a git repository. + /// + /// If it succeeds, and the commondir matches JJ's backing repo, then the + /// workspace is colocated, and we return the newly opened repository. + /// + /// Easy to sanity check with git -- if `git` works and addresses the same + /// underlying repo (commondir), then the workspace will be colocated. So + /// things like worktrees, symlinks, etc just work. fn try_detect_colocated_workspace( self, workspace_root: &Path, - _open_opts: gix::open::Options, + open_opts: gix::open::Options, ) -> Self { let store_repo = &self.git_repo; - // The git backend may be a bare repository if we created it without --colocate - // but then created a workspace using `jj workspace add --colocate ../second` - let git_backend_workdir = store_repo.work_dir(); - - // 1. Check if we are in a colocated workspace, specifically the one that has - // both .git and .jj/repo. - // -------------------------------------------------------------------------- - - // Fast path -- the paths are the same, without looking through symlinks. - if git_backend_workdir == Some(workspace_root) { - // We are in a colocated workspace that's home to the real .git directory. - // e.g. /repo with /repo/.git - return self.with_colocated(true); - } - - // Otherwise, canonicalize both the git backend workdir and the workspace - let git_backend_workdir_canonical = git_backend_workdir.and_then(|p| p.canonicalize().ok()); - - // Colocated workspace should have ".git" directory, file, or symlink. Compare - // its parent as the git_workdir might be resolved from the real ".git" path. - let workspace_dot_git = workspace_root.join(".git"); - let Ok(workspace_dot_git_canonical) = workspace_dot_git.canonicalize() else { + let Ok(workspace_repo) = + gix::ThreadSafeRepository::open_opts(workspace_root.join(".git"), open_opts) + else { + // If gix can't open it, we are not colocated. return self.with_colocated(false); }; - // i.e. (/symlink_to_repo -> /repo).canonicalize() == (/repo/.git).parent() - if let Some(gbw) = git_backend_workdir_canonical.as_deref() { - if workspace_dot_git_canonical.parent() == Some(gbw) { - // This is the default workspace of a colocated repo - return self.with_colocated(true); - } + // Especially for worktrees, common_dir() returns paths with ../.. in them, + // usually. Must canonicalize. + let Ok(workspace_common_dir) = workspace_repo.to_thread_local().common_dir().canonicalize() + else { + return self.with_colocated(false); + }; + let Ok(store_common_dir) = store_repo.to_thread_local().common_dir().canonicalize() else { + return self.with_colocated(false); + }; + + if workspace_common_dir != store_common_dir { + return self.with_colocated(false); } - self.with_colocated(false) + Self { + git_repo: workspace_repo, + colocated: true, + } } }