Skip to content

Commit d0b9025

Browse files
authored
Merge pull request #13765 from gitbutlerapp/merge-changes-into-tree
editor: merge commit changes into tree
2 parents c2c89ae + 3be8ea6 commit d0b9025

22 files changed

Lines changed: 1861 additions & 233 deletions

crates/but-core/src/commit/mod.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use anyhow::{Context as _, bail};
66
use bstr::{BStr, BString, ByteSlice};
77
use but_error::Code;
88
use gix::objs::WriteTo;
9+
use gix::objs::tree::EntryKind;
910
use gix::prelude::ObjectIdExt;
1011
use serde::{Deserialize, Serialize};
1112

@@ -698,8 +699,149 @@ impl ConflictEntries {
698699
}
699700
}
700701

702+
/// Derive GitButler conflict entry metadata from a `gix` tree-merge outcome.
703+
///
704+
/// This is the shared translation used when a merge is auto-resolved but still
705+
/// has unresolved conflicts that must be persisted in a conflicted commit/tree.
706+
/// It first asks `gix` to materialize conflict stages into an index view of the
707+
/// merged tree. If that yields no staged conflict paths, it falls back to the
708+
/// unresolved conflict list from the merge outcome itself, which is important
709+
/// for force-resolved merges where Git would otherwise omit index stages.
710+
///
711+
/// - `repo` - is the repository that owns `merged_tree_id` and provides the index
712+
/// machinery used to derive stage entries from the merge result.
713+
///
714+
/// - `merged_tree_id` - is the tree written from the merge result's auto-resolved
715+
/// output, which is reloaded so conflict stages can be inspected.
716+
///
717+
/// - `merge_result` - is the original `gix` merge outcome whose unresolved
718+
/// conflicts are translated into GitButler conflict-entry metadata.
719+
///
720+
/// - `treat_as_unresolved` - decides which conflicts should still be treated as
721+
/// unresolved when scanning both staged entries and fallback conflict records.
722+
pub fn conflict_entries_from_merge_outcome(
723+
repo: &gix::Repository,
724+
merged_tree_id: gix::ObjectId,
725+
merge_result: &gix::merge::tree::Outcome<'_>,
726+
treat_as_unresolved: gix::merge::tree::TreatAsUnresolved,
727+
) -> anyhow::Result<ConflictEntries> {
728+
use gix::index::entry::Stage;
729+
730+
let mut index = repo.index_from_tree(&merged_tree_id.attach(repo))?;
731+
merge_result.index_changed_after_applying_conflicts(
732+
&mut index,
733+
treat_as_unresolved,
734+
gix::merge::tree::apply_index_entries::RemovalMode::Mark,
735+
);
736+
737+
let (mut ancestor_entries, mut our_entries, mut their_entries) =
738+
(Vec::new(), Vec::new(), Vec::new());
739+
for entry in index.entries() {
740+
let storage = match entry.stage() {
741+
Stage::Unconflicted => continue,
742+
Stage::Base => &mut ancestor_entries,
743+
Stage::Ours => &mut our_entries,
744+
Stage::Theirs => &mut their_entries,
745+
};
746+
storage.push(gix::path::from_bstr(entry.path(&index)).into_owned());
747+
}
748+
749+
let mut out = ConflictEntries {
750+
ancestor_entries,
751+
our_entries,
752+
their_entries,
753+
};
754+
755+
if !out.has_entries() {
756+
fn push_unique(v: &mut Vec<PathBuf>, change: &gix::diff::tree_with_rewrites::Change) {
757+
let path = gix::path::from_bstr(change.location()).into_owned();
758+
if !v.contains(&path) {
759+
v.push(path);
760+
}
761+
}
762+
763+
for conflict in merge_result
764+
.conflicts
765+
.iter()
766+
.filter(|c| c.is_unresolved(treat_as_unresolved))
767+
{
768+
let (ours, theirs) = conflict.changes_in_resolution();
769+
push_unique(&mut out.our_entries, ours);
770+
push_unique(&mut out.their_entries, theirs);
771+
}
772+
}
773+
774+
assert_eq!(
775+
out.has_entries(),
776+
merge_result.has_unresolved_conflicts(treat_as_unresolved),
777+
"Must have entries to indicate conflicting files, or bad things will happen later: {:#?}",
778+
merge_result.conflicts
779+
);
780+
781+
Ok(out)
782+
}
783+
701784
mod conflict;
702785
pub use conflict::{
703786
add_conflict_markers, is_conflicted, message_is_conflicted,
704787
rewrite_conflict_markers_on_message_change, strip_conflict_markers,
705788
};
789+
790+
/// Write a GitButler conflicted tree that wraps `resolved_tree_id` together
791+
/// with the conflict side trees and conflict file list.
792+
///
793+
/// - `repo` - is the repository that owns all tree objects involved and receives
794+
/// the newly written conflicted tree.
795+
///
796+
/// - `resolved_tree_id` - is the visible auto-resolved tree that callers want to
797+
/// present as the conflicted commit's main tree.
798+
///
799+
/// - `base_tree_id` - is the merge-base tree used for the conflicted merge step.
800+
///
801+
/// - `ours_tree_id` - is the accumulated tree on the "ours" side of the conflict.
802+
///
803+
/// - `theirs_tree_id` - is the incoming tree on the "theirs" side of the conflict.
804+
///
805+
/// - `conflict_entries` - lists the paths that should be recorded as conflicted in
806+
/// the synthetic GitButler tree metadata.
807+
pub fn write_conflicted_tree(
808+
repo: &gix::Repository,
809+
resolved_tree_id: gix::ObjectId,
810+
base_tree_id: gix::ObjectId,
811+
ours_tree_id: gix::ObjectId,
812+
theirs_tree_id: gix::ObjectId,
813+
conflict_entries: &ConflictEntries,
814+
) -> anyhow::Result<gix::ObjectId> {
815+
let conflicted_files_string = toml::to_string(conflict_entries)?;
816+
let conflicted_files_blob = repo.write_blob(conflicted_files_string.as_bytes())?;
817+
818+
let mut tree = repo.find_tree(resolved_tree_id)?.edit()?;
819+
tree.upsert(
820+
TreeKind::Ours.as_tree_entry_name(),
821+
EntryKind::Tree,
822+
ours_tree_id,
823+
)?;
824+
tree.upsert(
825+
TreeKind::Theirs.as_tree_entry_name(),
826+
EntryKind::Tree,
827+
theirs_tree_id,
828+
)?;
829+
tree.upsert(
830+
TreeKind::Base.as_tree_entry_name(),
831+
EntryKind::Tree,
832+
base_tree_id,
833+
)?;
834+
tree.upsert(
835+
TreeKind::AutoResolution.as_tree_entry_name(),
836+
EntryKind::Tree,
837+
resolved_tree_id,
838+
)?;
839+
tree.upsert(
840+
TreeKind::ConflictFiles.as_tree_entry_name(),
841+
EntryKind::Blob,
842+
conflicted_files_blob,
843+
)?;
844+
tree.write()
845+
.context("failed to write conflicted tree")
846+
.map(|tree_id| tree_id.detach())
847+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Merge Commit Changes
2+
3+
This module contains the editor helpers for combining selected commit changes
4+
into a single tree and for planning those changes as mergeable `base..commit`
5+
ranges.
6+
7+
## `merge_commit_changes_to_tree`
8+
9+
`Editor::merge_commit_changes_to_tree(target, subjects, merge_options)` builds a
10+
tree by:
11+
12+
1. using the target commit's full visible tree as the baseline,
13+
2. asking the planner to normalize the selected subject commits into mergeable
14+
change ranges,
15+
3. merging those planned `base_tree_id -> commit^{tree}` ranges into the
16+
accumulated tree in planner-defined order, and
17+
4. stopping at the first unresolved merge conflict while returning the
18+
auto-resolved tree plus conflict metadata.
19+
20+
The target contributes its whole tree. Subject commits contribute only their
21+
selected change ranges.
22+
23+
## `plan_commit_changes_for_merge`
24+
25+
`Editor::plan_commit_changes_for_merge(target, subjects)` turns a set of
26+
selected subject commits into emitted `PlannedCommitChange` entries.
27+
28+
The planner uses a single traversal of the editor graph, rooted from the same
29+
child-most entrypoints used by commit parentage ordering. During that traversal
30+
it derives:
31+
32+
- canonical subject order from editor parentage,
33+
- selected first-parent relationships for contiguous-chain collapse, and
34+
- the target ancestry cone for pruning.
35+
36+
The planner then emits only non-pruned selected-chain tips.
37+
38+
Even though traversal and pruning are driven by the editor step graph, the
39+
planner still takes first-parent and tree semantics from the commit objects in
40+
the editor's in-memory repository. In other words:
41+
42+
- the in-memory repository is the source of truth for commit parents, trees,
43+
and SHAs,
44+
- the step graph is used only to walk the selected region deterministically and
45+
to discover the target ancestry cone quickly, and
46+
- callers are expected to keep those two views aligned before using these
47+
helpers.
48+
49+
## Design Decisions
50+
51+
- The planner is the sole source of truth for subject ordering. Call-sites
52+
should pass raw selected subject IDs and not pre-order them.
53+
- Subject ordering is canonicalized from the editor graph, not from caller
54+
input order.
55+
- Duplicate subject IDs are deduplicated by commit ID only.
56+
- Pruning uses full target ancestry across any parent edge.
57+
- Collapse uses only selected first-parent chains.
58+
- Pruned commits are never emitted as merge picks.
59+
- A pruned selected first parent may still define the `base_tree_id` boundary
60+
for a surviving descendant, so a surviving tip can emit `B..C` even when `B`
61+
is pruned.
62+
- The editor step graph is expected to represent the same topology as the
63+
editor's in-memory repository. These helpers do not treat step-graph rewiring
64+
as a new source of commit parent truth.
65+
- The editor is assumed to already be normalized and up to date before calling
66+
these helpers. Callers chaining earlier editor mutations should normalize via
67+
`editor.rebase()?.into_editor()` first.

crates/but-rebase/src/cherry_pick.rs

Lines changed: 9 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,13 @@ pub enum EmptyCommit {
2222
UsePrevious,
2323
}
2424

25-
use std::path::PathBuf;
26-
2725
use anyhow::{Context as _, bail};
2826
use bstr::BString;
29-
use but_core::commit::{HEADERS_CONFLICTED_FIELD, Headers, SignCommit, TreeKind};
27+
use but_core::commit::{
28+
HEADERS_CONFLICTED_FIELD, Headers, SignCommit, TreeKind, conflict_entries_from_merge_outcome,
29+
};
3030
use but_error::bail_precondition;
3131
use gix::{object::tree::EntryKind, prelude::ObjectIdExt};
32-
use serde::Serialize;
3332

3433
use crate::commit::DateMode;
3534

@@ -197,8 +196,12 @@ fn commit_from_conflicted_tree<'repo>(
197196
) -> anyhow::Result<gix::Id<'repo>> {
198197
let repo = resolved_tree_id.repo;
199198

