Skip to content

Commit

Permalink
workspace: Add worktree support to MaybeColocatedGitRepo
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cormacrelf committed Nov 13, 2024
1 parent 3e4b0c1 commit 2923211
Show file tree
Hide file tree
Showing 3 changed files with 610 additions and 29 deletions.
351 changes: 351 additions & 0 deletions cli/tests/test_git_colocated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "[email protected]");
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
}
}
Loading

0 comments on commit 2923211

Please sign in to comment.