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}