Skip to content

Commit 665e548

Browse files
committed
feat: provide a way to record and apply index changes.
These changes will then be applicable to an index that is created from the written tree editor.
1 parent b4b3966 commit 665e548

File tree

9 files changed

+482
-30
lines changed

9 files changed

+482
-30
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-merge/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ gix-quote = { version = "^0.4.13", path = "../gix-quote" }
3232
gix-revision = { version = "^0.30.0", path = "../gix-revision", default-features = false, features = ["merge_base"] }
3333
gix-revwalk = { version = "^0.16.0", path = "../gix-revwalk" }
3434
gix-diff = { version = "^0.47.0", path = "../gix-diff", default-features = false, features = ["blob"] }
35+
gix-index = { version = "^0.36.0", path = "../gix-index" }
3536

3637
thiserror = "2.0.0"
3738
imara-diff = { version = "0.1.7" }

gix-merge/src/tree/function.rs

Lines changed: 119 additions & 18 deletions
Large diffs are not rendered by default.

gix-merge/src/tree/mod.rs

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ impl Outcome<'_> {
8181
pub fn has_unresolved_conflicts(&self, how: TreatAsUnresolved) -> bool {
8282
self.conflicts.iter().any(|c| c.is_unresolved(how))
8383
}
84+
85+
/// Returns `true` if `index` changed as we applied conflicting stages to it, using `how` to determine if a
86+
/// conflict should be considered unresolved.
87+
/// It's important that `index` is at the state of [`Self::tree`].
88+
///
89+
/// Note that in practice, whenever there is a single [conflict], this function will return `true`.
90+
/// Also, the unconflicted stage of such entries will be removed merely by setting a flag, so the
91+
/// in-memory entry is still present.
92+
pub fn index_changed_after_applying_conflicts(&self, index: &mut gix_index::State, how: TreatAsUnresolved) -> bool {
93+
apply_index_entries(&self.conflicts, how, index)
94+
}
8495
}
8596

8697
/// A description of a conflict (i.e. merge issue without an auto-resolution) as seen during a [tree-merge](crate::tree()).
@@ -99,11 +110,45 @@ pub struct Conflict {
99110
pub ours: Change,
100111
/// The change representing *their* side.
101112
pub theirs: Change,
113+
/// An array to store an entry for each stage of the conflict.
114+
///
115+
/// * `entries[0]` => Base
116+
/// * `entries[1]` => Ours
117+
/// * `entries[2]` => Theirs
118+
///
119+
/// Note that ours and theirs might be swapped, so one should access it through [`Self::entries()`] to compensate for that.
120+
pub entries: [Option<ConflictIndexEntry>; 3],
102121
/// Determine how to interpret the `ours` and `theirs` fields. This is used to implement [`Self::changes_in_resolution()`]
103122
/// and [`Self::into_parts_by_resolution()`].
104123
map: ConflictMapping,
105124
}
106125

