Skip to main content

mkit_cli/commands/
remote.rs

1//! `mkit remote` — show / add / set the configured remote.
2//!
3//! URL validation: only `mkit+<scheme>://` is accepted. Recognised
4//! schemes: `file`, `https`, `s3`, `ssh`, `memory`.
5
6use std::io::Write;
7
8use clap::{Parser, Subcommand, ValueEnum};
9
10use crate::clap_shim;
11use crate::config::{self, Config, RemoteEntry};
12use crate::exit;
13use crate::format;
14
15const ACCEPTED_SCHEMES: &[(&str, &str)] = &[
16    ("mkit+file://", "file"),
17    ("mkit+https://", "http"),
18    ("mkit+s3://", "s3"),
19    ("mkit+ssh://", "ssh"),
20    ("mkit+memory://", "memory"),
21    // Git-bridge remotes (SPEC-GIT-BRIDGE / SPEC-GIT-IMPORT). Native
22    // push/pull/fetch/clone REFUSE these with a pointer to the
23    // `mkit git` subcommands — the transports are not interchangeable.
24    ("git+https://", "git"),
25    ("git+ssh://", "git"),
26    ("git+file://", "git"),
27];
28
29#[derive(Debug, Clone, Copy, ValueEnum)]
30enum RemoteFormat {
31    Default,
32    Json,
33}
34
35#[derive(Debug, Parser)]
36#[command(name = "mkit remote", about = "Show or configure the remote.")]
37struct RemoteOpts {
38    /// Output format for the show form. JSON object with `--format=json`.
39    #[arg(long, value_enum, default_value = "default")]
40    format: RemoteFormat,
41    #[command(subcommand)]
42    sub: Option<RemoteCmd>,
43}
44
45#[derive(Debug, Subcommand)]
46enum RemoteCmd {
47    /// Configure a remote. With one argument, sets the flat default
48    /// remote (`mkit remote add <url>`). With two, adds/updates a named
49    /// remote (`mkit remote add <name> <url>`). The URL must be
50    /// `mkit+<scheme>://...`.
51    Add {
52        name_or_url: String,
53        url: Option<String>,
54    },
55    /// Alias for `add`.
56    Set {
57        name_or_url: String,
58        url: Option<String>,
59    },
60    /// Remove a named remote (`mkit remote remove <name>`). Use the
61    /// reserved name `default` to clear the flat default remote.
62    #[command(alias = "rm")]
63    Remove { name: String },
64    /// Rename a named remote (`mkit remote rename <old> <new>`). Also
65    /// rewrites any `branch.<b>.remote` upstream pointing at `<old>`.
66    #[command(alias = "mv")]
67    Rename { old: String, new: String },
68}
69
70#[must_use]
71#[allow(clippy::too_many_lines)] // flat dispatch over the remote subcommands
72pub fn run(args: &[String]) -> u8 {
73    let opts = match clap_shim::parse::<RemoteOpts>("mkit remote", args) {
74        Ok(o) => o,
75        Err(code) => return code,
76    };
77    let cwd = match std::env::current_dir() {
78        Ok(p) => p,
79        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
80    };
81    let layered = match config::read_layered(&cwd) {
82        Ok(c) => c,
83        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
84    };
85    // `show` reflects the merged view; every mutating subcommand operates
86    // on and persists ONLY the repo layer, so a user-scoped value (e.g. a
87    // private `user.email`) is never materialized into the clone-traveling
88    // `.mkit/config` by `config::write`.
89    if opts.sub.is_none() {
90        return show(&layered.merged, matches!(opts.format, RemoteFormat::Json));
91    }
92    let mut cfg = layered.repo;
93
94    match opts.sub {
95        None => unreachable!("handled above"),
96        Some(RemoteCmd::Add { name_or_url, url } | RemoteCmd::Set { name_or_url, url }) => {
97            // Two forms:
98            //   `mkit remote add <url>`         -> flat default remote
99            //   `mkit remote add <name> <url>`  -> named remote
100            let (name, url) = match url {
101                Some(url) => (Some(name_or_url), url),
102                None => (None, name_or_url),
103            };
104            // Reject control characters (newline et al.) before the URL
105            // ever reaches `config::write`, which emits values raw — a
106            // newline would inject extra `key = value` lines into
107            // `.mkit/config` (config injection).
108            if config::validate_value(&url).is_err() {
109                return emit_err(
110                    &format!("invalid remote URL '{url}': contains control characters"),
111                    exit::PROTOCOL_ERROR,
112                );
113            }
114            let Some(scheme) = validate_url(&url) else {
115                return emit_err(
116                    &format!(
117                        "invalid remote URL '{url}': must start with 'mkit+<scheme>://'\n\
118                         hint: URL must start with mkit+<scheme>:// (e.g. mkit+https://, mkit+ssh://, mkit+file://, mkit+s3://)",
119                    ),
120                    exit::PROTOCOL_ERROR,
121                );
122            };
123            if let Some(name) = name {
124                if let Err(code) = validate_remote_name(&name) {
125                    return code;
126                }
127                cfg.remotes.insert(
128                    name,
129                    RemoteEntry {
130                        url,
131                        remote_type: scheme.to_owned(),
132                    },
133                );
134            } else {
135                cfg.remote_endpoint = url;
136                scheme.clone_into(&mut cfg.remote_type);
137            }
138            match config::write(&cwd, &cfg) {
139                Ok(()) => exit::OK,
140                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
141            }
142        }
143        Some(RemoteCmd::Remove { name }) => {
144            // Removing a remote only touches the repo-scoped address
145            // book. The user-scoped `trusted_remote_endpoint` (#97) is
146            // keyed by exact URL, not by remote name, and is never
147            // serialised by `config::write`, so the credential-trust
148            // boundary is unaffected: a later remote reusing the same URL
149            // would still be trusted, and one with a new URL still
150            // requires an explicit `config trusted_remote_endpoint`.
151            if name == config::DEFAULT_REMOTE_NAME {
152                if cfg.remote_endpoint.is_empty() {
153                    return emit_err("no default remote configured", exit::GENERAL_ERROR);
154                }
155                cfg.remote_endpoint.clear();
156                cfg.remote_type.clear();
157                cfg.remote_bucket.clear();
158            } else if cfg.remotes.remove(&name).is_none() {
159                return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
160            }
161            match config::write(&cwd, &cfg) {
162                Ok(()) => {
163                    // Stale tracking refs would shadow a future remote
164                    // reusing the name; objects stay (gc owns them).
165                    remove_tracking_refs(&cwd, &name);
166                    warn_orphaned_bridge_state(&cwd, &name);
167                    exit::OK
168                }
169                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
170            }
171        }
172        Some(RemoteCmd::Rename { old, new }) => {
173            if old == config::DEFAULT_REMOTE_NAME || new == config::DEFAULT_REMOTE_NAME {
174                return emit_err(
175                    "cannot rename the reserved `default` remote; use `remote add`/`remote remove`",
176                    exit::PROTOCOL_ERROR,
177                );
178            }
179            if let Err(code) = validate_remote_name(&new) {
180                return code;
181            }
182            let Some(entry) = cfg.remotes.remove(&old) else {
183                return emit_err(&format!("remote '{old}' not found"), exit::GENERAL_ERROR);
184            };
185            if cfg.remotes.contains_key(&new) {
186                // Put the source back so a failed rename is a no-op.
187                cfg.remotes.insert(old, entry);
188                return emit_err(&format!("remote '{new}' already exists"), exit::CANTCREAT);
189            }
190            cfg.remotes.insert(new.clone(), entry);
191            // Repoint any branch upstreams that tracked the old name.
192            for up in cfg.branch_upstreams.values_mut() {
193                if up.remote == old {
194                    up.remote.clone_from(&new);
195                }
196            }
197            match config::write(&cwd, &cfg) {
198                Ok(()) => {
199                    move_tracking_refs(&cwd, &old, &new);
200                    move_bridge_state(&cwd, &old, &new);
201                    exit::OK
202                }
203                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
204            }
205        }
206    }
207}
208
209/// Best-effort move of `refs/remotes/<old>/` to `refs/remotes/<new>/`
210/// after a rename. Failure is reported but non-fatal: the config
211/// rename already happened, and a follow-up fetch repopulates.
212fn move_tracking_refs(cwd: &std::path::Path, old: &str, new: &str) {
213    let remotes = cwd
214        .join(mkit_core::MKIT_DIR)
215        .join(mkit_core::refs::REMOTES_DIR);
216    let (src, dst) = (remotes.join(old), remotes.join(new));
217    if src.is_dir()
218        && let Err(e) = std::fs::rename(&src, &dst)
219    {
220        let mut stderr = std::io::stderr().lock();
221        let _ = writeln!(
222            stderr,
223            "warning: could not move tracking refs {old} -> {new}: {e}; \
224             run `mkit fetch {new}` to repopulate"
225        );
226    }
227}
228
229/// Bridge state under `.mkit/git/<name>/` follows a rename so leases,
230/// maps, and the staging mirror stay bound to the same remote name.
231fn move_bridge_state(cwd: &std::path::Path, old: &str, new: &str) {
232    let base = cwd.join(mkit_core::MKIT_DIR).join("git");
233    let (src, dst) = (base.join(old), base.join(new));
234    if src.is_dir()
235        && let Err(e) = std::fs::rename(&src, &dst)
236    {
237        let mut stderr = std::io::stderr().lock();
238        let _ = writeln!(
239            stderr,
240            "warning: could not move git-bridge state {old} -> {new}: {e}"
241        );
242    }
243}
244
245/// Removing a remote leaves its bridge state in place (it holds the
246/// staging mirror + retained provenance, which are durable artifacts,
247/// not caches) — but say so.
248fn warn_orphaned_bridge_state(cwd: &std::path::Path, name: &str) {
249    let dir = cwd.join(mkit_core::MKIT_DIR).join("git").join(name);
250    if dir.is_dir() {
251        let mut stderr = std::io::stderr().lock();
252        let _ = writeln!(
253            stderr,
254            "note: git-bridge state for '{name}' remains at .mkit/git/{name}/ \
255             (staging mirror + provenance); delete it manually if unwanted"
256        );
257    }
258}
259
260/// Best-effort removal of `refs/remotes/<name>/` after a remove.
261fn remove_tracking_refs(cwd: &std::path::Path, name: &str) {
262    let dir = cwd
263        .join(mkit_core::MKIT_DIR)
264        .join(mkit_core::refs::REMOTES_DIR)
265        .join(name);
266    if dir.is_dir()
267        && let Err(e) = std::fs::remove_dir_all(&dir)
268    {
269        let mut stderr = std::io::stderr().lock();
270        let _ = writeln!(
271            stderr,
272            "warning: could not remove tracking refs for '{name}': {e}"
273        );
274    }
275}
276
277/// Validate a named-remote name: rejects control characters, non
278/// ref-safe names, dots (which would collide with the
279/// `remote.<name>.<field>` config key grammar), and the reserved
280/// `default` name. Returns the CLI exit code to propagate on failure.
281fn validate_remote_name(name: &str) -> Result<(), u8> {
282    if config::validate_value(name).is_err() {
283        return Err(emit_err(
284            &format!("invalid remote name '{name}': contains control characters"),
285            exit::PROTOCOL_ERROR,
286        ));
287    }
288    if !mkit_core::refs::validate_ref_name(name)
289        || name.contains('.')
290        || name == config::DEFAULT_REMOTE_NAME
291    {
292        return Err(emit_err(
293            &format!(
294                "invalid remote name '{name}': must be a dot-free ref-safe name \
295                 (and not the reserved `default`)"
296            ),
297            exit::PROTOCOL_ERROR,
298        ));
299    }
300    Ok(())
301}
302
303fn validate_url(url: &str) -> Option<&'static str> {
304    for (prefix, kind) in ACCEPTED_SCHEMES {
305        if url.starts_with(prefix) {
306            return Some(kind);
307        }
308    }
309    None
310}
311
312fn show(cfg: &Config, json: bool) -> u8 {
313    let has_default = !cfg.remote_endpoint.is_empty();
314    if !has_default && cfg.remotes.is_empty() {
315        // Empty listing → empty stdout in both modes. The default
316        // mode emits a human note on stderr.
317        if !json {
318            let mut stderr = std::io::stderr().lock();
319            let _ = writeln!(stderr, "(no remote configured)");
320        }
321        return exit::OK;
322    }
323    let mut stdout = std::io::stdout().lock();
324    if json {
325        // Additive shape: when only the default remote is configured,
326        // emit the historical single-line object so existing JSON
327        // snapshots stay valid. When named remotes exist, emit one JSON
328        // object per line (JSONL) carrying a `name` field; the default
329        // remote (if any) appears as `name=default`.
330        if has_default && cfg.remotes.is_empty() {
331            let _ = stdout.write_all(b"{");
332            let _ = write!(
333                stdout,
334                "\"url\":\"{}\"",
335                format::json_escape(&cfg.remote_endpoint)
336            );
337            let _ = write!(
338                stdout,
339                ",\"transport\":\"{}\"",
340                format::json_escape(&cfg.remote_type)
341            );
342            let _ = stdout.write_all(b"}\n");
343            return exit::OK;
344        }
345        if has_default {
346            let _ = writeln!(
347                stdout,
348                "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
349                config::DEFAULT_REMOTE_NAME,
350                format::json_escape(&cfg.remote_endpoint),
351                format::json_escape(&cfg.remote_type)
352            );
353        }
354        for (name, entry) in &cfg.remotes {
355            let _ = writeln!(
356                stdout,
357                "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
358                format::json_escape(name),
359                format::json_escape(&entry.url),
360                format::json_escape(&entry.remote_type)
361            );
362        }
363        return exit::OK;
364    }
365    // Default (human) form. Keep the legacy two-line output when only
366    // the flat default remote is configured.
367    if has_default && cfg.remotes.is_empty() {
368        let _ = writeln!(stdout, "remote_endpoint = {}", cfg.remote_endpoint);
369        let _ = writeln!(stdout, "remote_type = {}", cfg.remote_type);
370        return exit::OK;
371    }
372    if has_default {
373        let _ = writeln!(
374            stdout,
375            "{}\t{} ({})",
376            config::DEFAULT_REMOTE_NAME,
377            cfg.remote_endpoint,
378            cfg.remote_type
379        );
380    }
381    for (name, entry) in &cfg.remotes {
382        let _ = writeln!(stdout, "{name}\t{} ({})", entry.url, entry.remote_type);
383    }
384    exit::OK
385}
386
387fn emit_err(msg: &str, code: u8) -> u8 {
388    let mut stderr = std::io::stderr().lock();
389    let _ = writeln!(stderr, "error: {msg}");
390    code
391}