Skip to main content

memstead_cli/commands/
transport.rs

1//! `memstead fetch` / `memstead pull` / `memstead push` CLI subcommands. The
2//! three engine surfaces share refusal codes and an outcome shape;
3//! the CLI front-end is a thin print of each.
4
5use clap::Args;
6
7use crate::CliError;
8use crate::output::ExitKind;
9use crate::setup::{CliContext, CliEngine};
10
11/// `memstead fetch <mem> [--remote <name>] [<refspec>...]` arguments.
12#[derive(Args, Debug)]
13pub struct FetchArgs {
14    pub mem: String,
15    #[arg(long, default_value = "origin")]
16    pub remote: String,
17    /// Optional refspecs forwarded to the underlying `git fetch`.
18    /// Empty list uses the remote's configured defaults.
19    #[arg(num_args = 0..)]
20    pub refspecs: Vec<String>,
21}
22
23/// `memstead pull <mem> [--remote <name>]` arguments.
24#[derive(Args, Debug)]
25pub struct PullArgs {
26    pub mem: String,
27    #[arg(long, default_value = "origin")]
28    pub remote: String,
29}
30
31/// `memstead push <mem> [--remote <name>] [--force]` and
32/// `memstead push --all [--remote <name>]` arguments.
33#[derive(Args, Debug)]
34pub struct PushArgs {
35    /// Mem whose branch to push. Omitted with `--all`.
36    #[arg(required_unless_present = "all", conflicts_with = "all")]
37    pub mem: Option<String>,
38    #[arg(long, default_value = "origin")]
39    pub remote: String,
40    /// Force-push (`--force-with-lease` under the hood). Refused
41    /// non-fast-forward pushes only happen here. Use with care — the
42    /// remote's view of the branch is overwritten. Single-mem only:
43    /// `--all` is fast-forward only and does not take it.
44    #[arg(long, default_value_t = false, conflicts_with = "all")]
45    pub force: bool,
46    /// Push every mounted git-branch mem's branch plus the workspace's
47    /// schema-and-config ref, fast-forward only. Refs already at the
48    /// remote's SHA are skipped silently; one line per ref moved; a
49    /// ref that cannot fast-forward is refused by name
50    /// (`NON_FAST_FORWARD`) while the other refs still go, and the
51    /// run exits non-zero at the end. Folder and archive mounts have
52    /// no branch and are skipped.
53    #[arg(long, default_value_t = false)]
54    pub all: bool,
55}
56
57pub fn run_fetch(ctx: &CliContext, args: FetchArgs) -> anyhow::Result<()> {
58    let outcome = match ctx.cli_engine()? {
59        CliEngine::MemRepo(engine) => engine
60            .fetch(&args.mem, &args.remote, &args.refspecs)
61            .map_err(CliError::from_engine_op)?,
62        CliEngine::Filesystem(_) => return Err(folder_refusal("memstead fetch", &args.mem)),
63    };
64    if ctx.json {
65        crate::output::print_json(&outcome)?;
66    } else {
67        let updated = if outcome.updated_refs.is_empty() {
68            "  (no refs changed)".to_string()
69        } else {
70            outcome
71                .updated_refs
72                .iter()
73                .map(|u| {
74                    let prev = if u.previous_sha.is_empty() {
75                        "<new>".to_string()
76                    } else {
77                        u.previous_sha.clone()
78                    };
79                    format!("  - {} : {prev} -> {}", u.ref_name, u.new_sha)
80                })
81                .collect::<Vec<_>>()
82                .join("\n")
83        };
84        crate::output::print_markdown(&format!(
85            "# Fetched from `{}`\n\n- Refspecs: {}\n- Updated refs:\n{}",
86            outcome.remote,
87            if outcome.refspecs.is_empty() {
88                "<defaults>".to_string()
89            } else {
90                outcome.refspecs.join(", ")
91            },
92            updated,
93        ));
94    }
95    Ok(())
96}
97
98pub fn run_pull(ctx: &CliContext, args: PullArgs) -> anyhow::Result<()> {
99    let outcome = match ctx.cli_engine()? {
100        CliEngine::MemRepo(mut engine) => engine
101            .pull(&args.mem, &args.remote)
102            .map_err(CliError::from_engine_op)?,
103        CliEngine::Filesystem(_) => return Err(folder_refusal("memstead pull", &args.mem)),
104    };
105    if ctx.json {
106        crate::output::print_json(&outcome)?;
107    } else {
108        let prev = if outcome.previous_sha.is_empty() {
109            "<new branch>".to_string()
110        } else {
111            outcome.previous_sha.clone()
112        };
113        crate::output::print_markdown(&format!(
114            "# Pulled `{}`\n\n- Branch ref: `{}`\n- Source ref: `{}`\n- Previous: `{prev}`\n- New: `{}`",
115            outcome.mem, outcome.branch_ref, outcome.source_ref, outcome.new_sha,
116        ));
117    }
118    Ok(())
119}
120
121pub fn run_push(ctx: &CliContext, args: PushArgs) -> anyhow::Result<()> {
122    if args.all {
123        return run_push_all(ctx, &args.remote);
124    }
125    // clap guarantees `mem` when `--all` is absent.
126    let mem = args.mem.as_deref().unwrap_or_default();
127    let outcome = match ctx.cli_engine()? {
128        CliEngine::MemRepo(engine) => engine
129            .push(mem, &args.remote, args.force)
130            .map_err(CliError::from_engine_op)?,
131        CliEngine::Filesystem(_) => return Err(folder_refusal("memstead push", mem)),
132    };
133    if ctx.json {
134        crate::output::print_json(&outcome)?;
135    } else {
136        let force_note = if outcome.forced { " (forced)" } else { "" };
137        crate::output::print_markdown(&format!(
138            "# Pushed `{}` to `{}`{force_note}\n\n- Branch ref: `{}`\n- New SHA at remote: `{}`",
139            outcome.mem, outcome.remote, outcome.branch_ref, outcome.new_sha,
140        ));
141    }
142    Ok(())
143}
144
145/// `memstead push --all`: the human surface prints exactly one line
146/// per ref that moved and nothing else, so a run with nothing to
147/// push is silent and a hook can echo the output verbatim. `--json`
148/// prints the whole outcome. Any refused ref turns the exit into a
149/// typed refusal carrying the first refusal's code, with every
150/// refused and pushed ref under `details`.
151fn run_push_all(ctx: &CliContext, remote: &str) -> anyhow::Result<()> {
152    let outcome = match ctx.cli_engine()? {
153        CliEngine::MemRepo(engine) => engine.push_all(remote).map_err(CliError::from_engine_op)?,
154        CliEngine::Filesystem(_) => {
155            return Err(CliError {
156                code: "INVALID_INPUT",
157                kind: ExitKind::Validation,
158                message: "this workspace has no git-branch mems — `memstead push --all` \
159                          requires a mem-repo workspace"
160                    .to_string(),
161                details: None,
162            }
163            .into());
164        }
165    };
166    if ctx.json {
167        // With a refusal the error envelope below carries the whole
168        // outcome under `details`; printing it here too would put two
169        // JSON documents on stdout.
170        if outcome.refused.is_empty() {
171            crate::output::print_json(&outcome)?;
172        }
173    } else {
174        for p in &outcome.pushed {
175            let prev = if p.previous_sha.is_empty() {
176                "<new>".to_string()
177            } else {
178                p.previous_sha.clone()
179            };
180            println!("{} {prev} -> {}", p.ref_name, p.new_sha);
181        }
182    }
183    if let Some(first) = outcome.refused.first() {
184        let code: &'static str = match first.code.as_str() {
185            "NON_FAST_FORWARD" => "NON_FAST_FORWARD",
186            "LOCAL_INVALID_STATE" => "LOCAL_INVALID_STATE",
187            "UNKNOWN_REF" => "UNKNOWN_REF",
188            "UNKNOWN_REMOTE" => "UNKNOWN_REMOTE",
189            _ => "INTERNAL",
190        };
191        let listed = outcome
192            .refused
193            .iter()
194            .map(|r| {
195                format!(
196                    "{} ({}{})",
197                    r.ref_name,
198                    r.code,
199                    r.mem
200                        .as_deref()
201                        .map(|m| format!(", mem `{m}`"))
202                        .unwrap_or_default()
203                )
204            })
205            .collect::<Vec<_>>()
206            .join(", ");
207        return Err(CliError {
208            code,
209            kind: ExitKind::Validation,
210            message: format!(
211                "memstead push --all: {} ref(s) refused, {} pushed, {} already in sync — refused: {listed}. \
212                 A NON_FAST_FORWARD ref has commits on the remote this clone lacks: \
213                 `memstead fetch <mem>` then `memstead pull <mem>` for that mem, then run `memstead push --all` again.",
214                outcome.refused.len(),
215                outcome.pushed.len(),
216                outcome.in_sync.len(),
217            ),
218            details: Some(serde_json::json!({
219                "remote": outcome.remote,
220                "refused": outcome.refused,
221                "pushed": outcome.pushed,
222                "in_sync": outcome.in_sync,
223            })),
224        }
225        .into());
226    }
227    Ok(())
228}
229
230fn folder_refusal(op: &str, mem: &str) -> anyhow::Error {
231    CliError {
232        code: "INVALID_INPUT",
233        kind: ExitKind::Validation,
234        message: format!("mem '{mem}' is not git-backed — `{op}` requires a git-branch mount",),
235        details: None,
236    }
237    .into()
238}