From 8aebbd1c7dbcb07e7c67b2771159cced9a49a32e Mon Sep 17 00:00:00 2001 From: Yuya Nishihara Date: Tue, 3 Oct 2023 19:45:03 +0900 Subject: [PATCH] git: add hack to unset Git HEAD by using random ref name I considered using the working-copy commit id to generate a unique ref, but the placeholder ref can be "born" by committing changes by git, and the ref name can conflict later by editing the original (hidden) commit. So I think it's better to generate a random name instead. Fixes #1495 --- cli/tests/test_git_colocated.rs | 132 ++++++++++++++++++++++++++++++++ lib/src/git.rs | 18 +++++ 2 files changed, 150 insertions(+) diff --git a/cli/tests/test_git_colocated.rs b/cli/tests/test_git_colocated.rs index 77d6d9a86b..eb2c4e9fa9 100644 --- a/cli/tests/test_git_colocated.rs +++ b/cli/tests/test_git_colocated.rs @@ -95,6 +95,138 @@ fn test_git_colocated() { ); } +#[test] +fn test_git_colocated_unborn_branch() { + let test_env = TestEnvironment::default(); + let workspace_root = test_env.env_root().join("repo"); + let git_repo = git2::Repository::init(&workspace_root).unwrap(); + + let add_file_to_index = |name: &str, data: &str| { + std::fs::write(workspace_root.join(name), data).unwrap(); + let mut index = git_repo.index().unwrap(); + index.add_path(Path::new(name)).unwrap(); + index.write().unwrap(); + }; + let checkout_index = || { + let mut index = git_repo.index().unwrap(); + index.read(true).unwrap(); // discard in-memory cache + git_repo.checkout_index(Some(&mut index), None).unwrap(); + }; + + // Initially, HEAD isn't set. + test_env.jj_cmd_success(&workspace_root, &["init", "--git-repo", "."]); + assert!(git_repo.head().is_err()); + assert_eq!( + git_repo.find_reference("HEAD").unwrap().symbolic_target(), + Some("refs/heads/master") + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_root), @r###" + @ 230dd059e1b059aefc0da06a2e5a7dbf22362f22 + ◉ 0000000000000000000000000000000000000000 + "###); + + // Stage some change, and check out root. This shouldn't clobber the HEAD. + add_file_to_index("file0", ""); + insta::assert_snapshot!( + test_env.jj_cmd_success(&workspace_root, &["checkout", "root()"]), @r###" + Working copy now at: kkmpptxz fcdbbd73 (empty) (no description set) + Parent commit : zzzzzzzz 00000000 (empty) (no description set) + Added 0 files, modified 0 files, removed 1 files + "###); + assert!(git_repo.head().is_err()); + assert_eq!( + git_repo.find_reference("HEAD").unwrap().symbolic_target(), + Some("refs/heads/master") + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_root), @r###" + @ fcdbbd731496cae17161cd6be9b6cf1f759655a8 + │ ◉ 1de814dbef9641cc6c5c80d2689b80778edcce09 + ├─╯ + ◉ 0000000000000000000000000000000000000000 + "###); + // Staged change shouldn't persist. + checkout_index(); + insta::assert_snapshot!(test_env.jj_cmd_success(&workspace_root, &["status"]), @r###" + The working copy is clean + Working copy : kkmpptxz fcdbbd73 (empty) (no description set) + Parent commit: zzzzzzzz 00000000 (empty) (no description set) + "###); + + // Stage some change, and create new HEAD. This shouldn't move the default + // branch. + add_file_to_index("file1", ""); + insta::assert_snapshot!(test_env.jj_cmd_success(&workspace_root, &["new"]), @r###" + Working copy now at: royxmykx 76c60bf0 (empty) (no description set) + Parent commit : kkmpptxz f8d5bc77 (no description set) + "###); + assert!(git_repo.head().unwrap().symbolic_target().is_none()); + insta::assert_snapshot!( + git_repo.head().unwrap().peel_to_commit().unwrap().id().to_string(), + @"f8d5bc772d1147351fd6e8cea52a4f935d3b31e7" + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_root), @r###" + @ 76c60bf0a66dcbe74d74d58c23848d96f9e86e84 + ◉ f8d5bc772d1147351fd6e8cea52a4f935d3b31e7 HEAD@git + │ ◉ 1de814dbef9641cc6c5c80d2689b80778edcce09 + ├─╯ + ◉ 0000000000000000000000000000000000000000 + "###); + // Staged change shouldn't persist. + checkout_index(); + insta::assert_snapshot!(test_env.jj_cmd_success(&workspace_root, &["status"]), @r###" + The working copy is clean + Working copy : royxmykx 76c60bf0 (empty) (no description set) + Parent commit: kkmpptxz f8d5bc77 (no description set) + "###); + + // Assign the default branch. The branch is no longer "unborn". + test_env.jj_cmd_success(&workspace_root, &["branch", "set", "-r@-", "master"]); + + // Stage some change, and check out root again. This should somehow unset the + // HEAD. https://github.com/martinvonz/jj/issues/1495 + add_file_to_index("file2", ""); + insta::assert_snapshot!( + test_env.jj_cmd_success(&workspace_root, &["checkout", "root()"]), @r###" + Working copy now at: znkkpsqq 10dd328b (empty) (no description set) + Parent commit : zzzzzzzz 00000000 (empty) (no description set) + Added 0 files, modified 0 files, removed 2 files + "###); + assert!(git_repo.head().is_err()); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_root), @r###" + @ 10dd328bb906e15890e55047740eab2812a3b2f7 + │ ◉ 2c576a57d2e6e8494616629cfdbb8fe5e3fea73b + │ ◉ f8d5bc772d1147351fd6e8cea52a4f935d3b31e7 master + ├─╯ + │ ◉ 1de814dbef9641cc6c5c80d2689b80778edcce09 + ├─╯ + ◉ 0000000000000000000000000000000000000000 + "###); + // Staged change shouldn't persist. + checkout_index(); + insta::assert_snapshot!(test_env.jj_cmd_success(&workspace_root, &["status"]), @r###" + The working copy is clean + Working copy : znkkpsqq 10dd328b (empty) (no description set) + Parent commit: zzzzzzzz 00000000 (empty) (no description set) + "###); + + // New snapshot and commit can be created after the HEAD got unset. + std::fs::write(workspace_root.join("file3"), "").unwrap(); + insta::assert_snapshot!(test_env.jj_cmd_success(&workspace_root, &["new"]), @r###" + Working copy now at: wqnwkozp cab23370 (empty) (no description set) + Parent commit : znkkpsqq 8f5b2638 (no description set) + "###); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_root), @r###" + @ cab233704a5c0b21bde070943055f22142fb2043 + ◉ 8f5b263819457712a2937428b9c58a2a84afbb1c HEAD@git + │ ◉ 2c576a57d2e6e8494616629cfdbb8fe5e3fea73b + │ ◉ f8d5bc772d1147351fd6e8cea52a4f935d3b31e7 master + ├─╯ + │ ◉ 1de814dbef9641cc6c5c80d2689b80778edcce09 + ├─╯ + ◉ 0000000000000000000000000000000000000000 + "###); +} + #[test] fn test_git_colocated_export_branches_on_snapshot() { // Checks that we export branches that were changed only because the working diff --git a/lib/src/git.rs b/lib/src/git.rs index db7c7eec2a..6f6baf3fe7 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -36,6 +36,8 @@ use crate::view::{RefName, View}; /// Reserved remote name for the backing Git repo. pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git"; +/// Ref namespace used as a placeholder to unset HEAD without a commit. +const UNBORN_REF_NAMESPACE: &str = "refs/jj/unborn/"; #[derive(Error, Debug)] pub enum GitImportError { @@ -690,6 +692,22 @@ pub fn reset_head( git_repo.set_head_detached(new_git_commit_id)?; git_repo.reset(new_git_commit.as_object(), git2::ResetType::Mixed, None)?; mut_repo.set_git_head_target(RefTarget::normal(first_parent_id.clone())); + } else { + // Can't detach HEAD without a commit. Use random ref to nullify the HEAD. + // We can't set_head() an arbitrary unborn ref, so use reference_symbolic() + // instead. Git CLI appears to deal with that. It would be nice if Git CLI + // couldn't create a commit without setting a valid branch name. + if mut_repo.git_head().is_present() { + let random_bytes: [u8; 16] = rand::random(); + let name = format!("{UNBORN_REF_NAMESPACE}{}", hex::encode(random_bytes)); + git_repo.reference_symbolic("HEAD", &name, true, "unset HEAD by jj")?; + } + // git_reset() of libgit2 requires a commit object. Do that manually. + let mut index = git_repo.index()?; + index.clear()?; // or read empty tree + index.write()?; + git_repo.cleanup_state()?; + mut_repo.set_git_head_target(RefTarget::absent()); } Ok(()) }