Skip to main content

mkit_cli/commands/
pull.rs

1//! `mkit pull [<remote>]` — fetch refs from the configured remote
2//! (named, or the flat default) and fast-forward the current branch.
3
4use std::io::Write;
5use std::path::Path;
6
7use clap::{Parser, ValueEnum};
8use mkit_core::hash::Hash;
9use mkit_core::layout::RepoLayout;
10use mkit_core::object::Object;
11
12use crate::clap_shim;
13use crate::config;
14use crate::exit;
15use crate::format::{self, JsonObject};
16use crate::remote_dispatch;
17
18#[derive(Debug, Clone, Copy, ValueEnum)]
19enum PullFormat {
20    Default,
21    Json,
22}
23
24#[derive(Debug, Parser)]
25#[command(name = "mkit pull", about = "Pull changes from the configured remote.")]
26struct PullOpts {
27    /// Named remote to pull from (default: the flat default remote).
28    remote: Option<String>,
29    /// Skip Ed25519 signature verification on newly-fetched commits/
30    /// remixes/tags (issue #692). Verification is ON by default and fails
31    /// closed on an unsigned or invalid signature — this flag, or the
32    /// user-scoped `pull.require_signed = false` config, is the only way
33    /// to opt out. Not settable from repo-scoped config.
34    #[arg(long = "no-verify-signatures")]
35    no_verify_signatures: bool,
36    /// Pull from every configured remote (the flat default plus every
37    /// named `remote.<name>.url`) instead of just one, fast-forwarding
38    /// the current branch from each in turn. Mutually exclusive with an
39    /// explicit `<remote>` argument.
40    #[arg(long, conflicts_with = "remote")]
41    all: bool,
42    /// Emit a machine-readable JSON result object to stdout:
43    /// `{"ok":true,"remote":"...","endpoint":"...","branch":"...",
44    /// "old":"<hex>|null","new":"<hex>|null","up_to_date":<bool>}`. With
45    /// `--all`, one JSON object is printed per remote pulled.
46    #[arg(long, value_enum, default_value = "default")]
47    format: PullFormat,
48    /// Suppress transfer progress output on stderr (#711).
49    #[arg(short = 'q', long)]
50    quiet: bool,
51}
52
53#[must_use]
54pub fn run(args: &[String]) -> u8 {
55    let opts = match clap_shim::parse::<PullOpts>("mkit pull", args) {
56        Ok(o) => o,
57        Err(code) => return code,
58    };
59    let json = matches!(opts.format, PullFormat::Json);
60    let cwd = match std::env::current_dir() {
61        Ok(p) => p,
62        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
63    };
64    let layout = match super::resolve_layout(&cwd) {
65        Ok(layout) => layout,
66        Err(code) => return code,
67    };
68    let cfg = match config::read_layered(&layout) {
69        Ok(c) => c,
70        Err(e) => return emit_err_json(&format!("config: {e}"), exit::CONFIG_ERROR, json),
71    };
72    // Fail closed by default (issue #692): verify unless `--no-verify-signatures`
73    // or the user-scoped `pull.require_signed = false` config opted out.
74    let require_signed = !opts.no_verify_signatures && cfg.merged.pull_require_signed_or_default();
75    if opts.all {
76        let names = config::configured_remote_names(&cfg);
77        if names.is_empty() {
78            return emit_err_json(
79                "no remote configured — use `mkit remote add <url>`",
80                exit::CONFIG_ERROR,
81                json,
82            );
83        }
84        // Pull from every remote in turn, continuing past a per-remote
85        // failure so one broken remote doesn't block the others; the
86        // worst exit code observed is returned at the end.
87        let mut worst = exit::OK;
88        for name in names {
89            let code = pull_one(&cwd, &layout, &cfg, &name, require_signed, json, opts.quiet);
90            if code != exit::OK {
91                worst = code;
92            }
93        }
94        return worst;
95    }
96    pull_one(
97        &cwd,
98        &layout,
99        &cfg,
100        opts.remote.as_deref().unwrap_or(""),
101        require_signed,
102        json,
103        opts.quiet,
104    )
105}
106
107/// Pull from a single named remote (or the flat default when `remote`
108/// is empty), fast-forwarding the current branch and reporting a
109/// git-style summary. Shared by the single-remote path and the `--all`
110/// loop.
111fn pull_one(
112    cwd: &Path,
113    layout: &RepoLayout,
114    cfg: &config::LayeredConfig,
115    remote: &str,
116    require_signed: bool,
117    json: bool,
118    quiet: bool,
119) -> u8 {
120    let Some(resolved) = config::resolve_remote(cfg, remote) else {
121        return emit_err_json(
122            &if remote.is_empty() {
123                "no remote configured — use `mkit remote add <url>`".to_owned()
124            } else {
125                format!("unknown remote '{remote}'")
126            },
127            exit::CONFIG_ERROR,
128            json,
129        );
130    };
131    let endpoint = resolved.endpoint.as_str();
132    // Snapshot the current branch tip so we can report a git-style
133    // `Updating <old>..<new>` / `Fast-forward` block (or `Already up to
134    // date.`) once the fast-forward completes.
135    let branch = match mkit_core::refs::read_head(layout) {
136        Ok(mkit_core::refs::Head::Branch(b)) => Some(b),
137        _ => None,
138    };
139    let old_tip = branch
140        .as_deref()
141        .and_then(|b| mkit_core::refs::read_ref(layout, b).ok().flatten());
142    match remote_dispatch::open_trusted(endpoint, &resolved.name, resolved.repo_chosen, cfg, layout)
143    {
144        Ok(tx) => {
145            let pull_outcome = {
146                // Scoped tightly so the progress guard's final line
147                // lands before the `Updating <a>..<b>` / diffstat
148                // summary printed below, not after it.
149                let _progress = crate::progress::start(
150                    "Unpacking objects",
151                    None,
152                    crate::progress::should_report(quiet),
153                    quiet,
154                );
155                remote_dispatch::pull_all_with(
156                    cwd,
157                    tx.as_ref(),
158                    &resolved.name,
159                    None,
160                    require_signed,
161                )
162            };
163            match pull_outcome {
164                Ok(_) => {
165                    let new_tip = branch
166                        .as_deref()
167                        .and_then(|b| mkit_core::refs::read_ref(layout, b).ok().flatten());
168                    report_pull(layout, endpoint, old_tip, new_tip);
169                    if json {
170                        let mut obj = JsonObject::new();
171                        obj.field_bool("ok", true)
172                            .field_str("remote", &resolved.name)
173                            .field_str("endpoint", endpoint)
174                            .field_opt_str("branch", branch.as_deref())
175                            .field_opt_hash("old", old_tip.as_ref())
176                            .field_opt_hash("new", new_tip.as_ref())
177                            .field_bool("up_to_date", old_tip == new_tip);
178                        let mut stdout = std::io::stdout().lock();
179                        let _ = writeln!(stdout, "{}", obj.finish());
180                    }
181                    exit::OK
182                }
183                Err(remote_dispatch::DispatchError::Interrupted) => {
184                    emit_err_json("pull: interrupted", exit::TEMPFAIL, json)
185                }
186                Err(e @ remote_dispatch::DispatchError::UnsignedOrInvalidObject { .. }) => {
187                    emit_err_json(&format!("pull: {e}"), exit::DATAERR, json)
188                }
189                Err(e) => emit_err_json(&format!("pull: {e}"), exit::GENERAL_ERROR, json),
190            }
191        }
192        Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
193            emit_err_json(&msg, exit::CONFIG_ERROR, json)
194        }
195        Err(e) => emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
196    }
197}
198
199/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
200/// line on stdout.
201fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
202    if json {
203        let mut obj = JsonObject::new();
204        obj.field_bool("ok", false).field_str("error", msg);
205        let mut stdout = std::io::stdout().lock();
206        let _ = writeln!(stdout, "{}", obj.finish());
207    }
208    emit_err(msg, code)
209}
210
211/// Render git's post-pull summary on stderr: `Already up to date.` for a
212/// no-op, else `From <url>` + `Updating <old>..<new>` + `Fast-forward` +
213/// the diffstat. The diffstat is best-effort — a failure to compute it
214/// still leaves the headline lines intact.
215fn report_pull(layout: &RepoLayout, endpoint: &str, old: Option<Hash>, new: Option<Hash>) {
216    let mut stderr = std::io::stderr().lock();
217    match (old, new) {
218        (o, n) if o == n => {
219            let _ = writeln!(stderr, "Already up to date.");
220        }
221        (Some(o), Some(n)) => {
222            let _ = writeln!(stderr, "From {endpoint}");
223            let _ = writeln!(
224                stderr,
225                "Updating {}..{}",
226                format::short_hash(&o, format::SUMMARY_ABBREV),
227                format::short_hash(&n, format::SUMMARY_ABBREV),
228            );
229            let _ = writeln!(stderr, "Fast-forward");
230            drop(stderr);
231            print_ff_stat(layout, o, n);
232        }
233        _ => {
234            // First-ever pull populating an empty branch: objects, HEAD,
235            // and worktree are already updated by `pull_all`; stay quiet
236            // rather than print a misleading `Updating <none>..` line.
237        }
238    }
239}
240
241/// Best-effort `Fast-forward` diffstat between two commits' trees,
242/// reusing `diff`'s renderer.
243fn print_ff_stat(layout: &RepoLayout, old: Hash, new: Hash) {
244    let Ok(store) = crate::commands::open_store_configured(layout) else {
245        return;
246    };
247    let (Some(old_tree), Some(new_tree)) = (tree_of(&store, old), tree_of(&store, new)) else {
248        return;
249    };
250    if let Ok(result) = mkit_core::ops::diff_trees(&store, Some(old_tree), Some(new_tree)) {
251        let mut stderr = std::io::stderr().lock();
252        // `render_stat` hoists its own `DisplaySource` wrapping (#625).
253        let _ = super::diff::render_stat(&mut stderr, &store, result.entries.iter());
254    }
255}
256
257fn tree_of(store: &mkit_core::store::ObjectStore, commit: Hash) -> Option<Hash> {
258    match store.read_object(&commit).ok()? {
259        Object::Commit(c) => Some(c.tree_hash),
260        Object::Remix(r) => Some(r.tree_hash),
261        _ => None,
262    }
263}
264
265use super::error as emit_err;