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::collections::BTreeMap;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use clap::{Parser, Subcommand, ValueEnum};
11use mkit_core::layout::RepoLayout;
12
13use crate::clap_shim;
14use crate::config::{self, Config, RemoteEntry};
15use crate::exit;
16use crate::format;
17use crate::remote_dispatch::applied_packs::AppliedPacks;
18
19const ACCEPTED_SCHEMES: &[(&str, &str)] = &[
20    ("mkit+file://", "file"),
21    ("mkit+https://", "http"),
22    ("mkit+s3://", "s3"),
23    ("mkit+ssh://", "ssh"),
24    ("mkit+memory://", "memory"),
25    // Git-bridge remotes (SPEC-GIT-BRIDGE / SPEC-GIT-IMPORT). Native
26    // push/pull/fetch/clone REFUSE these with a pointer to the
27    // `mkit git` subcommands — the transports are not interchangeable.
28    ("git+https://", "git"),
29    ("git+ssh://", "git"),
30    ("git+file://", "git"),
31];
32
33#[derive(Debug, Clone, Copy, ValueEnum)]
34enum RemoteFormat {
35    Default,
36    Json,
37}
38
39#[derive(Debug, Parser)]
40#[command(name = "mkit remote", about = "Show or configure the remote.")]
41struct RemoteOpts {
42    /// Output format for the show form. JSON object with `--format=json`.
43    #[arg(long, value_enum, default_value = "default")]
44    format: RemoteFormat,
45    /// Verbose: list each remote's URL and direction (`<name>\t<url>
46    /// (fetch)` / `(push)`), like `git remote -v`.
47    #[arg(short = 'v', long)]
48    verbose: bool,
49    #[command(subcommand)]
50    sub: Option<RemoteCmd>,
51}
52
53#[derive(Debug, Subcommand)]
54enum RemoteCmd {
55    /// Configure a remote. With one argument, sets the flat default
56    /// remote (`mkit remote add <url>`). With two, adds/updates a named
57    /// remote (`mkit remote add <name> <url>`). The URL must be
58    /// `mkit+<scheme>://...`.
59    Add {
60        name_or_url: String,
61        url: Option<String>,
62    },
63    /// Alias for `add`.
64    Set {
65        name_or_url: String,
66        url: Option<String>,
67    },
68    /// Remove a named remote (`mkit remote remove <name>`). Use the
69    /// reserved name `default` to clear the flat default remote.
70    #[command(alias = "rm")]
71    Remove { name: String },
72    /// Rename a named remote (`mkit remote rename <old> <new>`). Also
73    /// rewrites any `branch.<b>.remote` upstream pointing at `<old>`.
74    #[command(alias = "mv")]
75    Rename { old: String, new: String },
76    /// Print a remote's URL (`mkit remote get-url <name>`; use `default`
77    /// for the flat default remote).
78    #[command(name = "get-url")]
79    GetUrl { name: String },
80    /// Change a remote's URL (`mkit remote set-url <name> <url>`).
81    #[command(name = "set-url")]
82    SetUrl { name: String, url: String },
83}
84
85#[must_use]
86#[allow(clippy::too_many_lines)] // flat dispatch over the remote subcommands
87pub fn run(args: &[String]) -> u8 {
88    let opts = match clap_shim::parse::<RemoteOpts>("mkit remote", args) {
89        Ok(o) => o,
90        Err(code) => return code,
91    };
92    let cwd = match std::env::current_dir() {
93        Ok(p) => p,
94        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
95    };
96    let layout = match super::resolve_layout(&cwd) {
97        Ok(layout) => layout,
98        Err(code) => return code,
99    };
100    let layered = match config::read_layered(&layout) {
101        Ok(c) => c,
102        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
103    };
104    // `show` reflects the merged view; every mutating subcommand operates
105    // on and persists ONLY the repo layer, so a user-scoped value (e.g. a
106    // private `user.email`) is never materialized into the clone-traveling
107    // `.mkit/config` by `config::write`.
108    if opts.sub.is_none() {
109        return show(
110            &layered.merged,
111            matches!(opts.format, RemoteFormat::Json),
112            opts.verbose,
113        );
114    }
115    let mut cfg = layered.repo;
116
117    match opts.sub {
118        None => unreachable!("handled above"),
119        Some(RemoteCmd::Add { name_or_url, url } | RemoteCmd::Set { name_or_url, url }) => {
120            // Two forms:
121            //   `mkit remote add <url>`         -> flat default remote
122            //   `mkit remote add <name> <url>`  -> named remote
123            let (name, url) = match url {
124                Some(url) => (Some(name_or_url), url),
125                None => (None, name_or_url),
126            };
127            // Reject control characters (newline et al.) before the URL
128            // ever reaches `config::write`, which emits values raw — a
129            // newline would inject extra `key = value` lines into
130            // `.mkit/config` (config injection).
131            if config::validate_value(&url).is_err() {
132                return emit_err(
133                    &format!("invalid remote URL '{url}': contains control characters"),
134                    exit::PROTOCOL_ERROR,
135                );
136            }
137            let Some(scheme) = validate_url(&url) else {
138                return emit_err(
139                    &format!(
140                        "invalid remote URL '{url}': must start with 'mkit+<scheme>://'\n\
141                         hint: URL must start with mkit+<scheme>:// (e.g. mkit+https://, mkit+ssh://, mkit+file://, mkit+s3://)",
142                    ),
143                    exit::PROTOCOL_ERROR,
144                );
145            };
146            if let Some(name) = name {
147                if let Err(code) = validate_remote_name(&name) {
148                    return code;
149                }
150                cfg.remotes.insert(
151                    name,
152                    RemoteEntry {
153                        url,
154                        remote_type: scheme.to_owned(),
155                    },
156                );
157            } else {
158                cfg.remote_endpoint = url;
159                scheme.clone_into(&mut cfg.remote_type);
160            }
161            match config::write(&layout, &cfg) {
162                Ok(()) => exit::OK,
163                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
164            }
165        }
166        Some(RemoteCmd::Remove { name }) => {
167            // Removing a remote only touches the repo-scoped address
168            // book. The user-scoped `trusted_remote_endpoint` (#97) is
169            // keyed by exact URL, not by remote name, and is never
170            // serialised by `config::write`, so the credential-trust
171            // boundary is unaffected: a later remote reusing the same URL
172            // would still be trusted, and one with a new URL still
173            // requires an explicit `config trusted_remote_endpoint`.
174            if name == config::DEFAULT_REMOTE_NAME {
175                if cfg.remote_endpoint.is_empty() {
176                    return emit_err("no default remote configured", exit::GENERAL_ERROR);
177                }
178                cfg.remote_endpoint.clear();
179                cfg.remote_type.clear();
180                cfg.remote_bucket.clear();
181            } else if cfg.remotes.remove(&name).is_none() {
182                return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
183            }
184            // Configured remotes nested under `name` (#660): their ref
185            // and bridge-state subtrees must survive the removal even
186            // though they share `name`'s directory prefix.
187            let siblings = nested_sibling_names(&cfg.remotes, &name);
188            match config::write(&layout, &cfg) {
189                Ok(()) => {
190                    // Stale tracking refs would shadow a future remote
191                    // reusing the name; objects stay (gc owns them).
192                    remove_tracking_refs(&layout, &name, &siblings);
193                    remove_applied_packs_record(&layout, &name);
194                    warn_orphaned_bridge_state(&layout, &name);
195                    exit::OK
196                }
197                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
198            }
199        }
200        Some(RemoteCmd::Rename { old, new }) => {
201            if old == config::DEFAULT_REMOTE_NAME || new == config::DEFAULT_REMOTE_NAME {
202                return emit_err(
203                    "cannot rename the reserved `default` remote; use `remote add`/`remote remove`",
204                    exit::PROTOCOL_ERROR,
205                );
206            }
207            if let Err(code) = validate_remote_name(&new) {
208                return code;
209            }
210            let Some(entry) = cfg.remotes.remove(&old) else {
211                return emit_err(&format!("remote '{old}' not found"), exit::GENERAL_ERROR);
212            };
213            if cfg.remotes.contains_key(&new) {
214                // Put the source back so a failed rename is a no-op.
215                cfg.remotes.insert(old, entry);
216                return emit_err(&format!("remote '{new}' already exists"), exit::CANTCREAT);
217            }
218            // Configured remotes nested under `old` (#660): computed
219            // before `new` is inserted below, so `new` can never be
220            // mistaken for one of `old`'s own siblings even when `new`
221            // itself extends `old` (`rename a a/sub`).
222            let siblings = nested_sibling_names(&cfg.remotes, &old);
223            cfg.remotes.insert(new.clone(), entry);
224            // Repoint any branch upstreams that tracked the old name.
225            for up in cfg.branch_upstreams.values_mut() {
226                if up.remote == old {
227                    up.remote.clone_from(&new);
228                }
229            }
230            match config::write(&layout, &cfg) {
231                Ok(()) => {
232                    move_tracking_refs(&layout, &old, &new, &siblings);
233                    move_bridge_state(&layout, &old, &new, &siblings);
234                    move_applied_packs_record(&layout, &old, &new);
235                    exit::OK
236                }
237                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
238            }
239        }
240        Some(RemoteCmd::GetUrl { name }) => {
241            // Read-only — reflect the merged view (a default endpoint may be
242            // user-scoped).
243            let url = if name == config::DEFAULT_REMOTE_NAME {
244                (!layered.merged.remote_endpoint.is_empty())
245                    .then(|| layered.merged.remote_endpoint.clone())
246            } else {
247                layered.merged.remotes.get(&name).map(|e| e.url.clone())
248            };
249            match url {
250                Some(u) => {
251                    let mut stdout = std::io::stdout().lock();
252                    let _ = writeln!(stdout, "{u}");
253                    exit::OK
254                }
255                None => emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR),
256            }
257        }
258        Some(RemoteCmd::SetUrl { name, url }) => {
259            if config::validate_value(&url).is_err() {
260                return emit_err(
261                    &format!("invalid remote URL '{url}': contains control characters"),
262                    exit::PROTOCOL_ERROR,
263                );
264            }
265            let Some(scheme) = validate_url(&url) else {
266                return emit_err(
267                    &format!("invalid remote URL '{url}': must start with 'mkit+<scheme>://'"),
268                    exit::PROTOCOL_ERROR,
269                );
270            };
271            if name == config::DEFAULT_REMOTE_NAME {
272                if cfg.remote_endpoint.is_empty() {
273                    return emit_err("no default remote configured", exit::GENERAL_ERROR);
274                }
275                cfg.remote_endpoint = url;
276                scheme.clone_into(&mut cfg.remote_type);
277            } else {
278                let Some(entry) = cfg.remotes.get_mut(&name) else {
279                    return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
280                };
281                entry.url = url;
282                scheme.clone_into(&mut entry.remote_type);
283            }
284            match config::write(&layout, &cfg) {
285                Ok(()) => exit::OK,
286                Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
287            }
288        }
289    }
290}
291
292/// Best-effort move of `refs/remotes/<old>/` to `refs/remotes/<new>/`
293/// after a rename. Failure is reported but non-fatal: the config
294/// rename already happened, and a follow-up fetch repopulates.
295///
296/// `siblings` lists the configured remote names nested under `old`
297/// (#660, `nested_sibling_names`); this joins them against the root it
298/// already owns (`layout.remotes_dir()`) to build the protected set that
299/// `move_state_dir` needs — empty in the overwhelmingly common case, in
300/// which `move_state_dir` takes its whole-directory fast path.
301fn move_tracking_refs(layout: &RepoLayout, old: &str, new: &str, siblings: &[String]) {
302    let root = layout.remotes_dir();
303    let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
304    if let Err(e) = move_state_dir(&root, old, new, &protected) {
305        let mut stderr = std::io::stderr().lock();
306        let _ = writeln!(
307            stderr,
308            "warning: could not move tracking refs {old} -> {new}: {e}; \
309             run `mkit fetch {new}` to repopulate"
310        );
311    }
312}
313
314/// Bridge state under `.mkit/git/<name>/` follows a rename so leases,
315/// maps, and the staging mirror stay bound to the same remote name.
316///
317/// `siblings` lists the configured remote names nested under `old`
318/// (#660, `nested_sibling_names`); this joins them against the root it
319/// already owns (`layout.git_state_dir()`) to build the protected set —
320/// see `move_tracking_refs` for the shared `move_state_dir` call.
321fn move_bridge_state(layout: &RepoLayout, old: &str, new: &str, siblings: &[String]) {
322    let root = layout.git_state_dir();
323    let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
324    if let Err(e) = move_state_dir(&root, old, new, &protected) {
325        let mut stderr = std::io::stderr().lock();
326        let _ = writeln!(
327            stderr,
328            "warning: could not move git-bridge state {old} -> {new}: {e}"
329        );
330    }
331}
332
333/// The applied-packs record (`<common dir>/applied-packs/<name>`, #409) is a
334/// pure per-remote cache whose lifecycle follows the remote's (#545): drop it
335/// on remove so a later re-add of the same name starts from an empty record
336/// instead of inheriting a stale one (which would trip the fetch-side
337/// self-heal's spurious full re-download once the store has been gc'd).
338/// Best-effort and non-fatal, like the tracking-ref cleanup: on failure the
339/// self-heal still recovers, at the cost of that one re-download.
340fn remove_applied_packs_record(layout: &RepoLayout, name: &str) {
341    if let Err(e) = AppliedPacks::remove_record(layout, name) {
342        let mut stderr = std::io::stderr().lock();
343        let _ = writeln!(
344            stderr,
345            "warning: could not remove applied-packs record for '{name}': {e}"
346        );
347    }
348}
349
350/// The applied-packs record follows a rename (#545), so the renamed remote's
351/// next fetch reuses its redownload-avoidance cache instead of pulling the
352/// full pack chain again, and no orphan record is left under the old name.
353/// Best-effort and non-fatal, like `move_tracking_refs`: on failure the next
354/// `mkit fetch <new>` rebuilds the record with one full download.
355fn move_applied_packs_record(layout: &RepoLayout, old: &str, new: &str) {
356    if let Err(e) = AppliedPacks::rename_record(layout, old, new) {
357        let mut stderr = std::io::stderr().lock();
358        let _ = writeln!(
359            stderr,
360            "warning: could not move applied-packs record {old} -> {new}: {e}"
361        );
362    }
363}
364
365/// Move the per-remote state directory for `old` to `new` under `root`,
366/// tolerating multi-segment remote names (which map to nested
367/// subdirectories) on both sides, *including* the case where one name is
368/// a path-prefix of the other (`a` <-> `a/b`), and *including* the case
369/// where a configured sibling remote nests inside `old`'s own directory
370/// prefix (`a/b` configured while `a` is renamed or removed, #660) and
371/// must not be dragged along or deleted with it. `protected` (built by
372/// the caller via `nested_sibling_names` + its own root) lists the
373/// absolute paths of those siblings' state directories; empty in the
374/// overwhelmingly common case.
375///
376/// A direct `fs::rename(src, dst)` breaks on the prefix case in both
377/// directions: renaming `a` to `a/b` asks the OS to move a directory into
378/// its own subtree (EINVAL), and renaming `a/b` to `a` lands on the
379/// non-empty old ancestor (ENOTEMPTY). git's per-ref transactions don't
380/// have this problem, so this was a real parity gap, not an exotic
381/// input.
382///
383/// # Mechanism: one two-phase move through a temp sibling
384///
385/// The fix is a move through a dot-named temp directory directly under
386/// `root`, `.rename.tmp.<pid>.0` — one path, no prefix-nesting or
387/// protected-sibling case analysis at the call site.
388///
389/// Phase 1 extracts `src`'s content into `tmp`:
390/// - `protected.is_empty()` (no sibling in the way): a single
391///   whole-directory `fs::rename(&src, &tmp)` — always legal, since
392///   renaming a directory to a fresh sibling of `root` is never a move
393///   into its own subtree. This is the pre-#660 fast path, still exactly
394///   two renames total for the common case.
395/// - otherwise: `tmp` is created empty and `walk_unprotected` extracts
396///   every unprotected entry under `src` into it one at a time (skipping
397///   protected roots whole, recursing through their ancestors), leaving
398///   each protected subtree exactly where it is — still under `src`, not
399///   dragged to the equivalent position under `dst`.
400///
401/// Either way `tmp` ends up holding exactly what should land at `dst`.
402/// Phase 2 (`prune_empty_parents`) tidies `src`'s now-empty ancestors —
403/// `src` itself may legitimately remain non-empty when protected content
404/// stayed behind, which is the point. Phase 3 creates `dst`'s parents
405/// (`fs::rename` won't). Phase 4 is the single `fs::rename(&tmp, &dst)`
406/// that completes the move.
407///
408/// A missing source directory is a no-op (nothing to move).
409///
410/// # Restore / "parked at" contract — covers every phase, not just the
411/// final rename
412///
413/// If anything from phase 1 through phase 4 fails after content has
414/// actually reached `tmp`, the move backs out on a best-effort basis
415/// before the error is returned, so a failed move is a clean no-op
416/// rather than stranding state — the caller's existing warning then
417/// fires as before, and a follow-up fetch self-heals. If `tmp` never
418/// received any content (phase 1 itself failed outright — `create_dir`
419/// or the whole-dir rename never succeeded), there is nothing to restore
420/// and the original error is returned unadorned.
421///
422/// The restore is one helper for both shapes: try `fs::rename(&tmp,
423/// &src)` first — this succeeds outright when `src` vanished entirely
424/// (the fast-path case, where nothing remains at `src` to collide with).
425/// If that fails (the selective case, where `src` still exists holding
426/// retained protected content, so a whole-directory rename onto it is
427/// rejected), fall back to `merge_tree_into(&tmp, &src)`, which merges
428/// `tmp`'s entries back into `src` one at a time, recursing into any
429/// like-named retained ancestor directory instead of clobbering it.
430///
431/// When the restore succeeds — by either path — the original error is
432/// returned as-is. The `"; state parked at <tmp>"` annotation is added
433/// ONLY when the restore itself also fails, since that's the one case
434/// where the plain error text says nothing about where the content
435/// actually went. A `merge_tree_into` success followed by a failed
436/// best-effort `remove_dir(&tmp)` cleanup does NOT count as a restore
437/// failure and must NOT trigger the annotation (finding 5c, #789): the
438/// content is home, and a leftover (near-)empty `tmp` husk is inert
439/// debris, not stranded state.
440///
441/// Parent creation/pruning (phases 2 and 3) are themselves best-effort:
442/// a creation failure falls through to the following `rename`, which
443/// then fails and is handled by the same restore path; a prune failure
444/// is silent since it's tidiness, not correctness.
445///
446/// # Crash safety
447///
448/// A crash between phases leaves a `.rename.tmp.<pid>.0` directory
449/// orphaned directly under `root` (the same temp-name convention as
450/// `atomic::write_atomic`, `atomic.rs:44-46`, so crash debris is
451/// grep-able by one pattern across the codebase). Unlike the
452/// pre-existing empty-dir warts on this warn-only path, this one may be
453/// fully populated — but a dot-leading path component is invalid per
454/// `validate_ref_name` (`refs::validate_ref_name`, SPEC-REFS §3), so
455/// every listing enumerator (`show-ref`, `for-each-ref`,
456/// `list_remote_names`) and the git-bridge `state_names` scan treat it
457/// as inert rather than a phantom remote or ref namespace. The temp name
458/// can't collide with any live remote's state directory either, since
459/// remote names are validated dot-free (`validate_remote_name`). The
460/// "name" component is the fixed literal `rename` rather than
461/// `old`/`new`: those may be multi-segment remote names
462/// (`team/upstream`), and embedding a `/` into a single path component
463/// here would make `root.join(...)` build a *nested* path instead of a
464/// flat sibling, defeating the one-temp-dir-directly-under-`root`
465/// invariant this whole function relies on. The suffix is fixed at `.0`
466/// rather than reusing `atomic`'s process-wide counter (private to
467/// `mkit-core`, unreachable from this crate) because a single call
468/// creates and consumes at most one temp dir before returning, so
469/// nothing within one call can collide with it.
470///
471/// # Adopted edge case
472///
473/// A protected root that exists as a plain *file* (a branch-path /
474/// remote-name collision inherent to the directory-keyed layout, and
475/// predating #660) is adopted into the protection set by
476/// `walk_unprotected`'s exact-path check and left untouched — the safe
477/// direction for an input this function doesn't otherwise reason about.
478///
479/// # Destination-side nesting boundary (finding 3, #789 — not fixed here)
480///
481/// `protected` is always computed by the caller from `old`
482/// (`nested_sibling_names(remotes, old)`), never from `new`. When a
483/// configured sibling instead nests under the DESTINATION — `rename a/b
484/// a` while `a/x` is configured, or `rename a a/b` while `a/b/c` is
485/// configured — this function has no way to see that from `old` alone,
486/// so it takes the ordinary fast or selective path as if no sibling were
487/// involved, and the final `fs::rename(&tmp, &dst)` lands on `dst`'s
488/// already-occupied directory and fails closed: the restore contract
489/// above fires, the caller's standard warning is emitted, and the
490/// source state is intact rather than merged into the sibling's
491/// directory or dragging it along. This is a deliberate boundary, not a
492/// bug — see the #660/#789 PR discussion for why destination-side
493/// protection (computing and reasoning about siblings of `new` too) is
494/// out of scope here. `rename_round_trip_into_sibling_fails_closed`
495/// pins this exact shape.
496fn move_state_dir(root: &Path, old: &str, new: &str, protected: &[PathBuf]) -> std::io::Result<()> {
497    let (src, dst) = (root.join(old), root.join(new));
498    if !src.is_dir() {
499        return Ok(());
500    }
501    let tmp = root.join(format!(".rename.tmp.{}.0", std::process::id()));
502
503    let extracted = if protected.is_empty() {
504        std::fs::rename(&src, &tmp)
505    } else {
506        std::fs::create_dir(&tmp).and_then(|()| {
507            walk_unprotected(&src, protected, &mut |entry: &Path| {
508                // `entry` always came from `read_dir(src)` (directly, or
509                // via a recursive `walk_unprotected` call rooted at an
510                // ancestor under `src`), so it is always under `src` —
511                // never leave an entry behind on the strength of a
512                // silently-ignored mismatch here (finding 5a, #789).
513                let rel = entry
514                    .strip_prefix(&src)
515                    .expect("walk_unprotected only ever yields entries located under src");
516                let target = tmp.join(rel);
517                if let Some(parent) = target.parent() {
518                    std::fs::create_dir_all(parent)?;
519                }
520                std::fs::rename(entry, &target)
521            })
522        })
523    };
524
525    let result = extracted.and_then(|()| {
526        prune_empty_parents(&src, root);
527        if let Some(parent) = dst.parent() {
528            let _ = std::fs::create_dir_all(parent);
529        }
530        std::fs::rename(&tmp, &dst)
531    });
532
533    let Err(e) = result else {
534        return Ok(());
535    };
536    if !tmp.exists() {
537        // Phase 1 never got as far as creating/populating `tmp`: `src`
538        // is untouched, so there is nothing to restore.
539        return Err(e);
540    }
541    let _ = std::fs::create_dir_all(&src);
542    if std::fs::rename(&tmp, &src).is_ok() {
543        return Err(e);
544    }
545    if merge_tree_into(&tmp, &src).is_err() {
546        // The restore itself failed too: state is now parked at `tmp`,
547        // not `src`. Say so — the original error alone doesn't tell the
548        // caller (and its warning) where the state actually ended up.
549        return Err(std::io::Error::new(
550            e.kind(),
551            format!("{e}; state parked at {}", tmp.display()),
552        ));
553    }
554    // Merge succeeded: the state is home. `tmp` is now an inert
555    // (near-)empty husk; best-effort cleanup only — its failure is
556    // tidiness debris, not stranded state, so the annotation above must
557    // not fire for it (finding 5c, #789).
558    let _ = std::fs::remove_dir(&tmp);
559    Err(e)
560}
561
562/// Walk up from `dir`, removing empty directories, until `root` is
563/// reached or a removal fails (a non-empty directory is the natural
564/// terminator — no need to distinguish that from other errors).
565fn prune_empty_parents(dir: &Path, root: &Path) {
566    let mut dir = dir.parent();
567    while let Some(d) = dir {
568        if d == root || std::fs::remove_dir(d).is_err() {
569            break;
570        }
571        dir = d.parent();
572    }
573}
574
575/// Configured remote names that are proper extensions of `name`
576/// (`name + "/"` prefix): the subtrees under `name`'s state directories
577/// that actually belong to OTHER configured remotes and must survive a
578/// rename/remove of `name` (#660). Empty in the overwhelmingly common
579/// case, in which callers take the pre-existing whole-directory fast
580/// path unchanged.
581fn nested_sibling_names(remotes: &BTreeMap<String, RemoteEntry>, name: &str) -> Vec<String> {
582    let prefix = format!("{name}/");
583    remotes
584        .keys()
585        .filter(|k| k.starts_with(&prefix))
586        .cloned()
587        .collect()
588}
589
590/// Visit the entries of `dir` bottom-up, skipping any entry that IS a
591/// protected root (left untouched — not handed to `f`, not recursed
592/// into) and recursing into any entry that is an ANCESTOR of a
593/// protected root; every other entry is handed to `f` whole, since its
594/// subtree cannot contain a protected path. After visiting, `dir`
595/// itself is opportunistically removed via `remove_dir`: success means
596/// every unprotected entry left and no protected subtree remains
597/// beneath it; failure (non-empty) is the natural terminator, not an
598/// error, mirroring `prune_empty_parents`.
599///
600/// The entry list is snapshotted with one `read_dir` before any
601/// mutation happens, so a destination created inside `dir` by `f`
602/// itself (a rename into `dir`'s own subtree) is never iterated.
603///
604/// A missing `dir` is a no-op, matching the fast paths this is an
605/// alternative to. Like every other helper on these rename/remove
606/// paths, failure is best-effort: the first error from `f` (or from
607/// `read_dir`) aborts the walk immediately and propagates to the
608/// caller's existing warn-only handling — partial state is acceptable
609/// here and self-heals on the next fetch.
610fn walk_unprotected(
611    dir: &Path,
612    protected: &[PathBuf],
613    f: &mut dyn FnMut(&Path) -> std::io::Result<()>,
614) -> std::io::Result<()> {
615    if !dir.is_dir() {
616        return Ok(());
617    }
618    let entries: Vec<PathBuf> = std::fs::read_dir(dir)?
619        .map(|e| e.map(|e| e.path()))
620        .collect::<std::io::Result<_>>()?;
621    for entry in entries {
622        if protected.iter().any(|p| p == &entry) {
623            // `entry` IS a protected root: leave it, and everything
624            // beneath it, entirely alone.
625            continue;
626        }
627        if protected.iter().any(|p| p.starts_with(&entry)) {
628            // `entry` is an ancestor of some protected root: recurse so
629            // its unprotected children are still visited individually.
630            walk_unprotected(&entry, protected, f)?;
631        } else {
632            // No protected path can live under `entry` — hand it to `f`
633            // whole.
634            f(&entry)?;
635        }
636    }
637    let _ = std::fs::remove_dir(dir);
638    Ok(())
639}
640
641/// Restore helper for `move_state_dir`'s occupied-destination /
642/// mid-extraction failure: moves every entry under `from` into `into`,
643/// recursing into any like-named directory that already exists at the
644/// destination (needed because `into` — `src` — may have retained
645/// ancestor directories of protected content that a moved entry's
646/// relative path nests under, finding 4 / #789) and renaming everything
647/// else directly. Not a general merge utility: used only to put
648/// `move_state_dir`'s temp contents back where they came from.
649fn merge_tree_into(from: &Path, into: &Path) -> std::io::Result<()> {
650    // Snapshot the entry list before any mutation, the same discipline
651    // as `walk_unprotected` (finding 5b, #789): recursing into a
652    // like-named destination directory below mutates `into`, and a
653    // naive un-snapshotted `read_dir(from)` iterator is only required to
654    // reflect entries present at some unspecified point during the
655    // scan, not to ignore ones renamed away out from under it mid-walk.
656    let entries: Vec<PathBuf> = std::fs::read_dir(from)?
657        .map(|e| e.map(|e| e.path()))
658        .collect::<std::io::Result<_>>()?;
659    for path in entries {
660        let name = path
661            .file_name()
662            .expect("read_dir entries always have a file name");
663        let target = into.join(name);
664        if path.is_dir() && target.is_dir() {
665            merge_tree_into(&path, &target)?;
666            std::fs::remove_dir(&path)?;
667        } else {
668            if let Some(parent) = target.parent() {
669                std::fs::create_dir_all(parent)?;
670            }
671            std::fs::rename(&path, &target)?;
672        }
673    }
674    Ok(())
675}
676
677/// Removing a remote leaves its bridge state in place (it holds the
678/// staging mirror + retained provenance, which are durable artifacts,
679/// not caches) — but say so.
680fn warn_orphaned_bridge_state(layout: &RepoLayout, name: &str) {
681    let dir = layout.git_state_dir().join(name);
682    if dir.is_dir() {
683        let mut stderr = std::io::stderr().lock();
684        let _ = writeln!(
685            stderr,
686            "note: git-bridge state for '{name}' remains at .mkit/git/{name}/ \
687             (staging mirror + provenance); delete it manually if unwanted"
688        );
689    }
690}
691
692/// Best-effort removal of `refs/remotes/<name>/` after a remove.
693///
694/// `siblings` lists the configured remote names nested under `name`
695/// (#660, `nested_sibling_names`); this joins them against the root it
696/// already owns (`layout.remotes_dir()`) to build the protected set. When
697/// empty (the overwhelmingly common case) this is the unchanged
698/// whole-directory `remove_dir_all` fast path; otherwise a selective walk
699/// deletes only the entries not on a protected sibling's path, so `a/b`'s
700/// refs survive `remove a`.
701fn remove_tracking_refs(layout: &RepoLayout, name: &str, siblings: &[String]) {
702    let root = layout.remotes_dir();
703    let dir = root.join(name);
704    let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
705    let result = if protected.is_empty() {
706        if dir.is_dir() {
707            std::fs::remove_dir_all(&dir)
708        } else {
709            Ok(())
710        }
711    } else {
712        walk_unprotected(&dir, &protected, &mut |entry: &Path| {
713            if entry.is_dir() {
714                std::fs::remove_dir_all(entry)
715            } else {
716                std::fs::remove_file(entry)
717            }
718        })
719    };
720    if let Err(e) = result {
721        let mut stderr = std::io::stderr().lock();
722        let _ = writeln!(
723            stderr,
724            "warning: could not remove tracking refs for '{name}': {e}"
725        );
726    }
727}
728
729/// Validate a named-remote name: rejects control characters, non
730/// ref-safe names, dots (which would collide with the
731/// `remote.<name>.<field>` config key grammar), and the reserved
732/// `default` name. Returns the CLI exit code to propagate on failure.
733fn validate_remote_name(name: &str) -> Result<(), u8> {
734    if config::validate_value(name).is_err() {
735        return Err(emit_err(
736            &format!("invalid remote name '{name}': contains control characters"),
737            exit::PROTOCOL_ERROR,
738        ));
739    }
740    if !mkit_core::refs::validate_ref_name(name)
741        || name.contains('.')
742        || name == config::DEFAULT_REMOTE_NAME
743    {
744        return Err(emit_err(
745            &format!(
746                "invalid remote name '{name}': must be a dot-free ref-safe name \
747                 (and not the reserved `default`)"
748            ),
749            exit::PROTOCOL_ERROR,
750        ));
751    }
752    Ok(())
753}
754
755fn validate_url(url: &str) -> Option<&'static str> {
756    for (prefix, kind) in ACCEPTED_SCHEMES {
757        if url.starts_with(prefix) {
758            return Some(kind);
759        }
760    }
761    None
762}
763
764fn show(cfg: &Config, json: bool, verbose: bool) -> u8 {
765    let has_default = !cfg.remote_endpoint.is_empty();
766    if !has_default && cfg.remotes.is_empty() {
767        // Empty listing → empty stdout in both modes. The default
768        // mode emits a human note on stderr.
769        if !json {
770            let mut stderr = std::io::stderr().lock();
771            let _ = writeln!(stderr, "(no remote configured)");
772        }
773        return exit::OK;
774    }
775    let mut stdout = std::io::stdout().lock();
776    if json {
777        // Additive shape: when only the default remote is configured,
778        // emit the historical single-line object so existing JSON
779        // snapshots stay valid. When named remotes exist, emit one JSON
780        // object per line (JSONL) carrying a `name` field; the default
781        // remote (if any) appears as `name=default`.
782        if has_default && cfg.remotes.is_empty() {
783            let _ = stdout.write_all(b"{");
784            let _ = write!(
785                stdout,
786                "\"url\":\"{}\"",
787                format::json_escape(&cfg.remote_endpoint)
788            );
789            let _ = write!(
790                stdout,
791                ",\"transport\":\"{}\"",
792                format::json_escape(&cfg.remote_type)
793            );
794            let _ = stdout.write_all(b"}\n");
795            return exit::OK;
796        }
797        if has_default {
798            let _ = writeln!(
799                stdout,
800                "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
801                config::DEFAULT_REMOTE_NAME,
802                format::json_escape(&cfg.remote_endpoint),
803                format::json_escape(&cfg.remote_type)
804            );
805        }
806        for (name, entry) in &cfg.remotes {
807            let _ = writeln!(
808                stdout,
809                "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
810                format::json_escape(name),
811                format::json_escape(&entry.url),
812                format::json_escape(&entry.remote_type)
813            );
814        }
815        return exit::OK;
816    }
817    // Default (human) form, git-shaped:
818    //   `mkit remote`      → one remote NAME per line
819    //   `mkit remote -v`   → `<name>\t<url> (fetch)` and `(push)` per remote
820    // The flat default remote shows under the reserved name `default`.
821    if verbose {
822        if has_default {
823            let url = &cfg.remote_endpoint;
824            let name = config::DEFAULT_REMOTE_NAME;
825            let _ = writeln!(stdout, "{name}\t{url} (fetch)");
826            let _ = writeln!(stdout, "{name}\t{url} (push)");
827        }
828        for (name, entry) in &cfg.remotes {
829            let _ = writeln!(stdout, "{name}\t{} (fetch)", entry.url);
830            let _ = writeln!(stdout, "{name}\t{} (push)", entry.url);
831        }
832        return exit::OK;
833    }
834    if has_default {
835        let _ = writeln!(stdout, "{}", config::DEFAULT_REMOTE_NAME);
836    }
837    for name in cfg.remotes.keys() {
838        let _ = writeln!(stdout, "{name}");
839    }
840    exit::OK
841}
842
843use super::error as emit_err;
844
845#[cfg(test)]
846mod tests {
847    use super::{PathBuf, walk_unprotected};
848
849    fn touch(path: &std::path::Path) {
850        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
851        std::fs::write(path, b"x").unwrap();
852    }
853
854    #[test]
855    fn protected_root_is_left_untouched() {
856        let td = tempfile::tempdir().unwrap();
857        let root = td.path();
858        touch(&root.join("keep/marker.txt"));
859        touch(&root.join("drop.txt"));
860        let protected = vec![root.join("keep")];
861        let mut visited = Vec::new();
862        walk_unprotected(root, &protected, &mut |p| {
863            visited.push(p.to_path_buf());
864            if p.is_dir() {
865                std::fs::remove_dir_all(p)
866            } else {
867                std::fs::remove_file(p)
868            }
869        })
870        .unwrap();
871        assert!(
872            root.join("keep/marker.txt").exists(),
873            "protected subtree must survive whole"
874        );
875        assert!(
876            !root.join("drop.txt").exists(),
877            "unprotected entry must be visited and removed"
878        );
879        assert_eq!(visited, vec![root.join("drop.txt")]);
880    }
881
882    #[test]
883    fn ancestor_of_protected_root_is_recursed_not_removed_whole() {
884        let td = tempfile::tempdir().unwrap();
885        let root = td.path();
886        touch(&root.join("a/b/c/marker.txt")); // protected root is a/b/c
887        touch(&root.join("a/other.txt")); // unprotected sibling under ancestor `a`
888        let protected = vec![root.join("a/b/c")];
889        let mut visited = Vec::new();
890        walk_unprotected(root, &protected, &mut |p| {
891            visited.push(p.to_path_buf());
892            if p.is_dir() {
893                std::fs::remove_dir_all(p)
894            } else {
895                std::fs::remove_file(p)
896            }
897        })
898        .unwrap();
899        assert!(
900            root.join("a/b/c/marker.txt").exists(),
901            "deeply nested protected root must survive"
902        );
903        assert!(
904            !root.join("a/other.txt").exists(),
905            "unprotected file under the ancestor must be removed"
906        );
907        assert_eq!(visited, vec![root.join("a/other.txt")]);
908        // `a` and `a/b` survive only because `a/b/c` remains beneath them
909        // — proof the ancestor dirs were recursed into, not deleted
910        // whole, and that they are NOT force-pruned once emptied of
911        // unprotected content.
912        assert!(root.join("a").is_dir());
913        assert!(root.join("a/b").is_dir());
914    }
915
916    #[test]
917    fn snapshot_is_taken_before_any_mutation() {
918        // Mirrors a rename into the walked directory's own subtree:
919        // `f` creates a NEW entry inside `dir` as a side effect. That
920        // entry must never be visited by this same walk, since the
921        // entry list was snapshotted up front.
922        let td = tempfile::tempdir().unwrap();
923        let root = td.path();
924        touch(&root.join("existing.txt"));
925        let protected: Vec<PathBuf> = Vec::new();
926        let mut visited = Vec::new();
927        walk_unprotected(root, &protected, &mut |p| {
928            visited.push(p.to_path_buf());
929            std::fs::write(root.join("created-during-walk.txt"), b"new").unwrap();
930            std::fs::remove_file(p)
931        })
932        .unwrap();
933        assert_eq!(visited, vec![root.join("existing.txt")]);
934        assert!(
935            root.join("created-during-walk.txt").exists(),
936            "entry created mid-walk must not be picked up by the same walk"
937        );
938    }
939
940    #[test]
941    fn dir_is_removed_bottom_up_after_its_entries_are_individually_processed() {
942        let td = tempfile::tempdir().unwrap();
943        let root = td.path();
944        let sub = root.join("a");
945        std::fs::create_dir_all(&sub).unwrap();
946        std::fs::write(sub.join("one.txt"), b"1").unwrap();
947        std::fs::write(sub.join("two.txt"), b"2").unwrap();
948        let protected: Vec<PathBuf> = Vec::new();
949        walk_unprotected(&sub, &protected, &mut |p| {
950            assert!(
951                p.is_file(),
952                "each entry is handed to f individually, never the dir itself"
953            );
954            std::fs::remove_file(p)
955        })
956        .unwrap();
957        // `f` only ever saw the two files; `sub`'s own removal is the
958        // walker's bottom-up cleanup once it is empty, not something
959        // `f` did.
960        assert!(
961            !sub.exists(),
962            "now-empty dir must be removed by the walk itself"
963        );
964    }
965}