Skip to main content

memstead_cli/commands/
conflicts.rs

1//! `memstead conflicts` — the sanctioned door for git merge conflicts
2//! in folder mems (backlog-sweep plan 07, decision 20).
3//!
4//! A hand-committed folder mem lives in the user's own git repository,
5//! so an ordinary merge can write conflict markers into entity files —
6//! at which point the file refuses to load and every other repair
7//! route (git verbs, raw edits) is correctly blocked by the guards.
8//! `conflicts list` shows what is conflicted; `conflicts resolve`
9//! picks a side per entity through the engine — validated before it
10//! lands, committed as an attributed, note-carrying mutation.
11
12use clap::{Parser, Subcommand};
13
14use memstead_base::EntityId;
15use memstead_base::engine::conflicts::ConflictSide;
16use memstead_base::vcs::Actor;
17
18use crate::CliError;
19use crate::output::{ExitKind, print_json, print_markdown};
20use crate::setup::{CliContext, CliEngine};
21
22/// List and resolve git merge conflicts in folder-backed mems.
23#[derive(Parser, Debug)]
24pub struct Args {
25    #[command(subcommand)]
26    pub command: Command,
27}
28
29#[derive(Subcommand, Debug)]
30pub enum Command {
31    /// List every entity file carrying git merge-conflict markers.
32    /// Scope with `--mem`; unscoped sweeps every writable folder mem.
33    /// Naming a non-folder mem refuses `CONFLICT_RESOLVE_UNSUPPORTED_BACKEND`
34    /// — only folder mems live in a user git repo where merges can
35    /// conflict entity files.
36    List {
37        /// Restrict the sweep to one folder mem.
38        #[arg(long)]
39        mem: Option<String>,
40    },
41    /// Resolve one conflicted entity to the chosen side. The chosen
42    /// side is validated as an entity BEFORE anything is written — an
43    /// invalid side refuses with the validation error. Sides are the
44    /// standard two; there is no merged-content resolution: to merge,
45    /// resolve to the better base side, then edit the entity through
46    /// the normal mutation surface (`memstead update`), and say so in
47    /// the note (e.g. "base for a manual merge; discarded side: theirs").
48    /// A non-conflicted target refuses `NOT_CONFLICTED`.
49    Resolve {
50        /// Entity id (e.g. `specs--torn-entity`), as listed by
51        /// `conflicts list`.
52        id: String,
53        /// Which side to keep: `ours` or `theirs`.
54        #[arg(long, value_name = "ours|theirs")]
55        side: String,
56        /// Agent-authored provenance note (≤280 chars) — lands in the
57        /// resolution's commit and provenance record.
58        #[arg(long)]
59        note: Option<String>,
60    },
61}
62
63pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
64    match ctx.cli_engine()? {
65        #[cfg(feature = "mem-repo")]
66        CliEngine::MemRepo(engine) => dispatch(ctx, engine, args),
67        CliEngine::Filesystem(engine) => dispatch(ctx, engine, args),
68    }
69}
70
71fn dispatch(ctx: &CliContext, mut engine: memstead_base::Engine, args: Args) -> anyhow::Result<()> {
72    match args.command {
73        Command::List { mem } => {
74            let conflicts = engine
75                .list_merge_conflicts(mem.as_deref())
76                .map_err(CliError::from_engine_op)?;
77            if ctx.json {
78                print_json(&serde_json::json!({
79                    "count": conflicts.len(),
80                    "conflicts": conflicts,
81                }))?;
82            } else if conflicts.is_empty() {
83                print_markdown("# Merge conflicts\n\nNo conflicted entities.");
84            } else {
85                let mut body = format!("# Merge conflicts ({})\n", conflicts.len());
86                for c in &conflicts {
87                    body.push_str(&format!("\n- `{}` — {} ({})", c.id, c.file_path, c.mem));
88                }
89                body.push_str(
90                    "\n\nResolve each with: `memstead conflicts resolve <id> --side ours|theirs`",
91                );
92                print_markdown(&body);
93            }
94            Ok(())
95        }
96        Command::Resolve { id, side, note } => {
97            let side = ConflictSide::from_wire(&side).ok_or_else(|| {
98                CliError::new(
99                    ExitKind::Validation,
100                    "INVALID_INPUT",
101                    format!("unknown side: {side:?} — expected \"ours\" or \"theirs\""),
102                )
103            })?;
104            let id = EntityId::canonical(&id);
105            let outcome = engine
106                .resolve_merge_conflict(&id, side, Actor::Cli, None, note.as_deref())
107                .map_err(CliError::from_engine_op)?;
108            if ctx.json {
109                print_json(&serde_json::json!({
110                    "id": outcome.id.as_ref(),
111                    "side": outcome.side,
112                    "commit_sha": outcome.commit_sha,
113                }))?;
114            } else {
115                print_markdown(&format!(
116                    "# Resolved `{}`\n\n- Kept side: {}\n- The discarded side is gone from the \
117                     file; to fold parts of it back in, edit through `memstead update` and note \
118                     the manual merge.",
119                    outcome.id, outcome.side,
120                ));
121            }
122            Ok(())
123        }
124    }
125}