use std::fs;
use std::path::Path;
use anyhow::{Context, Result, anyhow, bail};
use mnem_core::HEADS_PREFIX;
use mnem_core::id::Cid;
use mnem_core::objects::RefTarget;
use mnem_core::repo::{MergeOutcome, MergeStrategy, conflict_category_counts, merge_three_way};
use crate::config;
use crate::repo;
const MERGE_HEAD_FILE: &str = "MERGE_HEAD";
const ORIG_HEAD_FILE: &str = "ORIG_HEAD";
const MERGE_CONFLICTS_FILE: &str = "MERGE_CONFLICTS.json";
#[derive(clap::Args, Debug)]
#[command(after_long_help = "\
Examples:
mnem merge feature # 3-way merge `refs/heads/feature` into current HEAD
mnem merge feature --strategy=ours # on conflict, pick left / current side
mnem merge feature --strategy=theirs # on conflict, pick right / incoming side
mnem merge feature --dry-run # preview outcome, persist nothing
mnem merge --continue # finish an in-progress merge after manual edits
mnem merge --abort # cancel an in-progress merge, restore pre-merge HEAD
")]
pub(crate) struct Args {
pub branch: Option<String>,
#[arg(long, value_enum, default_value_t = StrategyArg::Manual)]
pub strategy: StrategyArg,
#[arg(long)]
pub dry_run: bool,
#[arg(long = "continue", conflicts_with_all = ["abort", "branch"])]
pub continue_: bool,
#[arg(long, conflicts_with_all = ["continue_", "branch"])]
pub abort: bool,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub(crate) enum StrategyArg {
Manual,
Ours,
Theirs,
}
impl From<StrategyArg> for MergeStrategy {
fn from(value: StrategyArg) -> Self {
match value {
StrategyArg::Manual => Self::Manual,
StrategyArg::Ours => Self::Ours,
StrategyArg::Theirs => Self::Theirs,
}
}
}
pub(crate) fn run(override_path: Option<&Path>, args: Args) -> Result<()> {
let data_dir = repo::locate_data_dir(override_path)?;
if args.abort {
return run_abort(&data_dir, override_path);
}
if args.continue_ {
return run_continue(&data_dir, override_path);
}
let branch = args
.branch
.clone()
.ok_or_else(|| anyhow!("missing <branch> argument. Try `mnem merge feature`."))?;
let merge_head_path = data_dir.join(MERGE_HEAD_FILE);
if merge_head_path.exists() {
bail!(
"merge in progress: use `mnem merge --continue` after resolving \
`.mnem/{MERGE_CONFLICTS_FILE}`, or `mnem merge --abort` to cancel."
);
}
let cfg = config::load(&data_dir)?;
let (_dir, r, bs, ohs) = repo::open_all(override_path)?;
let left = r
.view()
.heads
.first()
.cloned()
.ok_or_else(|| anyhow!("repository has no commits yet; nothing to merge into"))?;
let right = resolve_commitish(&r, &branch)?;
if left == right {
println!("already up to date (left == right)");
return Ok(());
}
let strategy = MergeStrategy::from(args.strategy);
let outcome = merge_three_way(&bs, &ohs, left.clone(), right.clone(), strategy)
.context("3-way merge pipeline")?;
if args.dry_run {
preview(&outcome, &branch);
return Ok(());
}
match outcome {
MergeOutcome::FastForward(target) => {
let author = config::author_string(&cfg);
match current_branch_name(&r) {
Some(name) => {
advance_ref(&r, &cfg, &name, target.clone())?;
println!("fast-forward: advanced {name} -> {target}");
}
None => {
r.update_heads(target.clone(), &author)
.context("advancing detached HEAD")?;
println!("fast-forward: HEAD -> {target}");
}
}
}
MergeOutcome::Clean(merge_cid) => {
let author = config::author_string(&cfg);
match current_branch_name(&r) {
Some(name) => {
advance_ref(&r, &cfg, &name, merge_cid.clone())?;
println!("merge: advanced {name} -> {merge_cid}");
}
None => {
r.update_heads(merge_cid.clone(), &author)
.context("advancing detached HEAD")?;
println!("merge: HEAD -> {merge_cid}");
}
}
}
MergeOutcome::Conflicts(mc) => {
fs::write(data_dir.join(ORIG_HEAD_FILE), left.to_string())
.with_context(|| format!("writing {ORIG_HEAD_FILE}"))?;
fs::write(data_dir.join(MERGE_HEAD_FILE), right.to_string())
.with_context(|| format!("writing {MERGE_HEAD_FILE}"))?;
let json =
serde_json::to_string_pretty(&mc).context("serialising MergeConflicts to JSON")?;
fs::write(data_dir.join(MERGE_CONFLICTS_FILE), json)
.with_context(|| format!("writing {MERGE_CONFLICTS_FILE}"))?;
let (n_node, n_edge, n_tvm) = conflict_category_counts(&mc);
println!(
"merge produced {} conflict(s): {n_node} node-cid, {n_edge} edge-prop, {n_tvm} tombstone-vs-modify",
mc.conflicts.len(),
);
println!(
"resolve by editing `.mnem/{MERGE_CONFLICTS_FILE}`, then run \
`mnem merge --continue` (or `mnem merge --abort` to cancel)."
);
}
}
Ok(())
}
fn resolve_commitish(r: &mnem_core::repo::ReadonlyRepo, s: &str) -> Result<Cid> {
if let Ok(cid) = Cid::parse_str(s) {
return Ok(cid);
}
let refs = &r.view().refs;
let candidate = if refs.contains_key(s) {
s.to_string()
} else {
format!("{HEADS_PREFIX}{s}")
};
match refs.get(&candidate) {
Some(RefTarget::Normal { target }) => Ok(target.clone()),
Some(RefTarget::Conflicted { .. }) => {
bail!("ref `{candidate}` is conflicted; resolve the ref before merging")
}
None => bail!(
"cannot resolve `{s}` to a commit. Tried raw CID, `{s}`, and \
`{HEADS_PREFIX}{s}`."
),
}
}
fn current_branch_name(r: &mnem_core::repo::ReadonlyRepo) -> Option<String> {
if let Some(name) = r.view().active_branch() {
return Some(name.to_string());
}
let head = r.view().heads.first()?;
for (name, target) in &r.view().refs {
if let RefTarget::Normal { target: t } = target
&& t == head
&& name.starts_with(HEADS_PREFIX)
{
return Some(name.clone());
}
}
None
}
fn advance_ref(
r: &mnem_core::repo::ReadonlyRepo,
cfg: &config::Config,
ref_name: &str,
target: Cid,
) -> Result<()> {
let prev = r.view().refs.get(ref_name).cloned();
let new = RefTarget::normal(target.clone());
let author = config::author_string(cfg);
let r2 = r
.update_ref(ref_name, prev.as_ref(), Some(new), &author)
.context("advancing ref")?;
let _ = r2
.update_heads(target, &author)
.context("advancing HEAD after ref update")?;
Ok(())
}
fn run_abort(data_dir: &Path, _override_path: Option<&Path>) -> Result<()> {
let mh = data_dir.join(MERGE_HEAD_FILE);
let oh = data_dir.join(ORIG_HEAD_FILE);
let mc = data_dir.join(MERGE_CONFLICTS_FILE);
if !mh.exists() {
bail!("no merge in progress (no `.mnem/{MERGE_HEAD_FILE}`)");
}
let _ = fs::remove_file(&mh);
let _ = fs::remove_file(&oh);
let _ = fs::remove_file(&mc);
println!("merge aborted");
Ok(())
}
fn run_continue(data_dir: &Path, override_path: Option<&Path>) -> Result<()> {
let mh = data_dir.join(MERGE_HEAD_FILE);
let oh = data_dir.join(ORIG_HEAD_FILE);
let mc_path = data_dir.join(MERGE_CONFLICTS_FILE);
if !mh.exists() {
bail!(
"no merge in progress. Start one with `mnem merge <branch>` \
or re-run the command after resolving the conflict file."
);
}
if !mc_path.exists() {
bail!(
"`.mnem/{MERGE_CONFLICTS_FILE}` not found. \
Re-run `mnem merge <branch>` to regenerate it, or \
run `mnem merge --abort` to cancel."
);
}
let mc_json = fs::read_to_string(&mc_path)
.with_context(|| format!("reading .mnem/{MERGE_CONFLICTS_FILE}"))?;
let mc: mnem_core::repo::MergeConflicts =
serde_json::from_str(&mc_json).with_context(|| {
format!(
"parsing .mnem/{MERGE_CONFLICTS_FILE}. \
Make sure the file is valid JSON."
)
})?;
let mut unresolved_indices: Vec<usize> = Vec::new();
for (i, c) in mc.conflicts.iter().enumerate() {
match c.resolution.as_deref() {
Some("ours" | "theirs") => {}
Some(other) => bail!(
"conflict #{} has unrecognised resolution {:?}. \
Set `\"resolution\"` to `\"ours\"` or `\"theirs\"` \
in `.mnem/{MERGE_CONFLICTS_FILE}`.",
i + 1,
other
),
None => unresolved_indices.push(i + 1),
}
}
if !unresolved_indices.is_empty() {
bail!(
"{} conflict(s) still lack a `\"resolution\"` field (entries: {}). \
Open `.mnem/{MERGE_CONFLICTS_FILE}`, add \
`\"resolution\": \"ours\"` or `\"resolution\": \"theirs\"` \
to each conflict entry, then re-run `mnem merge --continue`.",
unresolved_indices.len(),
unresolved_indices
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
);
}
let strategy = if mc.conflicts.is_empty() {
MergeStrategy::Ours
} else {
let all_ours = mc
.conflicts
.iter()
.all(|c| c.resolution.as_deref() == Some("ours"));
let all_theirs = mc
.conflicts
.iter()
.all(|c| c.resolution.as_deref() == Some("theirs"));
if all_theirs {
MergeStrategy::Theirs
} else if all_ours {
MergeStrategy::Ours
} else {
let ours_count = mc
.conflicts
.iter()
.filter(|c| c.resolution.as_deref() == Some("ours"))
.count();
let theirs_count = mc
.conflicts
.iter()
.filter(|c| c.resolution.as_deref() == Some("theirs"))
.count();
bail!(
"mixed resolutions: {ours_count} conflict(s) set to \"ours\" and \
{theirs_count} set to \"theirs\". The current merge engine applies \
one strategy to all conflicts. Set every `\"resolution\"` to \
the same value (all `\"ours\"` or all `\"theirs\"`), then re-run \
`mnem merge --continue`."
);
}
};
let left = parse_cid_file(&oh)?;
let right = parse_cid_file(&mh)?;
let cfg = config::load(data_dir)?;
let (_dir, r, bs, ohs) = repo::open_all(override_path)?;
let outcome = merge_three_way(&bs, &ohs, left.clone(), right.clone(), strategy)
.context("3-way merge --continue")?;
match outcome {
MergeOutcome::Clean(cid) | MergeOutcome::FastForward(cid) => {
let author = config::author_string(&cfg);
match current_branch_name(&r) {
Some(name) => {
advance_ref(&r, &cfg, &name, cid.clone())?;
println!("merge continued: advanced {name} -> {cid}");
}
None => {
r.update_heads(cid.clone(), &author)
.context("advancing detached HEAD on --continue")?;
println!("merge continued: HEAD -> {cid}");
}
}
let _ = fs::remove_file(&mh);
let _ = fs::remove_file(&oh);
let _ = fs::remove_file(&mc_path);
}
MergeOutcome::Conflicts(mc) => {
let (n_node, n_edge, n_tvm) = conflict_category_counts(&mc);
let json =
serde_json::to_string_pretty(&mc).context("serialising MergeConflicts to JSON")?;
fs::write(&mc_path, json).with_context(|| format!("writing {MERGE_CONFLICTS_FILE}"))?;
bail!(
"merge --continue still has {} unresolved conflict(s) \
({n_node} node-cid, {n_edge} edge-prop, {n_tvm} tombstone-vs-modify). \
Conflict file updated - re-edit `.mnem/{MERGE_CONFLICTS_FILE}` and run \
`mnem merge --continue` again, or run `mnem merge --abort`.",
mc.conflicts.len(),
);
}
}
Ok(())
}
fn parse_cid_file(p: &Path) -> Result<Cid> {
let s = fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?;
let trimmed = s.trim();
Cid::parse_str(trimmed)
.with_context(|| format!("parsing CID in {}", p.display()))
.map_err(|e| anyhow!("{e}"))
}
fn preview(outcome: &MergeOutcome, branch: &str) {
match outcome {
MergeOutcome::FastForward(cid) => {
println!("[dry-run] would fast-forward to {cid} (from branch `{branch}`)");
}
MergeOutcome::Clean(cid) => {
println!("[dry-run] would create clean merge commit {cid} (with branch `{branch}`)");
}
MergeOutcome::Conflicts(mc) => {
let (n_node, n_edge, n_tvm) = conflict_category_counts(mc);
println!(
"[dry-run] would produce {} conflict(s): {n_node} node-cid, {n_edge} edge-prop, {n_tvm} tombstone-vs-modify",
mc.conflicts.len(),
);
for c in mc.conflicts.iter().take(5) {
let id = c
.node_id
.map(|n| n.to_uuid_string())
.or_else(|| {
c.edge_key.as_ref().map(|k| {
format!(
"{}-[{}]->{}",
k.src.to_uuid_string(),
k.etype,
k.dst.to_uuid_string()
)
})
})
.unwrap_or_else(|| "<?>".into());
println!(" - {:?} {id}", c.category);
}
if mc.conflicts.len() > 5 {
println!(" ... {} more", mc.conflicts.len() - 5);
}
}
}
}