126+
/// A conflicting entry for insertion into the index.
127+
/// It will always be either on stage 1 (ancestor/base), 2 (ours) or 3 (theirs)
128+
#[derive(Debug, Clone, Copy)]
129+
pub struct ConflictIndexEntry {
130+
/// The kind of object at this stage.
131+
/// Note that it's possible that this is a directory, for instance if a directory was replaced with a file.
132+
pub mode: gix_object::tree::EntryMode,
133+
/// The id defining the state of the object.
134+
pub id: gix_hash::ObjectId,
135+
/// Hidden, maybe one day we can do without?
136+
path_hint: Option<ConflictIndexEntryPathHint>,
137+
}
138+
139+
/// A hint for [`apply_index_entries()`] to know which paths to use for an entry.
140+
/// This is only used when necessary.
141+
#[derive(Debug, Clone, Copy)]
142+
enum ConflictIndexEntryPathHint {
143+
/// Use the previous path, i.e. rename source.
144+
Source,
145+
/// Use the current path as it is in the tree.
146+
Current,
147+
/// Use the path of the final destination, or *their* name.
148+
/// It's definitely finicky, as we don't store the actual path and instead refer to it.
149+
RenamedOrTheirs,
150+
}
151+
107152
/// A utility to help define which side is what in the [`Conflict`] type.
108153
#[derive(Debug, Clone, Copy)]
109154
enum ConflictMapping {
@@ -147,7 +192,11 @@ impl Conflict {
147192
TreatAsUnresolved::Renames | TreatAsUnresolved::RenamesAndAutoResolvedContent => match &self.resolution {
148193
Ok(success) => match success {
149194
Resolution::SourceLocationAffectedByRename { .. } => false,
150-
Resolution::OursModifiedTheirsRenamedAndChangedThenRename { .. } => true,
195+
Resolution::OursModifiedTheirsRenamedAndChangedThenRename {
196+
merged_blob,
197+
final_location,
198+
..
199+
} => final_location.is_some() || merged_blob.as_ref().map_or(false, content_merge_matches),
151200
Resolution::OursModifiedTheirsModifiedThenBlobContentMerge { merged_blob } => {
152201
content_merge_matches(merged_blob)
153202
}
@@ -178,6 +227,14 @@ impl Conflict {
178227
}
179228
}
180229

230+
/// Return the index entries for insertion into the index, to match with what's returned by [`Self::changes_in_resolution()`].
231+
pub fn entries(&self) -> [Option<ConflictIndexEntry>; 3] {
232+
match self.map {
233+
ConflictMapping::Original => self.entries,
234+
ConflictMapping::Swapped => [self.entries[0], self.entries[2], self.entries[1]],
235+
}
236+
}
237+
181238
/// Return information about the content merge if it was performed.
182239
pub fn content_merge(&self) -> Option<ContentMerge> {
183240
match &self.resolution {
@@ -308,3 +365,132 @@ pub struct Options {
308365

309366
pub(super) mod function;
310367
mod utils;
368+
pub mod apply_index_entries {
369+
370+
pub(super) mod function {
371+
use crate::tree::{Conflict, ConflictIndexEntryPathHint, Resolution, ResolutionFailure, TreatAsUnresolved};
372+
use bstr::{BStr, ByteSlice};
373+
use std::collections::{hash_map, HashMap};
374+
375+
/// Returns `true` if `index` changed as we applied conflicting stages to it, using `how` to determine if a
376+
/// conflict should be considered unresolved.
377+
/// Once a stage of a path conflicts, the unconflicting stage is removed even though it might be the one
378+
/// that is currently checked out.
379+
/// This removal, however, is only done by flagging it with [gix_index::entry::Flags::REMOVE], which means
380+
/// these entries won't be written back to disk but will still be present in the index.
381+
/// It's important that `index` matches the tree that was produced as part of the merge that also
382+
/// brought about `conflicts`, or else this function will fail if it cannot find the path matching
383+
/// the conflicting entries.
384+
///
385+
/// Note that in practice, whenever there is a single [conflict], this function will return `true`.
386+
/// Errors can only occour if `index` isn't the one created from the merged tree that produced the `conflicts`.
387+
pub fn apply_index_entries(
388+
conflicts: &[Conflict],
389+
how: TreatAsUnresolved,
390+
index: &mut gix_index::State,
391+
) -> bool {
392+
let len = index.entries().len();
393+
let mut idx_by_path_stage = HashMap::<(gix_index::entry::Stage, &BStr), usize>::default();
394+
for conflict in conflicts.iter().filter(|c| c.is_unresolved(how)) {
395+
let (renamed_path, current_path): (Option<&BStr>, &BStr) = match &conflict.resolution {
396+
Ok(success) => match success {
397+
Resolution::SourceLocationAffectedByRename { final_location } => {
398+
(Some(final_location.as_bstr()), final_location.as_bstr())
399+
}
400+
Resolution::OursModifiedTheirsRenamedAndChangedThenRename { final_location, .. } => (
401+
final_location.as_ref().map(|p| p.as_bstr()),
402+
conflict.changes_in_resolution().1.location(),
403+
),
404+
Resolution::OursModifiedTheirsModifiedThenBlobContentMerge { .. } => {
405+
(None, conflict.ours.location())
406+
}
407+
},
408+
Err(failure) => match failure {
409+
ResolutionFailure::OursRenamedTheirsRenamedDifferently { .. } => {
410+
(Some(conflict.theirs.location()), conflict.ours.location())
411+
}
412+
ResolutionFailure::OursModifiedTheirsRenamedTypeMismatch
413+
| ResolutionFailure::OursDeletedTheirsRenamed
414+
| ResolutionFailure::OursModifiedTheirsDeleted
415+
| ResolutionFailure::Unknown => (None, conflict.ours.location()),
416+
ResolutionFailure::OursModifiedTheirsDirectoryThenOursRenamed {
417+
renamed_unique_path_to_modified_blob,
418+
} => (
419+
Some(renamed_unique_path_to_modified_blob.as_bstr()),
420+
conflict.ours.location(),
421+
),
422+
ResolutionFailure::OursAddedTheirsAddedTypeMismatch { their_unique_location } => {
423+
(Some(their_unique_location.as_bstr()), conflict.ours.location())
424+
}
425+
},
426+
};
427+
let source_path = conflict.ours.source_location();
428+
429+
let entries_with_stage = conflict.entries().into_iter().enumerate().filter_map(|(idx, entry)| {
430+
entry.filter(|e| e.mode.is_no_tree()).map(|e| {
431+
(
432+
match idx {
433+
0 => gix_index::entry::Stage::Base,
434+
1 => gix_index::entry::Stage::Ours,
435+
2 => gix_index::entry::Stage::Theirs,
436+
_ => unreachable!("fixed size array with three items"),
437+
},
438+
match e.path_hint {
439+
None => renamed_path.unwrap_or(current_path),
440+
Some(ConflictIndexEntryPathHint::Source) => source_path,
441+
Some(ConflictIndexEntryPathHint::Current) => current_path,
442+
Some(ConflictIndexEntryPathHint::RenamedOrTheirs) => {
443+
renamed_path.unwrap_or_else(|| conflict.changes_in_resolution().1.location())
444+
}
445+
},
446+
e,
447+
)
448+
})
449+
});
450+
451+
if !entries_with_stage.clone().any(|(_, path, _)| {
452+
index
453+
.entry_index_by_path_and_stage_bounded(path, gix_index::entry::Stage::Unconflicted, len)
454+
.is_some()
455+
}) {
456+
continue;
457+
}
458+
459+
for (stage, path, entry) in entries_with_stage {
460+
if let Some(pos) =
461+
index.entry_index_by_path_and_stage_bounded(path, gix_index::entry::Stage::Unconflicted, len)
462+
{
463+
index.entries_mut()[pos].flags.insert(gix_index::entry::Flags::REMOVE);
464+
};
465+
match idx_by_path_stage.entry((stage, path)) {
466+
hash_map::Entry::Occupied(map_entry) => {
467+
// This can happen due to the way the algorithm works.
468+
// The same happens in Git, but it stores the index-related data as part of its deduplicating tree.
469+
// We store each conflict we encounter, which also may duplicate their index entries, sometimes, but
470+
// with different values. The most recent value wins.
471+
// Instead of trying to deduplicate the index entries when the merge runs, we put the cost
472+
// to the tree-assembly - there is no way around it.
473+
let index_entry = &mut index.entries_mut()[*map_entry.get()];
474+
index_entry.mode = entry.mode.into();
475+
index_entry.id = entry.id;
476+
}
477+
hash_map::Entry::Vacant(map_entry) => {
478+
map_entry.insert(index.entries().len());
479+
index.dangerously_push_entry(
480+
Default::default(),
481+
entry.id,
482+
stage.into(),
483+
entry.mode.into(),
484+
path,
485+
);
486+
}
487+
};
488+
}
489+
}
490+
491+
index.sort_entries();
492+
index.entries().len() != len
493+
}
494+
}
495+
}
496+
pub use apply_index_entries::function::apply_index_entries;

gix-merge/src/tree/utils.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! contribute to finding a fix faster.
88
use crate::blob::builtin_driver::binary::Pick;
99
use crate::blob::ResourceKind;
10-
use crate::tree::{Conflict, ConflictMapping, Error, Options, Resolution, ResolutionFailure};
10+
use crate::tree::{Conflict, ConflictIndexEntry, ConflictMapping, Error, Options, Resolution, ResolutionFailure};
1111
use bstr::ByteSlice;
1212
use bstr::{BStr, BString, ByteVec};
1313
use gix_diff::tree_with_rewrites::{Change, ChangeRef};
@@ -98,6 +98,14 @@ pub fn perform_blob_merge<E>(
9898
where
9999
E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
100100
{
101+
if our_id == their_id {
102+
// This can happen if the merge modes are different.
103+
debug_assert_ne!(
104+
our_mode, their_mode,
105+
"BUG: we must think anything has to be merged if the modes and the ids are the same"
106+
);
107+
return Ok((their_id, crate::blob::Resolution::Complete));
108+
}
101109
if matches!(our_mode.kind(), EntryKind::Link) && matches!(their_mode.kind(), EntryKind::Link) {
102110
let (pick, resolution) = crate::blob::builtin_driver::binary(options.symlink_conflicts);
103111
let (our_id, their_id) = match outer_side {
@@ -541,28 +549,53 @@ pub fn to_components(rela_path: &BStr) -> impl Iterator<Item = &BStr> {
541549
}
542550

543551
impl Conflict {
552+
pub(super) fn without_resolution_no_entry(
553+
resolution: ResolutionFailure,
554+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
555+
) -> Self {
556+
Conflict::maybe_resolved_no_entry(Err(resolution), changes)
557+
}
558+
544559
pub(super) fn without_resolution(
545560
resolution: ResolutionFailure,
546561
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
562+
entries: [Option<ConflictIndexEntry>; 3],
547563
) -> Self {
548-
Conflict::maybe_resolved(Err(resolution), changes)
564+
Conflict::maybe_resolved(Err(resolution), changes, entries)
565+
}
566+
567+
pub(super) fn with_resolution_no_entry(
568+
resolution: Resolution,
569+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
570+
) -> Self {
571+
Conflict::maybe_resolved_no_entry(Ok(resolution), changes)
549572
}
550573

551574
pub(super) fn with_resolution(
552575
resolution: Resolution,
553576
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
577+
entries: [Option<ConflictIndexEntry>; 3],
578+
) -> Self {
579+
Conflict::maybe_resolved(Ok(resolution), changes, entries)
580+
}
581+
582+
pub(super) fn maybe_resolved_no_entry(
583+
resolution: Result<Resolution, ResolutionFailure>,
584+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
554585
) -> Self {
555-
Conflict::maybe_resolved(Ok(resolution), changes)
586+
Self::maybe_resolved(resolution, changes, Default::default())
556587
}
557588

558589
pub(super) fn maybe_resolved(
559590
resolution: Result<Resolution, ResolutionFailure>,
560591
(ours, theirs, map, outer_map): (&Change, &Change, ConflictMapping, ConflictMapping),
592+
entries: [Option<ConflictIndexEntry>; 3],
561593
) -> Self {
562594
Conflict {
563595
resolution,
564596
ours: ours.clone(),
565597
theirs: theirs.clone(),
598+
entries,
566599
map: match outer_map {
567600
ConflictMapping::Original => map,
568601
ConflictMapping::Swapped => map.swapped(),
Binary file not shown.

gix-merge/tests/fixtures/tree-baseline.sh

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,26 @@ git init same-rename-different-mode
493493
git commit -am "Git, when branches are reversed, doesn't keep the +x flag on a/w so we specify our own expectation"
494494
)
495495

496+
git init remove-executable-mode
497+
(cd remove-executable-mode
498+
touch w
499+
chmod +x w
500+
git add --chmod=+x w
501+
git add . && git commit -m "original"
502+
503+
git branch A
504+
git branch B
505+
506+
git checkout A
507+
chmod -x w
508+
git update-index --chmod=-x w
509+
git commit -am "remove executable bit from w"
510+
511+
git checkout B
512+
write_lines 1 2 3 4 5 >w
513+
git commit -am "unrelated change to w"
514+
)
515+
496516
git init renamed-symlink-with-conflict
497517
(cd renamed-symlink-with-conflict
498518
mkdir a && write_lines original 1 2 3 4 5 >a/x.f
@@ -670,16 +690,16 @@ git init both-modify-file-with-binary-attr
670690

671691
git init big-file-merge
672692
(cd big-file-merge
673-
git config --local core.bigFileThreshold 80
693+
git config --local core.bigFileThreshold 100
674694
mkdir a && write_lines original 1 2 3 4 5 >a/x.f
675695
git add . && git commit -m "original"
676696

677697
git branch A
678698
git branch B
679699

680700
git checkout A
681-
seq 30 >a/x.f
682-
git commit -am "turn normal file into big one (81 bytes)"
701+
seq 37 >a/x.f
702+
git commit -am "turn normal file into big one (102 bytes)"
683703
git branch expected
684704

685705
git checkout B
@@ -803,6 +823,7 @@ git init type-change-to-symlink
803823

804824

805825

826+
baseline remove-executable-mode A-B A B
806827
baseline simple side-1-3-without-conflict side1 side3
807828
baseline simple fast-forward side1 main
808829
baseline simple no-change main main

0 commit comments

Comments
 (0)