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}