workspace: Add worktree support to MaybeColocatedGitRepo
Some checks failed
build / build (, macos-13) (push) Has been cancelled
build / build (, macos-14) (push) Has been cancelled
build / build (, ubuntu-latest) (push) Has been cancelled
build / build (, windows-latest) (push) Has been cancelled
build / build (--all-features, ubuntu-latest) (push) Has been cancelled
build / Build jj-lib without Git support (push) Has been cancelled
build / Check protos (push) Has been cancelled
build / Check formatting (push) Has been cancelled
build / Check that MkDocs can build the docs (push) Has been cancelled
build / Check that MkDocs can build the docs with Poetry 1.8 (push) Has been cancelled
build / cargo-deny (advisories) (push) Has been cancelled
build / cargo-deny (bans licenses sources) (push) Has been cancelled
build / Clippy check (push) Has been cancelled

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.
This commit is contained in:
Cormac Relf 2024-11-10 21:25:02 +11:00
parent 3e4b0c192a
commit 2923211ba1
3 changed files with 611 additions and 30 deletions

View file

@ -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
}
}

View file

@ -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();

View file

@ -1863,46 +1863,45 @@ impl MaybeColocatedGitRepo {
Self { colocated, ..self }
}
/// Try to open `<workspace_root>/.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,
}
}
}