Skip to main content

mkit_cli/commands/
fetch.rs

1//! `mkit fetch [<remote>]` — like `pull` but does NOT move HEAD.
2//! Downloads every object reachable from each remote ref and updates
3//! the `refs/remotes/<remote>/<name>` tracking refs.
4
5use std::collections::HashMap;
6use std::io::Write;
7use std::path::Path;
8
9use clap::{Parser, ValueEnum};
10use mkit_core::hash::Hash;
11use mkit_core::layout::RepoLayout;
12
13use crate::clap_shim;
14use crate::config;
15use crate::exit;
16use crate::format::{self, JsonObject};
17use crate::remote_dispatch;
18
19#[derive(Debug, Clone, Copy, ValueEnum)]
20enum FetchFormat {
21    Default,
22    Json,
23}
24
25#[derive(Debug, Parser)]
26#[command(
27    name = "mkit fetch",
28    about = "Download from the configured remote without merging."
29)]
30struct FetchOpts {
31    /// Named remote to fetch from (default: the flat default remote).
32    remote: Option<String>,
33    /// Skip Ed25519 signature verification on newly-fetched commits/
34    /// remixes/tags (issue #692). Verification is ON by default and fails
35    /// closed on an unsigned or invalid signature — this flag, or the
36    /// user-scoped `pull.require_signed = false` config, is the only way
37    /// to opt out. Not settable from repo-scoped config.
38    #[arg(long = "no-verify-signatures")]
39    no_verify_signatures: bool,
40    /// Fetch every configured remote (the flat default plus every
41    /// named `remote.<name>.url`) instead of just one. Mutually
42    /// exclusive with an explicit `<remote>` argument.
43    #[arg(long, conflicts_with = "remote")]
44    all: bool,
45    /// Emit a machine-readable JSON result object to stdout:
46    /// `{"ok":true,"remote":"...","endpoint":"...","updated":[{"name":"...",
47    /// "old":"<hex>|null","new":"<hex>"}]}`. With `--all`, one JSON object
48    /// is printed per remote fetched.
49    #[arg(long, value_enum, default_value = "default")]
50    format: FetchFormat,
51    /// Suppress transfer progress output on stderr (#711).
52    #[arg(short = 'q', long)]
53    quiet: bool,
54}
55
56#[must_use]
57pub fn run(args: &[String]) -> u8 {
58    let opts = match clap_shim::parse::<FetchOpts>("mkit fetch", args) {
59        Ok(o) => o,
60        Err(code) => return code,
61    };
62    let json = matches!(opts.format, FetchFormat::Json);
63    let cwd = match std::env::current_dir() {
64        Ok(p) => p,
65        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
66    };
67    let layout = match super::resolve_layout(&cwd) {
68        Ok(layout) => layout,
69        Err(code) => return code,
70    };
71    let cfg = match config::read_layered(&layout) {
72        Ok(c) => c,
73        Err(e) => return emit_err_json(&format!("config: {e}"), exit::CONFIG_ERROR, json),
74    };
75    // Fail closed by default (issue #692): verify unless `--no-verify-signatures`
76    // or the user-scoped `pull.require_signed = false` config opted out.
77    let require_signed = !opts.no_verify_signatures && cfg.merged.pull_require_signed_or_default();
78    if opts.all {
79        let names = config::configured_remote_names(&cfg);
80        if names.is_empty() {
81            return emit_err_json(
82                "no remote configured — use `mkit remote add <url>`",
83                exit::CONFIG_ERROR,
84                json,
85            );
86        }
87        // Fetch every remote in turn, continuing past a per-remote
88        // failure so one broken remote doesn't block the others; the
89        // worst exit code observed is returned at the end.
90        let mut worst = exit::OK;
91        for name in names {
92            let code = fetch_one(&cwd, &layout, &cfg, &name, require_signed, json, opts.quiet);
93            if code != exit::OK {
94                worst = code;
95            }
96        }
97        return worst;
98    }
99    fetch_one(
100        &cwd,
101        &layout,
102        &cfg,
103        opts.remote.as_deref().unwrap_or(""),
104        require_signed,
105        json,
106        opts.quiet,
107    )
108}
109
110/// Fetch a single named remote (or the flat default when `remote` is
111/// empty), snapshotting + reporting its tracking-ref movement. Shared
112/// by the single-remote path and the `--all` loop.
113fn fetch_one(
114    cwd: &Path,
115    layout: &RepoLayout,
116    cfg: &config::LayeredConfig,
117    remote: &str,
118    require_signed: bool,
119    json: bool,
120    quiet: bool,
121) -> u8 {
122    let Some(resolved) = config::resolve_remote(cfg, remote) else {
123        return emit_err_json(
124            &if remote.is_empty() {
125                "no remote configured — use `mkit remote add <url>`".to_owned()
126            } else {
127                format!("unknown remote '{remote}'")
128            },
129            exit::CONFIG_ERROR,
130            json,
131        );
132    };
133    let endpoint = resolved.endpoint.as_str();
134    // Snapshot the remote-tracking refs so we can report exactly which
135    // ones moved (git prints nothing when nothing changed).
136    let before = tracking_snapshot(layout, &resolved.name);
137    match remote_dispatch::open_trusted(endpoint, resolved.repo_chosen, cfg, layout) {
138        Ok(tx) => {
139            let fetch_outcome = {
140                // Scoped tightly so the progress guard's final line
141                // lands before the `From <url>` summary printed below.
142                let _progress = crate::progress::start(
143                    "Unpacking objects",
144                    None,
145                    crate::progress::should_report(quiet),
146                );
147                remote_dispatch::fetch_all_with(cwd, tx.as_ref(), &resolved.name, require_signed)
148            };
149            match fetch_outcome {
150                Ok(_) => {
151                    let after = tracking_snapshot(layout, &resolved.name);
152                    report_fetch(endpoint, &resolved.name, &before, &after);
153                    if json {
154                        emit_fetch_json(&resolved.name, endpoint, &before, &after);
155                    }
156                    exit::OK
157                }
158                Err(remote_dispatch::DispatchError::Interrupted) => {
159                    emit_err_json("fetch: interrupted", exit::TEMPFAIL, json)
160                }
161                Err(e @ remote_dispatch::DispatchError::UnsignedOrInvalidObject { .. }) => {
162                    emit_err_json(&format!("fetch: {e}"), exit::DATAERR, json)
163                }
164                Err(e) => emit_err_json(&format!("fetch: {e}"), exit::GENERAL_ERROR, json),
165            }
166        }
167        Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
168            emit_err_json(&msg, exit::CONFIG_ERROR, json)
169        }
170        Err(e) => emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
171    }
172}
173
174/// Emit the `--format=json` success payload: the same changed-tracking-ref
175/// set `report_fetch` prints as text, as a JSON array.
176fn emit_fetch_json(
177    remote: &str,
178    endpoint: &str,
179    before: &HashMap<String, Hash>,
180    after: &HashMap<String, Hash>,
181) {
182    let mut changed: Vec<(&String, Option<Hash>, Hash)> = after
183        .iter()
184        .filter(|(name, new)| before.get(*name) != Some(*new))
185        .map(|(name, new)| (name, before.get(name).copied(), *new))
186        .collect();
187    changed.sort_by(|a, b| a.0.cmp(b.0));
188    let entries: Vec<String> = changed
189        .iter()
190        .map(|(name, old, new)| {
191            let mut obj = JsonObject::new();
192            obj.field_str("name", name)
193                .field_opt_hash("old", old.as_ref())
194                .field_hash("new", new);
195            obj.finish()
196        })
197        .collect();
198    let mut top = JsonObject::new();
199    top.field_bool("ok", true)
200        .field_str("remote", remote)
201        .field_str("endpoint", endpoint)
202        .field_raw("updated", &format!("[{}]", entries.join(",")));
203    let mut stdout = std::io::stdout().lock();
204    let _ = writeln!(stdout, "{}", top.finish());
205}
206
207/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
208/// line on stdout.
209fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
210    if json {
211        let mut obj = JsonObject::new();
212        obj.field_bool("ok", false).field_str("error", msg);
213        let mut stdout = std::io::stdout().lock();
214        let _ = writeln!(stdout, "{}", obj.finish());
215    }
216    emit_err(msg, code)
217}
218
219/// Map of `refs/remotes/<remote>/<branch>` → tip, used to diff the
220/// tracking-ref state across a fetch.
221fn tracking_snapshot(layout: &RepoLayout, remote: &str) -> HashMap<String, Hash> {
222    mkit_core::refs::list_remote_refs(layout, remote)
223        .unwrap_or_default()
224        .into_iter()
225        .filter_map(|r| r.hash.map(|h| (r.name, h)))
226        .collect()
227}
228
229/// Print git's `From <url>` block with one summary line per moved
230/// tracking ref. Stays silent when nothing changed.
231fn report_fetch(
232    endpoint: &str,
233    remote: &str,
234    before: &HashMap<String, Hash>,
235    after: &HashMap<String, Hash>,
236) {
237    let mut changed: Vec<(&String, Option<Hash>, Hash)> = after
238        .iter()
239        .filter(|(name, new)| before.get(*name) != Some(*new))
240        .map(|(name, new)| (name, before.get(name).copied(), *new))
241        .collect();
242    if changed.is_empty() {
243        return;
244    }
245    changed.sort_by(|a, b| a.0.cmp(b.0));
246    let mut stderr = std::io::stderr().lock();
247    let _ = writeln!(stderr, "From {endpoint}");
248    for (name, old, new) in changed {
249        // Tracking-ref updates are rendered as fast-forwards; detecting a
250        // forced (`+ old...new`) tracking update would need per-ref
251        // ancestry checks against the store — deferred (cosmetic only).
252        let dst = format!("{remote}/{name}");
253        let _ = writeln!(
254            stderr,
255            "{}",
256            format::ref_update_line(old.as_ref(), &new, name, &dst, false)
257        );
258    }
259}
260
261use super::error as emit_err;