200-
let conflicted_files =
201-
extract_conflicted_files(resolved_tree_id, cherry_pick, treat_as_unresolved)?;
199+
let conflicted_files = conflict_entries_from_merge_outcome(
200+
repo,
201+
resolved_tree_id.detach(),
202+
&cherry_pick,
203+
treat_as_unresolved,
204+
)?;
202205

203206
// convert files into a string and save as a blob
204207
let conflicted_files_string = toml::to_string(&conflicted_files)?;
@@ -250,85 +253,3 @@ fn commit_from_conflicted_tree<'repo>(
250253
)?
251254
.attach(repo))
252255
}
253-
254-
fn extract_conflicted_files(
255-
merged_tree_id: gix::Id<'_>,
256-
merge_result: gix::merge::tree::Outcome<'_>,
257-
treat_as_unresolved: gix::merge::tree::TreatAsUnresolved,
258-
) -> anyhow::Result<ConflictEntries> {
259-
use gix::index::entry::Stage;
260-
let repo = merged_tree_id.repo;
261-
let mut index = repo.index_from_tree(&merged_tree_id)?;
262-
merge_result.index_changed_after_applying_conflicts(
263-
&mut index,
264-
treat_as_unresolved,
265-
gix::merge::tree::apply_index_entries::RemovalMode::Mark,
266-
);
267-
let (mut ancestor_entries, mut our_entries, mut their_entries) =
268-
(Vec::new(), Vec::new(), Vec::new());
269-
for entry in index.entries() {
270-
let stage = entry.stage();
271-
let storage = match stage {
272-
Stage::Unconflicted => {
273-
continue;
274-
}
275-
Stage::Base => &mut ancestor_entries,
276-
Stage::Ours => &mut our_entries,
277-
Stage::Theirs => &mut their_entries,
278-
};
279-
280-
let path = entry.path(&index);
281-
storage.push(gix::path::from_bstr(path).into_owned());
282-
}
283-
let mut out = ConflictEntries {
284-
ancestor_entries,
285-
our_entries,
286-
their_entries,
287-
};
288-
289-
// Since we typically auto-resolve with 'ours', it maybe that conflicting entries don't have an
290-
// unconflicting counterpart anymore, so they are not applied (which is also what Git does).
291-
// So to have something to show for - we *must* produce a conflict, extract paths manually.
292-
// TODO(ST): instead of doing this, don't pre-record the paths. Instead redo the merge without
293-
// merge-strategy so that the index entries can be used instead.
294-
if !out.has_entries() {
295-
fn push_unique(v: &mut Vec<PathBuf>, change: &gix::diff::tree_with_rewrites::Change) {
296-
let path = gix::path::from_bstr(change.location()).into_owned();
297-
if !v.contains(&path) {
298-
v.push(path);
299-
}
300-
}
301-
for conflict in merge_result
302-
.conflicts
303-
.iter()
304-
.filter(|c| c.is_unresolved(treat_as_unresolved))
305-
{
306-
let (ours, theirs) = conflict.changes_in_resolution();
307-
push_unique(&mut out.our_entries, ours);
308-
push_unique(&mut out.their_entries, theirs);
309-
}
310-
}
311-
assert_eq!(
312-
out.has_entries(),
313-
merge_result.has_unresolved_conflicts(treat_as_unresolved),
314-
"Must have entries to indicate conflicting files, or bad things will happen later: {:#?}",
315-
merge_result.conflicts
316-
);
317-
Ok(out)
318-
}
319-
320-
#[derive(Default, Debug, Clone, Serialize, PartialEq)]
321-
#[serde(rename_all = "camelCase")]
322-
pub(crate) struct ConflictEntries {
323-
pub(crate) ancestor_entries: Vec<PathBuf>,
324-
pub(crate) our_entries: Vec<PathBuf>,
325-
pub(crate) their_entries: Vec<PathBuf>,
326-
}
327-
328-
impl ConflictEntries {
329-
pub(crate) fn has_entries(&self) -> bool {
330-
!self.ancestor_entries.is_empty()
331-
|| !self.our_entries.is_empty()
332-
|| !self.their_entries.is_empty()
333-
}
334-
}

0 commit comments

Comments
 (0)