diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1acab142..d0d2cee863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Author and committer names are now yellow by default. +* Initial support for shallow git repositories has been implemented. However + deepening the history of a shallow repository is not yet supported. + ### Fixed bugs * Update working copy before reporting changes. This prevents errors during reporting diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index 03a0f13cbf..291183a8aa 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -449,12 +449,9 @@ impl From for CommandError { GitImportError::MissingHeadTarget { .. } | GitImportError::MissingRefAncestor { .. } => Some( "\ -Is this Git repository a shallow or partial clone (cloned with the --depth or --filter \ - argument)? -jj currently does not support shallow/partial clones. To use jj with this \ - repository, try -unshallowing the repository (https://stackoverflow.com/q/6802145) or re-cloning with the full -repository contents." +Is this Git repository a partial clone (cloned with the --filter argument)? +jj currently does not support partial clones. To use jj with this repository, try re-cloning with \ + the full repository contents." .to_string(), ), GitImportError::RemoteReservedForLocalGitRepo => { diff --git a/docs/git-compatibility.md b/docs/git-compatibility.md index 36eb957b64..a5d1adcef3 100644 --- a/docs/git-compatibility.md +++ b/docs/git-compatibility.md @@ -57,8 +57,9 @@ a comparison with Git, including how workflows are different, see the not be lost either. * **Partial clones: No.** We use the [libgit2](https://libgit2.org/) library, which [doesn't have support for partial clones](https://github.com/libgit2/libgit2/issues/5564). -* **Shallow clones: No.** We use the [libgit2](https://libgit2.org/) library, - which [doesn't have support for shallow clones](https://github.com/libgit2/libgit2/issues/3058). +* **Shallow clones: Kind of.** Shallow commits all have the virtual root commit as + their parent. However, deepening or fully unshallowing a repository is currently not yet + supported and will cause issues. * **git-worktree: No.** However, there's native support for multiple working copies backed by a single repo. See the `jj workspace` family of commands. * **Sparse checkouts: No.** However, there's native support for sparse diff --git a/lib/src/git_backend.rs b/lib/src/git_backend.rs index 8a69f4836a..ce281819cd 100644 --- a/lib/src/git_backend.rs +++ b/lib/src/git_backend.rs @@ -519,6 +519,7 @@ fn commit_from_git_without_root_parent( id: &CommitId, git_object: &gix::Object, uses_tree_conflict_format: bool, + is_shallow: bool, ) -> BackendResult { let commit = git_object .try_to_commit_ref() @@ -537,10 +538,17 @@ fn commit_from_git_without_root_parent( .map(|b| b.reverse_bits()) .collect(), ); - let parents = commit - .parents() - .map(|oid| CommitId::from_bytes(oid.as_bytes())) - .collect_vec(); + // shallow commits don't have parents their parents actually fetched, so we + // discard them here + // TODO: This causes issues when a shallow repository is deepened/unshallowed + let parents = if is_shallow { + vec![] + } else { + commit + .parents() + .map(|oid| CommitId::from_bytes(oid.as_bytes())) + .collect_vec() + }; let tree_id = TreeId::from_bytes(commit.tree().as_bytes()); // If this commit is a conflict, we'll update the root tree later, when we read // the extra metadata. @@ -859,6 +867,10 @@ fn import_extra_metadata_entries_from_heads( head_ids: &HashSet<&CommitId>, uses_tree_conflict_format: bool, ) -> BackendResult<()> { + let shallow_commits = git_repo + .shallow_commits() + .map_err(|e| BackendError::Other(Box::new(e)))?; + let mut work_ids = head_ids .iter() .filter(|&id| mut_table.get_value(id.as_bytes()).is_none()) @@ -868,11 +880,18 @@ fn import_extra_metadata_entries_from_heads( let git_object = git_repo .find_object(validate_git_object_id(&id)?) .map_err(|err| map_not_found_err(err, &id))?; + let is_shallow = shallow_commits + .as_ref() + .is_some_and(|shallow| shallow.contains(&git_object.id)); // TODO(#1624): Should we read the root tree here and check if it has a // `.jjconflict-...` entries? That could happen if the user used `git` to e.g. // change the description of a commit with tree-level conflicts. - let commit = - commit_from_git_without_root_parent(&id, &git_object, uses_tree_conflict_format)?; + let commit = commit_from_git_without_root_parent( + &id, + &git_object, + uses_tree_conflict_format, + is_shallow, + )?; mut_table.add_entry(id.to_bytes(), serialize_extras(&commit)); work_ids.extend( commit @@ -1141,7 +1160,12 @@ impl Backend for GitBackend { let git_object = locked_repo .find_object(git_commit_id) .map_err(|err| map_not_found_err(err, id))?; - commit_from_git_without_root_parent(id, &git_object, false)? + let is_shallow = locked_repo + .shallow_commits() + .ok() + .flatten() + .is_some_and(|shallow| shallow.contains(&git_object.id)); + commit_from_git_without_root_parent(id, &git_object, false, is_shallow)? }; if commit.parents.is_empty() { commit.parents.push(self.root_commit_id.clone()); diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index d9106f994f..74b8e15e34 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -15,6 +15,7 @@ use std::collections::BTreeMap; use std::collections::HashSet; use std::fs; +use std::io::Write; use std::iter; use std::path::Path; use std::path::PathBuf; @@ -3468,3 +3469,95 @@ ignoreThisSection = foo assert_eq!(result, expected); } + +#[test] +fn test_shallow_commits_lack_parents() { + let settings = testutils::user_settings(); + let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); + let repo = &test_repo.repo; + let git_repo = get_git_repo(repo); + + // D E (`main`) + // | | + // B C // shallow boundary + // | / + // A + // | + // git_root + let git_root = empty_git_commit(&git_repo, "refs/heads/main", &[]); + + let a = empty_git_commit(&git_repo, "refs/heads/main", &[&git_root]); + + let b = empty_git_commit(&git_repo, "refs/heads/feature", &[&a]); + let c = empty_git_commit(&git_repo, "refs/heads/main", &[&a]); + + let d = empty_git_commit(&git_repo, "refs/heads/feature", &[&b]); + let e = empty_git_commit(&git_repo, "refs/heads/main", &[&c]); + + git_repo.set_head("refs/heads/main").unwrap(); + + let make_shallow = |repo, mut shallow_commits: Vec<_>| { + let shallow_file = get_git_backend(repo).git_repo().shallow_file(); + shallow_commits.sort(); + let mut buf = Vec::::new(); + for commit in shallow_commits { + writeln!(buf, "{commit}").unwrap(); + } + fs::write(&shallow_file, buf).unwrap(); + }; + make_shallow(repo, vec![b.id(), c.id()]); + + let mut tx = repo.start_transaction(&settings); + git::import_refs(tx.repo_mut(), &GitSettings::default()).unwrap(); + let repo = tx.commit("import"); + let store = repo.store(); + let root = store.root_commit_id(); + + let expected_heads = hashset! { + jj_id(&d), + jj_id(&e), + }; + assert_eq!(*repo.view().heads(), expected_heads); + + let parents = |store: &Arc, commit| { + let commit = store.get_commit(&jj_id(commit)).unwrap(); + commit.parent_ids().to_vec() + }; + + assert_eq!( + parents(store, &b), + vec![root.clone()], + "shallow commits have the root commit as a parent" + ); + assert_eq!( + parents(store, &c), + vec![root.clone()], + "shallow commits have the root commit as a parent" + ); + + // deepen the shallow clone + make_shallow(&repo, vec![a.id()]); + + let mut tx = repo.start_transaction(&settings); + git::import_refs(tx.repo_mut(), &GitSettings::default()).unwrap(); + let repo = tx.commit("import"); + let store = repo.store(); + let root = store.root_commit_id(); + + assert_eq!( + parents(store, &a), + vec![root.clone()], + "shallow commits have the root commit as a parent" + ); + // TODO: These should be assert_eq! + assert_ne!( + parents(store, &b), + vec![jj_id(&a)], + "unshallowed commits have parents" + ); + assert_ne!( + parents(store, &c), + vec![jj_id(&a)], + "unshallowed commits have correct parents" + ); +}