git: use merged parent tree for git index

Instead of setting the index to match the tree of HEAD, we now set the
index to the merged parent tree of the working copy commit. This means
that if you edit a merge commit, it will make the Git index look like it
would in the middle of a `git merge` operation (with all of the
successfully-merged files staged in the index).

If there are any 2-sided conflicts in the merged parent tree, then they
will be added to the index as conflicts. Since Git doesn't support
conflicts with more than 2 sides, many-sided conflicts are staged as the
first side of the conflict. The following commit will improve this.
This commit is contained in:
Scott Taylor 2024-12-18 18:18:58 -06:00 committed by Scott Taylor
parent 9cc8b35251
commit 42b390bbc4
4 changed files with 224 additions and 99 deletions

View file

@ -26,6 +26,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* The deprecated `--siblings` options for `jj split` has been removed.
`jj split --parallel` can be used instead.
* In colocated repos, the Git index now contains the changes from all parents
of the working copy instead of just the first parent (`HEAD`). 2-sided
conflicts from the merged parents are now added to the Git index as conflicts
as well.
### Deprecations
### New features

View file

@ -899,12 +899,15 @@ fn test_git_colocated_update_index_merge_conflict() {
0000000000000000000000000000000000000000
"#);
// The index should contain the tree of the Git HEAD. The stat for base.txt
// should not change.
// Conflict should be added in index with correct blob IDs. The stat for
// base.txt should not change.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 conflict.txt
Base Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 conflict.txt
Ours Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 conflict.txt
Theirs Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 right.txt
"#);
test_env.jj_cmd_ok(&repo_path, &["new"]);
@ -920,21 +923,14 @@ fn test_git_colocated_update_index_merge_conflict() {
0000000000000000000000000000000000000000
"#);
// The Git HEAD now contains ".jjconflict" files instead of the real contents.
// Index should be the same after `jj new`.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/base.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/right.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/base.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/right.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/base.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/right.txt
Unconflicted Mode(FILE) 5dc38902e68e ctime=0:0 mtime=0:0 size=0 README
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Base Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 conflict.txt
Ours Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 conflict.txt
Theirs Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 right.txt
"#);
}
@ -992,8 +988,8 @@ fn test_git_colocated_update_index_rebase_conflict() {
0000000000000000000000000000000000000000
"#);
// The index should contain the tree of the Git HEAD. The stat for base.txt
// should not change.
// Index should contain files from parent commit, so there should be no conflict
// in conflict.txt yet. The stat for base.txt should not change.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 conflict.txt
@ -1010,21 +1006,15 @@ fn test_git_colocated_update_index_rebase_conflict() {
0000000000000000000000000000000000000000
"#);
// The Git HEAD now contains ".jjconflict" files instead of the real contents.
// Now the working copy commit's parent is conflicted, so the index should have
// a conflict with correct blob IDs.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/base.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/right.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/base.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/right.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/base.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/right.txt
Unconflicted Mode(FILE) 5dc38902e68e ctime=0:0 mtime=0:0 size=0 README
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Base Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 conflict.txt
Ours Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 conflict.txt
Theirs Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 conflict.txt
Unconflicted Mode(FILE) 45cf141ba67d ctime=0:0 mtime=0:0 size=0 left.txt
Unconflicted Mode(FILE) c376d892e8b1 ctime=0:0 mtime=0:0 size=0 right.txt
"#);
}
@ -1082,12 +1072,14 @@ fn test_git_colocated_update_index_3_sided_conflict() {
0000000000000000000000000000000000000000
"#);
// The index should contain the tree of the Git HEAD. The stat for base.txt
// should not change.
// We can't add conflicts with more than 2 sides to the index, so they should
// show as unconflicted. The stat for base.txt should not change.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 side-3.txt
"#);
test_env.jj_cmd_ok(&repo_path, &["new"]);
@ -1105,34 +1097,13 @@ fn test_git_colocated_update_index_3_sided_conflict() {
0000000000000000000000000000000000000000
"#);
// The Git HEAD now contains ".jjconflict" files instead of the real contents.
// Index should be the same after `jj new`.
insta::assert_snapshot!(get_index_state(&repo_path), @r#"
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/base.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-0/side-3.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-1/base.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-1/conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-1/side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-1/side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-base-1/side-3.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/base.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-0/side-3.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/base.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-1/side-3.txt
Unconflicted Mode(FILE) df967b96a579 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-2/base.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-2/conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-2/side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-2/side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 .jjconflict-side-2/side-3.txt
Unconflicted Mode(FILE) 5dc38902e68e ctime=0:0 mtime=0:0 size=0 README
Unconflicted Mode(FILE) df967b96a579 ctime=[nonzero] mtime=[nonzero] size=5 base.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 conflict.txt
Unconflicted Mode(FILE) dd8f930010b3 ctime=0:0 mtime=0:0 size=0 side-1.txt
Unconflicted Mode(FILE) 7b44e11df720 ctime=0:0 mtime=0:0 size=0 side-2.txt
Unconflicted Mode(FILE) 42f37a71bf20 ctime=0:0 mtime=0:0 size=0 side-3.txt
"#);
}

View file

@ -25,16 +25,18 @@ use std::num::NonZeroU32;
use std::path::PathBuf;
use std::str;
use bstr::BStr;
use itertools::Itertools;
use tempfile::NamedTempFile;
use thiserror::Error;
use crate::backend::BackendError;
use crate::backend::CommitId;
use crate::backend::TreeId;
use crate::backend::TreeValue;
use crate::commit::Commit;
use crate::git_backend::GitBackend;
use crate::index::Index;
use crate::merged_tree::MergedTree;
use crate::object_id::ObjectId;
use crate::op_store::RefTarget;
use crate::op_store::RefTargetOptionExt;
@ -44,6 +46,7 @@ use crate::refs;
use crate::refs::BookmarkPushUpdate;
use crate::repo::MutableRepo;
use crate::repo::Repo;
use crate::repo_path::RepoPath;
use crate::revset::RevsetExpression;
use crate::settings::GitSettings;
use crate::store::Store;
@ -1024,39 +1027,29 @@ pub fn reset_head(mut_repo: &mut MutableRepo, wc_commit: &Commit) -> Result<(),
.map_err(GitExportError::from_git)?;
}
// This is a way to find the tree ID associated with the raw Git commit, meaning
// it contains the ".jjconflict" trees as well. This is temporary; we just want
// to maintain the same behavior from git2.
let parent_tree_id = if first_parent_id == mut_repo.store().root_commit_id() {
mut_repo.store().empty_tree_id().clone()
} else {
TreeId::new(
git_repo
.find_commit(gix::ObjectId::from_bytes_or_panic(
first_parent_id.as_bytes(),
))
.map_err(GitExportError::from_git)?
.tree_id()
.map_err(GitExportError::from_git)?
.as_bytes()
.to_owned(),
)
};
let parent_tree = wc_commit.parent_tree(mut_repo)?;
let mut index = if &parent_tree_id == mut_repo.store().empty_tree_id() {
// If the tree is empty, gix can fail to load the object (since Git doesn't
// require the empty tree to actually be present in the object database), so we
// just use an empty index directly.
gix::index::File::from_state(
gix::index::State::new(git_repo.object_hash()),
git_repo.index_path(),
)
// Use the merged parent tree as the Git index, allowing `git diff` to show the
// same changes as `jj diff`. If the merged parent tree has 2-sided conflicts,
// then the Git index will also be conflicted.
let mut index = if let Some(tree) = parent_tree.as_merge().as_resolved() {
if tree.id() == mut_repo.store().empty_tree_id() {
// If the tree is empty, gix can fail to load the object (since Git doesn't
// require the empty tree to actually be present in the object database), so we
// just use an empty index directly.
gix::index::File::from_state(
gix::index::State::new(git_repo.object_hash()),
git_repo.index_path(),
)
} else {
// If the parent tree is resolved, we can use gix's `index_from_tree` method.
// This is more efficient than iterating over the tree and adding each entry.
git_repo
.index_from_tree(&gix::ObjectId::from_bytes_or_panic(tree.id().as_bytes()))
.map_err(GitExportError::from_git)?
}
} else {
git_repo
.index_from_tree(&gix::ObjectId::from_bytes_or_panic(
parent_tree_id.as_bytes(),
))
.map_err(GitExportError::from_git)?
build_index_from_merged_tree(&git_repo, parent_tree)?
};
// Match entries in the new index with entries in the old index, and copy stat
@ -1083,6 +1076,87 @@ pub fn reset_head(mut_repo: &mut MutableRepo, wc_commit: &Commit) -> Result<(),
Ok(())
}
fn build_index_from_merged_tree(
git_repo: &gix::Repository,
merged_tree: MergedTree,
) -> Result<gix::index::File, GitExportError> {
let mut index = gix::index::File::from_state(
gix::index::State::new(git_repo.object_hash()),
git_repo.index_path(),
);
let mut push_index_entry =
|path: &RepoPath, maybe_entry: &Option<TreeValue>, stage: gix::index::entry::Stage| {
let Some(entry) = maybe_entry else {
return;
};
let (id, mode) = match entry {
TreeValue::File { id, executable } => {
if *executable {
(id.as_bytes(), gix::index::entry::Mode::FILE_EXECUTABLE)
} else {
(id.as_bytes(), gix::index::entry::Mode::FILE)
}
}
TreeValue::Symlink(id) => (id.as_bytes(), gix::index::entry::Mode::SYMLINK),
TreeValue::Tree(_) => {
// This case is only possible if there is a file-directory conflict, since
// `MergedTree::entries` handles the recursion otherwise. We only materialize a
// file in the working copy for file-directory conflicts, so we don't add the
// tree to the index here either.
return;
}
TreeValue::GitSubmodule(id) => (id.as_bytes(), gix::index::entry::Mode::COMMIT),
TreeValue::Conflict(_) => panic!("unexpected merged tree entry: {entry:?}"),
};
let path = BStr::new(path.as_internal_file_string());
// It is safe to push the entry because we ensure that we only add each path to
// a stage once, and we sort the entries after we finish adding them.
index.dangerously_push_entry(
gix::index::entry::Stat::default(),
gix::ObjectId::from_bytes_or_panic(id),
gix::index::entry::Flags::from_stage(stage),
mode,
path,
);
};
for (path, entry) in merged_tree.entries() {
let entry = entry?;
if let Some(resolved) = entry.as_resolved() {
push_index_entry(&path, resolved, gix::index::entry::Stage::Unconflicted);
continue;
}
let conflict = entry.simplify();
if let [left, base, right] = conflict.as_slice() {
// 2-sided conflicts can be represented in the Git index
push_index_entry(&path, left, gix::index::entry::Stage::Ours);
push_index_entry(&path, base, gix::index::entry::Stage::Base);
push_index_entry(&path, right, gix::index::entry::Stage::Theirs);
} else {
// We can't represent many-sided conflicts in the Git index, so just add the
// first side as staged. This is preferable to adding the first 2 sides as a
// conflict, since some tools rely on being able to resolve conflicts using the
// index, which could lead to an incorrect conflict resolution if the index
// didn't contain all of the conflict sides.
push_index_entry(
&path,
conflict.first(),
gix::index::entry::Stage::Unconflicted,
);
}
}
// Required after `dangerously_push_entry` for correctness
index.sort_entries();
Ok(index)
}
#[derive(Debug, Error)]
pub enum GitRemoteManagementError {
#[error("No git remote named '{0}'")]

View file

@ -2408,16 +2408,91 @@ fn test_reset_head_with_index_merge_conflict() {
// Reset head to working copy commit with merge conflict
git::reset_head(mut_repo, &wc_commit).unwrap();
// Files from left commit (HEAD) should be added to index as "Unconflicted".
// Index should contain conflicted files from merge of parent commits.
// `Mode(DIR | SYMLINK)` actually means `MODE(COMMIT)`, as in a git submodule.
insta::assert_snapshot!(get_index_state(&workspace_root), @r#"
Unconflicted some/dir/commit Mode(DIR | SYMLINK)
Unconflicted some/dir/executable-file Mode(FILE | FILE_EXECUTABLE)
Unconflicted some/dir/normal-file Mode(FILE)
Unconflicted some/dir/symlink Mode(SYMLINK)
Base some/dir/commit Mode(DIR | SYMLINK)
Ours some/dir/commit Mode(DIR | SYMLINK)
Theirs some/dir/commit Mode(DIR | SYMLINK)
Base some/dir/executable-file Mode(FILE | FILE_EXECUTABLE)
Ours some/dir/executable-file Mode(FILE | FILE_EXECUTABLE)
Theirs some/dir/executable-file Mode(FILE | FILE_EXECUTABLE)
Base some/dir/normal-file Mode(FILE)
Ours some/dir/normal-file Mode(FILE)
Theirs some/dir/normal-file Mode(FILE)
Base some/dir/symlink Mode(SYMLINK)
Ours some/dir/symlink Mode(SYMLINK)
Theirs some/dir/symlink Mode(SYMLINK)
"#);
}
#[test]
fn test_reset_head_with_index_file_directory_conflict() {
// Create colocated workspace
let settings = testutils::user_settings();
let temp_dir = testutils::new_temp_dir();
let workspace_root = temp_dir.path().join("repo");
gix::init(&workspace_root).unwrap();
let (_workspace, repo) =
Workspace::init_external_git(&settings, &workspace_root, &workspace_root.join(".git"))
.unwrap();
let mut tx = repo.start_transaction();
let mut_repo = tx.repo_mut();
// Build conflict trees containing file-directory conflict
let left_tree_id = {
let mut tree_builder =
TreeBuilder::new(repo.store().clone(), repo.store().empty_tree_id().clone());
testutils::write_normal_file(
&mut tree_builder,
RepoPath::from_internal_string("test/dir/file"),
"dir\n",
);
MergedTreeId::resolved(tree_builder.write_tree().unwrap())
};
let right_tree_id = {
let mut tree_builder =
TreeBuilder::new(repo.store().clone(), repo.store().empty_tree_id().clone());
testutils::write_normal_file(
&mut tree_builder,
RepoPath::from_internal_string("test"),
"file\n",
);
MergedTreeId::resolved(tree_builder.write_tree().unwrap())
};
let left_commit = mut_repo
.new_commit(
vec![repo.store().root_commit_id().clone()],
left_tree_id.clone(),
)
.write()
.unwrap();
let right_commit = mut_repo
.new_commit(
vec![repo.store().root_commit_id().clone()],
right_tree_id.clone(),
)
.write()
.unwrap();
let wc_commit = mut_repo
.new_commit(
vec![left_commit.id().clone(), right_commit.id().clone()],
repo.store().empty_merged_tree_id().clone(),
)
.write()
.unwrap();
// Reset head to working copy commit with file-directory conflict
git::reset_head(mut_repo, &wc_commit).unwrap();
// Only the file should be added to the index (the tree should be skipped).
insta::assert_snapshot!(get_index_state(&workspace_root), @"Theirs test Mode(FILE)");
}
#[test]
fn test_init() {
let settings = testutils::user_settings();