Skip to content

Commit 10ae556

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 39f46c5 commit 10ae556

File tree

8 files changed

+432
-28
lines changed

8 files changed

+432
-28
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: 102 additions & 19 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.expect("BUG: renamed path must be present if we hint at it")
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: 28 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};
@@ -541,28 +541,53 @@ pub fn to_components(rela_path: &BStr) -> impl Iterator<Item = &BStr> {
541541
}
542542

543543
impl Conflict {
544+
pub(super) fn without_resolution_no_entry(
545+
resolution: ResolutionFailure,
546+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
547+
) -> Self {
548+
Conflict::maybe_resolved_no_entry(Err(resolution), changes)
549+
}
550+
544551
pub(super) fn without_resolution(
545552
resolution: ResolutionFailure,
546553
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
554+
entries: [Option<ConflictIndexEntry>; 3],
547555
) -> Self {
548-
Conflict::maybe_resolved(Err(resolution), changes)
556+
Conflict::maybe_resolved(Err(resolution), changes, entries)
557+
}
558+
559+
pub(super) fn with_resolution_no_entry(
560+
resolution: Resolution,
561+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
562+
) -> Self {
563+
Conflict::maybe_resolved_no_entry(Ok(resolution), changes)
549564
}
550565

551566
pub(super) fn with_resolution(
552567
resolution: Resolution,
553568
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
569+
entries: [Option<ConflictIndexEntry>; 3],
570+
) -> Self {
571+
Conflict::maybe_resolved(Ok(resolution), changes, entries)
572+
}
573+
574+
pub(super) fn maybe_resolved_no_entry(
575+
resolution: Result<Resolution, ResolutionFailure>,
576+
changes: (&Change, &Change, ConflictMapping, ConflictMapping),
554577
) -> Self {
555-
Conflict::maybe_resolved(Ok(resolution), changes)
578+
Self::maybe_resolved(resolution, changes, Default::default())
556579
}
557580

558581
pub(super) fn maybe_resolved(
559582
resolution: Result<Resolution, ResolutionFailure>,
560583
(ours, theirs, map, outer_map): (&Change, &Change, ConflictMapping, ConflictMapping),
584+
entries: [Option<ConflictIndexEntry>; 3],
561585
) -> Self {
562586
Conflict {
563587
resolution,
564588
ours: ours.clone(),
565589
theirs: theirs.clone(),
590+
entries,
566591
map: match outer_map {
567592
ConflictMapping::Original => map,
568593
ConflictMapping::Swapped => map.swapped(),
Binary file not shown.

0 commit comments

Comments
 (0)