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.repo_chosen, cfg, layout) {
143        Ok(tx) => {
144            let pull_outcome = {
145                // Scoped tightly so the progress guard's final line
146                // lands before the `Updating <a>..<b>` / diffstat
147                // summary printed below, not after it.
148                let _progress = crate::progress::start(
149                    "Unpacking objects",
150                    None,
151                    crate::progress::should_report(quiet),
152                );
153                remote_dispatch::pull_all_with(
154                    cwd,
155                    tx.as_ref(),
156                    &resolved.name,
157                    None,
158                    require_signed,
159                )
160            };
161            match pull_outcome {
162                Ok(_) => {
163                    let new_tip = branch
164                        .as_deref()
165                        .and_then(|b| mkit_core::refs::read_ref(layout, b).ok().flatten());
166                    report_pull(layout, endpoint, old_tip, new_tip);
167                    if json {
168                        let mut obj = JsonObject::new();
169                        obj.field_bool("ok", true)
170                            .field_str("remote", &resolved.name)
171                            .field_str("endpoint", endpoint)
172                            .field_opt_str("branch", branch.as_deref())
173                            .field_opt_hash("old", old_tip.as_ref())
174                            .field_opt_hash("new", new_tip.as_ref())
175                            .field_bool("up_to_date", old_tip == new_tip);
176                        let mut stdout = std::io::stdout().lock();
177                        let _ = writeln!(stdout, "{}", obj.finish());
178                    }
179                    exit::OK
180                }
181                Err(remote_dispatch::DispatchError::Interrupted) => {
182                    emit_err_json("pull: interrupted", exit::TEMPFAIL, json)
183                }
184                Err(e @ remote_dispatch::DispatchError::UnsignedOrInvalidObject { .. }) => {
185                    emit_err_json(&format!("pull: {e}"), exit::DATAERR, json)
186                }
187                Err(e) => emit_err_json(&format!("pull: {e}"), exit::GENERAL_ERROR, json),
188            }
189        }
190        Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
191            emit_err_json(&msg, exit::CONFIG_ERROR, json)
192        }
193        Err(e) => emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
194    }
195}
196
197/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
198/// line on stdout.
199fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
200    if json {
201        let mut obj = JsonObject::new();
202        obj.field_bool("ok", false).field_str("error", msg);
203        let mut stdout = std::io::stdout().lock();
204        let _ = writeln!(stdout, "{}", obj.finish());
205    }
206    emit_err(msg, code)
207}
208
209/// Render git's post-pull summary on stderr: `Already up to date.` for a
210/// no-op, else `From <url>` + `Updating <old>..<new>` + `Fast-forward` +
211/// the diffstat. The diffstat is best-effort — a failure to compute it
212/// still leaves the headline lines intact.
213fn report_pull(layout: &RepoLayout, endpoint: &str, old: Option<Hash>, new: Option<Hash>) {
214    let mut stderr = std::io::stderr().lock();
215    match (old, new) {
216        (o, n) if o == n => {
217            let _ = writeln!(stderr, "Already up to date.");
218        }
219        (Some(o), Some(n)) => {
220            let _ = writeln!(stderr, "From {endpoint}");
221            let _ = writeln!(
222                stderr,
223                "Updating {}..{}",
224                format::short_hash(&o, format::SUMMARY_ABBREV),
225                format::short_hash(&n, format::SUMMARY_ABBREV),
226            );
227            let _ = writeln!(stderr, "Fast-forward");
228            drop(stderr);
229            print_ff_stat(layout, o, n);
230        }
231        _ => {
232            // First-ever pull populating an empty branch: objects, HEAD,
233            // and worktree are already updated by `pull_all`; stay quiet
234            // rather than print a misleading `Updating <none>..` line.
235        }
236    }
237}
238
239/// Best-effort `Fast-forward` diffstat between two commits' trees,
240/// reusing `diff`'s renderer.
241fn print_ff_stat(layout: &RepoLayout, old: Hash, new: Hash) {
242    let Ok(store) = crate::commands::open_store_configured(layout) else {
243        return;
244    };
245    let (Some(old_tree), Some(new_tree)) = (tree_of(&store, old), tree_of(&store, new)) else {
246        return;
247    };
248    if let Ok(result) = mkit_core::ops::diff_trees(&store, Some(old_tree), Some(new_tree)) {
249        let mut stderr = std::io::stderr().lock();
250        // `render_stat` hoists its own `DisplaySource` wrapping (#625).
251        let _ = super::diff::render_stat(&mut stderr, &store, result.entries.iter());
252    }
253}
254
255fn tree_of(store: &mkit_core::store::ObjectStore, commit: Hash) -> Option<Hash> {
256    match store.read_object(&commit).ok()? {
257        Object::Commit(c) => Some(c.tree_hash),
258        Object::Remix(r) => Some(r.tree_hash),
259        _ => None,
260    }
261}
262
263use super::error as emit_err;