Skip to content

Commit c601902

Browse files
committed
feat: add gix merge-file with similar features as git merge-file
1 parent 44c77b6 commit c601902

File tree

6 files changed

+195
-2
lines changed

6 files changed

+195
-2
lines changed

gitoxide-core/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ serde = ["gix/serde", "dep:serde_json", "dep:serde", "bytesize/serde"]
4949

5050
[dependencies]
5151
# deselect everything else (like "performance") as this should be controllable by the parent application.
52-
gix = { version = "^0.66.0", path = "../gix", default-features = false, features = ["blob-diff", "revision", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status", "dirwalk"] }
52+
gix = { version = "^0.66.0", path = "../gix", default-features = false, features = ["blob-merge", "blob-diff", "revision", "mailmap", "excludes", "attributes", "worktree-mutation", "credentials", "interrupt", "status", "dirwalk"] }
5353
gix-pack-for-configuration-only = { package = "gix-pack", version = "^0.53.0", path = "../gix-pack", default-features = false, features = ["pack-cache-lru-dynamic", "pack-cache-lru-static", "generate", "streaming-input"] }
5454
gix-transport-configuration-only = { package = "gix-transport", version = "^0.42.3", path = "../gix-transport", default-features = false }
5555
gix-archive-for-configuration-only = { package = "gix-archive", version = "^0.15.0", path = "../gix-archive", optional = true, features = ["tar", "tar_gz"] }

gitoxide-core/src/repository/merge.rs

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use crate::OutputFormat;
2+
use anyhow::{bail, Context};
3+
use gix::bstr::BString;
4+
use gix::bstr::ByteSlice;
5+
use gix::merge::blob::builtin_driver::binary;
6+
use gix::merge::blob::builtin_driver::text::Conflict;
7+
use gix::merge::blob::pipeline::WorktreeRoots;
8+
use gix::merge::blob::{Resolution, ResourceKind};
9+
use gix::object::tree::EntryKind;
10+
use gix::Id;
11+
use std::path::Path;
12+
13+
pub fn file(
14+
repo: gix::Repository,
15+
out: &mut dyn std::io::Write,
16+
format: OutputFormat,
17+
conflict: Option<gix::merge::blob::builtin_driver::text::Conflict>,
18+
base: BString,
19+
ours: BString,
20+
theirs: BString,
21+
) -> anyhow::Result<()> {
22+
if format != OutputFormat::Human {
23+
bail!("JSON output isn't implemented yet");
24+
}
25+
let index = &repo.index_or_load_from_head()?;
26+
let specs = repo.pathspec(
27+
false,
28+
[base, ours, theirs],
29+
true,
30+
index,
31+
gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping.adjust_for_bare(repo.is_bare()),
32+
)?;
33+
// TODO: there should be a way to normalize paths without going through patterns, at least in this case maybe?
34+
// `Search` actually sorts patterns by excluding or not, all that can lead to strange results.
35+
let mut patterns = specs.search().patterns().map(|p| p.path().to_owned());
36+
let base = patterns.next().unwrap();
37+
let ours = patterns.next().unwrap();
38+
let theirs = patterns.next().unwrap();
39+
40+
let base_id = repo.rev_parse_single(base.as_bstr()).ok();
41+
let ours_id = repo.rev_parse_single(ours.as_bstr()).ok();
42+
let theirs_id = repo.rev_parse_single(theirs.as_bstr()).ok();
43+
let roots = worktree_roots(base_id, ours_id, theirs_id, repo.work_dir())?;
44+
45+
let mut cache = repo.merge_resource_cache(roots)?;
46+
let null = repo.object_hash().null();
47+
cache.set_resource(
48+
base_id.map_or(null, Id::detach),
49+
EntryKind::Blob,
50+
base.as_bstr(),
51+
ResourceKind::CommonAncestorOrBase,
52+
&repo.objects,
53+
)?;
54+
cache.set_resource(
55+
ours_id.map_or(null, Id::detach),
56+
EntryKind::Blob,
57+
ours.as_bstr(),
58+
ResourceKind::CurrentOrOurs,
59+
&repo.objects,
60+
)?;
61+
cache.set_resource(
62+
theirs_id.map_or(null, Id::detach),
63+
EntryKind::Blob,
64+
theirs.as_bstr(),
65+
ResourceKind::OtherOrTheirs,
66+
&repo.objects,
67+
)?;
68+
69+
let mut options = repo.blob_merge_options()?;
70+
if let Some(conflict) = conflict {
71+
options.text.conflict = conflict;
72+
options.resolve_binary_with = match conflict {
73+
Conflict::Keep { .. } => None,
74+
Conflict::ResolveWithOurs => Some(binary::ResolveWith::Ours),
75+
Conflict::ResolveWithTheirs => Some(binary::ResolveWith::Theirs),
76+
Conflict::ResolveWithUnion => None,
77+
};
78+
}
79+
let platform = cache.prepare_merge(&repo.objects, options)?;
80+
let labels = gix::merge::blob::builtin_driver::text::Labels {
81+
ancestor: Some(base.as_bstr()),
82+
current: Some(ours.as_bstr()),
83+
other: Some(theirs.as_bstr()),
84+
};
85+
let mut buf = repo.empty_reusable_buffer();
86+
let (pick, resolution) = platform.merge(&mut buf, labels, repo.command_context()?)?;
87+
let buf = platform.buffer_by_pick(pick).unwrap_or(&buf);
88+
out.write_all(buf)?;
89+
90+
if resolution == Resolution::Conflict {
91+
bail!("File conflicted")
92+
}
93+
Ok(())
94+
}
95+
96+
fn worktree_roots(
97+
base: Option<gix::Id<'_>>,
98+
ours: Option<gix::Id<'_>>,
99+
theirs: Option<gix::Id<'_>>,
100+
workdir: Option<&Path>,
101+
) -> anyhow::Result<gix::merge::blob::pipeline::WorktreeRoots> {
102+
let roots = if base.is_none() || ours.is_none() || theirs.is_none() {
103+
let workdir = workdir.context("A workdir is required if one of the bases are provided as path.")?;
104+
gix::merge::blob::pipeline::WorktreeRoots {
105+
current_root: ours.is_none().then(|| workdir.to_owned()),
106+
other_root: theirs.is_none().then(|| workdir.to_owned()),
107+
common_ancestor_root: base.is_none().then(|| workdir.to_owned()),
108+
}
109+
} else {
110+
WorktreeRoots::default()
111+
};
112+
Ok(roots)
113+
}

gitoxide-core/src/repository/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub mod index;
4646
pub mod mailmap;
4747
mod merge_base;
4848
pub use merge_base::merge_base;
49+
pub mod merge;
4950
pub mod odb;
5051
pub mod remote;
5152
pub mod revision;

gix/src/repository/merge.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ impl Repository {
6161
is_virtual_ancestor: false,
6262
resolve_binary_with: None,
6363
text: gix_merge::blob::builtin_driver::text::Options {
64-
diff_algorithm: self.config.diff_algorithm()?,
64+
diff_algorithm: self.diff_algorithm()?,
6565
conflict: text::Conflict::Keep {
6666
style: self
6767
.config

src/plumbing/main.rs

+37
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use gitoxide_core as core;
1414
use gitoxide_core::{pack::verify, repository::PathsOrPatterns};
1515
use gix::bstr::{io::BufReadExt, BString};
1616

17+
use crate::plumbing::options::merge;
1718
use crate::plumbing::{
1819
options::{
1920
attributes, commit, commitgraph, config, credential, exclude, free, fsck, index, mailmap, odb, revision, tree,
@@ -141,6 +142,42 @@ pub fn main() -> Result<()> {
141142
}
142143

143144
match cmd {
145+
Subcommands::Merge(merge::Platform { cmd }) => match cmd {
146+
merge::SubCommands::File {
147+
resolve_with,
148+
ours,
149+
base,
150+
theirs,
151+
} => prepare_and_run(
152+
"merge-file",
153+
trace,
154+
verbose,
155+
progress,
156+
progress_keep_open,
157+
None,
158+
move |_progress, out, _err| {
159+
core::repository::merge::file(
160+
repository(Mode::Lenient)?,
161+
out,
162+
format,
163+
resolve_with.map(|c| match c {
164+
merge::ResolveWith::Union => {
165+
gix::merge::blob::builtin_driver::text::Conflict::ResolveWithUnion
166+
}
167+
merge::ResolveWith::Ours => {
168+
gix::merge::blob::builtin_driver::text::Conflict::ResolveWithOurs
169+
}
170+
merge::ResolveWith::Theirs => {
171+
gix::merge::blob::builtin_driver::text::Conflict::ResolveWithTheirs
172+
}
173+
}),
174+
base,
175+
ours,
176+
theirs,
177+
)
178+
},
179+
),
180+
},
144181
Subcommands::MergeBase(crate::plumbing::options::merge_base::Command { first, others }) => prepare_and_run(
145182
"merge-base",
146183
trace,

src/plumbing/options/mod.rs

+42
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ pub enum Subcommands {
139139
#[cfg(feature = "gitoxide-core-tools-corpus")]
140140
Corpus(corpus::Platform),
141141
MergeBase(merge_base::Command),
142+
Merge(merge::Platform),
142143
Worktree(worktree::Platform),
143144
/// Subcommands that need no git repository to run.
144145
#[clap(subcommand)]
@@ -337,6 +338,47 @@ pub mod corpus {
337338
}
338339
}
339340

341+
pub mod merge {
342+
use gix::bstr::BString;
343+
344+
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
345+
pub enum ResolveWith {
346+
/// Use ours then theirs in case of conflict.
347+
Union,
348+
/// Use only ours in case of conflict.
349+
Ours,
350+
/// Use only theirs in case of conflict.
351+
Theirs,
352+
}
353+
354+
#[derive(Debug, clap::Parser)]
355+
#[command(about = "perform merges of various kinds")]
356+
pub struct Platform {
357+
#[clap(subcommand)]
358+
pub cmd: SubCommands,
359+
}
360+
361+
#[derive(Debug, clap::Subcommand)]
362+
pub enum SubCommands {
363+
/// Merge a file by specifying ours, base and theirs.
364+
File {
365+
/// Decide how to resolve conflicts. If unset, write conflict markers and fail.
366+
#[clap(long, short = 'c')]
367+
resolve_with: Option<ResolveWith>,
368+
369+
/// A path or revspec to our file
370+
#[clap(value_name = "OURS", value_parser = crate::shared::AsBString)]
371+
ours: BString,
372+
/// A path or revspec to the base for both ours and theirs
373+
#[clap(value_name = "BASE", value_parser = crate::shared::AsBString)]
374+
base: BString,
375+
/// A path or revspec to their file
376+
#[clap(value_name = "OURS", value_parser = crate::shared::AsBString)]
377+
theirs: BString,
378+
},
379+
}
380+
}
381+
340382
pub mod config {
341383
use gix::bstr::BString;
342384

0 commit comments

Comments
 (0)