use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use mkit_core::layout::RepoLayout;
use crate::clap_shim;
use crate::config::{self, Config, RemoteEntry};
use crate::exit;
use crate::format;
use crate::remote_dispatch::applied_packs::AppliedPacks;
const ACCEPTED_SCHEMES: &[(&str, &str)] = &[
("mkit+file://", "file"),
("mkit+https://", "http"),
("mkit+s3://", "s3"),
("mkit+ssh://", "ssh"),
("mkit+memory://", "memory"),
("git+https://", "git"),
("git+ssh://", "git"),
("git+file://", "git"),
];
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RemoteFormat {
Default,
Json,
}
#[derive(Debug, Parser)]
#[command(name = "mkit remote", about = "Show or configure the remote.")]
struct RemoteOpts {
#[arg(long, value_enum, default_value = "default")]
format: RemoteFormat,
#[arg(short = 'v', long)]
verbose: bool,
#[command(subcommand)]
sub: Option<RemoteCmd>,
}
#[derive(Debug, Subcommand)]
enum RemoteCmd {
Add {
name_or_url: String,
url: Option<String>,
},
Set {
name_or_url: String,
url: Option<String>,
},
#[command(alias = "rm")]
Remove { name: String },
#[command(alias = "mv")]
Rename { old: String, new: String },
#[command(name = "get-url")]
GetUrl { name: String },
#[command(name = "set-url")]
SetUrl { name: String, url: String },
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<RemoteOpts>("mkit remote", args) {
Ok(o) => o,
Err(code) => return code,
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let layered = match config::read_layered(&layout) {
Ok(c) => c,
Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
};
if opts.sub.is_none() {
return show(
&layered.merged,
matches!(opts.format, RemoteFormat::Json),
opts.verbose,
);
}
let mut cfg = layered.repo;
match opts.sub {
None => unreachable!("handled above"),
Some(RemoteCmd::Add { name_or_url, url } | RemoteCmd::Set { name_or_url, url }) => {
let (name, url) = match url {
Some(url) => (Some(name_or_url), url),
None => (None, name_or_url),
};
if config::validate_value(&url).is_err() {
return emit_err(
&format!("invalid remote URL '{url}': contains control characters"),
exit::PROTOCOL_ERROR,
);
}
let Some(scheme) = validate_url(&url) else {
return emit_err(
&format!(
"invalid remote URL '{url}': must start with 'mkit+<scheme>://'\n\
hint: URL must start with mkit+<scheme>:// (e.g. mkit+https://, mkit+ssh://, mkit+file://, mkit+s3://)",
),
exit::PROTOCOL_ERROR,
);
};
if let Some(name) = name {
if let Err(code) = validate_remote_name(&name) {
return code;
}
cfg.remotes.insert(
name,
RemoteEntry {
url,
remote_type: scheme.to_owned(),
},
);
} else {
cfg.remote_endpoint = url;
scheme.clone_into(&mut cfg.remote_type);
}
match config::write(&layout, &cfg) {
Ok(()) => exit::OK,
Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
}
}
Some(RemoteCmd::Remove { name }) => {
if name == config::DEFAULT_REMOTE_NAME {
if cfg.remote_endpoint.is_empty() {
return emit_err("no default remote configured", exit::GENERAL_ERROR);
}
cfg.remote_endpoint.clear();
cfg.remote_type.clear();
cfg.remote_bucket.clear();
} else if cfg.remotes.remove(&name).is_none() {
return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
}
let siblings = nested_sibling_names(&cfg.remotes, &name);
match config::write(&layout, &cfg) {
Ok(()) => {
remove_tracking_refs(&layout, &name, &siblings);
remove_applied_packs_record(&layout, &name);
warn_orphaned_bridge_state(&layout, &name);
exit::OK
}
Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
}
}
Some(RemoteCmd::Rename { old, new }) => {
if old == config::DEFAULT_REMOTE_NAME || new == config::DEFAULT_REMOTE_NAME {
return emit_err(
"cannot rename the reserved `default` remote; use `remote add`/`remote remove`",
exit::PROTOCOL_ERROR,
);
}
if let Err(code) = validate_remote_name(&new) {
return code;
}
let Some(entry) = cfg.remotes.remove(&old) else {
return emit_err(&format!("remote '{old}' not found"), exit::GENERAL_ERROR);
};
if cfg.remotes.contains_key(&new) {
cfg.remotes.insert(old, entry);
return emit_err(&format!("remote '{new}' already exists"), exit::CANTCREAT);
}
let siblings = nested_sibling_names(&cfg.remotes, &old);
cfg.remotes.insert(new.clone(), entry);
for up in cfg.branch_upstreams.values_mut() {
if up.remote == old {
up.remote.clone_from(&new);
}
}
match config::write(&layout, &cfg) {
Ok(()) => {
move_tracking_refs(&layout, &old, &new, &siblings);
move_bridge_state(&layout, &old, &new, &siblings);
move_applied_packs_record(&layout, &old, &new);
exit::OK
}
Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
}
}
Some(RemoteCmd::GetUrl { name }) => {
let url = if name == config::DEFAULT_REMOTE_NAME {
(!layered.merged.remote_endpoint.is_empty())
.then(|| layered.merged.remote_endpoint.clone())
} else {
layered.merged.remotes.get(&name).map(|e| e.url.clone())
};
match url {
Some(u) => {
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{u}");
exit::OK
}
None => emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR),
}
}
Some(RemoteCmd::SetUrl { name, url }) => {
if config::validate_value(&url).is_err() {
return emit_err(
&format!("invalid remote URL '{url}': contains control characters"),
exit::PROTOCOL_ERROR,
);
}
let Some(scheme) = validate_url(&url) else {
return emit_err(
&format!("invalid remote URL '{url}': must start with 'mkit+<scheme>://'"),
exit::PROTOCOL_ERROR,
);
};
if name == config::DEFAULT_REMOTE_NAME {
if cfg.remote_endpoint.is_empty() {
return emit_err("no default remote configured", exit::GENERAL_ERROR);
}
cfg.remote_endpoint = url;
scheme.clone_into(&mut cfg.remote_type);
} else {
let Some(entry) = cfg.remotes.get_mut(&name) else {
return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
};
entry.url = url;
scheme.clone_into(&mut entry.remote_type);
}
match config::write(&layout, &cfg) {
Ok(()) => exit::OK,
Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
}
}
}
}
fn move_tracking_refs(layout: &RepoLayout, old: &str, new: &str, siblings: &[String]) {
let root = layout.remotes_dir();
let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
if let Err(e) = move_state_dir(&root, old, new, &protected) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: could not move tracking refs {old} -> {new}: {e}; \
run `mkit fetch {new}` to repopulate"
);
}
}
fn move_bridge_state(layout: &RepoLayout, old: &str, new: &str, siblings: &[String]) {
let root = layout.git_state_dir();
let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
if let Err(e) = move_state_dir(&root, old, new, &protected) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: could not move git-bridge state {old} -> {new}: {e}"
);
}
}
fn remove_applied_packs_record(layout: &RepoLayout, name: &str) {
if let Err(e) = AppliedPacks::remove_record(layout, name) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: could not remove applied-packs record for '{name}': {e}"
);
}
}
fn move_applied_packs_record(layout: &RepoLayout, old: &str, new: &str) {
if let Err(e) = AppliedPacks::rename_record(layout, old, new) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: could not move applied-packs record {old} -> {new}: {e}"
);
}
}
fn move_state_dir(root: &Path, old: &str, new: &str, protected: &[PathBuf]) -> std::io::Result<()> {
let (src, dst) = (root.join(old), root.join(new));
if !src.is_dir() {
return Ok(());
}
let tmp = root.join(format!(".rename.tmp.{}.0", std::process::id()));
let extracted = if protected.is_empty() {
std::fs::rename(&src, &tmp)
} else {
std::fs::create_dir(&tmp).and_then(|()| {
walk_unprotected(&src, protected, &mut |entry: &Path| {
let rel = entry
.strip_prefix(&src)
.expect("walk_unprotected only ever yields entries located under src");
let target = tmp.join(rel);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(entry, &target)
})
})
};
let result = extracted.and_then(|()| {
prune_empty_parents(&src, root);
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::rename(&tmp, &dst)
});
let Err(e) = result else {
return Ok(());
};
if !tmp.exists() {
return Err(e);
}
let _ = std::fs::create_dir_all(&src);
if std::fs::rename(&tmp, &src).is_ok() {
return Err(e);
}
if merge_tree_into(&tmp, &src).is_err() {
return Err(std::io::Error::new(
e.kind(),
format!("{e}; state parked at {}", tmp.display()),
));
}
let _ = std::fs::remove_dir(&tmp);
Err(e)
}
fn prune_empty_parents(dir: &Path, root: &Path) {
let mut dir = dir.parent();
while let Some(d) = dir {
if d == root || std::fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
}
fn nested_sibling_names(remotes: &BTreeMap<String, RemoteEntry>, name: &str) -> Vec<String> {
let prefix = format!("{name}/");
remotes
.keys()
.filter(|k| k.starts_with(&prefix))
.cloned()
.collect()
}
fn walk_unprotected(
dir: &Path,
protected: &[PathBuf],
f: &mut dyn FnMut(&Path) -> std::io::Result<()>,
) -> std::io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
let entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<_>>()?;
for entry in entries {
if protected.iter().any(|p| p == &entry) {
continue;
}
if protected.iter().any(|p| p.starts_with(&entry)) {
walk_unprotected(&entry, protected, f)?;
} else {
f(&entry)?;
}
}
let _ = std::fs::remove_dir(dir);
Ok(())
}
fn merge_tree_into(from: &Path, into: &Path) -> std::io::Result<()> {
let entries: Vec<PathBuf> = std::fs::read_dir(from)?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<_>>()?;
for path in entries {
let name = path
.file_name()
.expect("read_dir entries always have a file name");
let target = into.join(name);
if path.is_dir() && target.is_dir() {
merge_tree_into(&path, &target)?;
std::fs::remove_dir(&path)?;
} else {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&path, &target)?;
}
}
Ok(())
}
fn warn_orphaned_bridge_state(layout: &RepoLayout, name: &str) {
let dir = layout.git_state_dir().join(name);
if dir.is_dir() {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"note: git-bridge state for '{name}' remains at .mkit/git/{name}/ \
(staging mirror + provenance); delete it manually if unwanted"
);
}
}
fn remove_tracking_refs(layout: &RepoLayout, name: &str, siblings: &[String]) {
let root = layout.remotes_dir();
let dir = root.join(name);
let protected: Vec<PathBuf> = siblings.iter().map(|s| root.join(s)).collect();
let result = if protected.is_empty() {
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
} else {
Ok(())
}
} else {
walk_unprotected(&dir, &protected, &mut |entry: &Path| {
if entry.is_dir() {
std::fs::remove_dir_all(entry)
} else {
std::fs::remove_file(entry)
}
})
};
if let Err(e) = result {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"warning: could not remove tracking refs for '{name}': {e}"
);
}
}
fn validate_remote_name(name: &str) -> Result<(), u8> {
if config::validate_value(name).is_err() {
return Err(emit_err(
&format!("invalid remote name '{name}': contains control characters"),
exit::PROTOCOL_ERROR,
));
}
if !mkit_core::refs::validate_ref_name(name)
|| name.contains('.')
|| name == config::DEFAULT_REMOTE_NAME
{
return Err(emit_err(
&format!(
"invalid remote name '{name}': must be a dot-free ref-safe name \
(and not the reserved `default`)"
),
exit::PROTOCOL_ERROR,
));
}
Ok(())
}
fn validate_url(url: &str) -> Option<&'static str> {
for (prefix, kind) in ACCEPTED_SCHEMES {
if url.starts_with(prefix) {
return Some(kind);
}
}
None
}
fn show(cfg: &Config, json: bool, verbose: bool) -> u8 {
let has_default = !cfg.remote_endpoint.is_empty();
if !has_default && cfg.remotes.is_empty() {
if !json {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "(no remote configured)");
}
return exit::OK;
}
let mut stdout = std::io::stdout().lock();
if json {
if has_default && cfg.remotes.is_empty() {
let _ = stdout.write_all(b"{");
let _ = write!(
stdout,
"\"url\":\"{}\"",
format::json_escape(&cfg.remote_endpoint)
);
let _ = write!(
stdout,
",\"transport\":\"{}\"",
format::json_escape(&cfg.remote_type)
);
let _ = stdout.write_all(b"}\n");
return exit::OK;
}
if has_default {
let _ = writeln!(
stdout,
"{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
config::DEFAULT_REMOTE_NAME,
format::json_escape(&cfg.remote_endpoint),
format::json_escape(&cfg.remote_type)
);
}
for (name, entry) in &cfg.remotes {
let _ = writeln!(
stdout,
"{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
format::json_escape(name),
format::json_escape(&entry.url),
format::json_escape(&entry.remote_type)
);
}
return exit::OK;
}
if verbose {
if has_default {
let url = &cfg.remote_endpoint;
let name = config::DEFAULT_REMOTE_NAME;
let _ = writeln!(stdout, "{name}\t{url} (fetch)");
let _ = writeln!(stdout, "{name}\t{url} (push)");
}
for (name, entry) in &cfg.remotes {
let _ = writeln!(stdout, "{name}\t{} (fetch)", entry.url);
let _ = writeln!(stdout, "{name}\t{} (push)", entry.url);
}
return exit::OK;
}
if has_default {
let _ = writeln!(stdout, "{}", config::DEFAULT_REMOTE_NAME);
}
for name in cfg.remotes.keys() {
let _ = writeln!(stdout, "{name}");
}
exit::OK
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::{PathBuf, walk_unprotected};
fn touch(path: &std::path::Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"x").unwrap();
}
#[test]
fn protected_root_is_left_untouched() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
touch(&root.join("keep/marker.txt"));
touch(&root.join("drop.txt"));
let protected = vec![root.join("keep")];
let mut visited = Vec::new();
walk_unprotected(root, &protected, &mut |p| {
visited.push(p.to_path_buf());
if p.is_dir() {
std::fs::remove_dir_all(p)
} else {
std::fs::remove_file(p)
}
})
.unwrap();
assert!(
root.join("keep/marker.txt").exists(),
"protected subtree must survive whole"
);
assert!(
!root.join("drop.txt").exists(),
"unprotected entry must be visited and removed"
);
assert_eq!(visited, vec![root.join("drop.txt")]);
}
#[test]
fn ancestor_of_protected_root_is_recursed_not_removed_whole() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
touch(&root.join("a/b/c/marker.txt")); touch(&root.join("a/other.txt")); let protected = vec![root.join("a/b/c")];
let mut visited = Vec::new();
walk_unprotected(root, &protected, &mut |p| {
visited.push(p.to_path_buf());
if p.is_dir() {
std::fs::remove_dir_all(p)
} else {
std::fs::remove_file(p)
}
})
.unwrap();
assert!(
root.join("a/b/c/marker.txt").exists(),
"deeply nested protected root must survive"
);
assert!(
!root.join("a/other.txt").exists(),
"unprotected file under the ancestor must be removed"
);
assert_eq!(visited, vec![root.join("a/other.txt")]);
assert!(root.join("a").is_dir());
assert!(root.join("a/b").is_dir());
}
#[test]
fn snapshot_is_taken_before_any_mutation() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
touch(&root.join("existing.txt"));
let protected: Vec<PathBuf> = Vec::new();
let mut visited = Vec::new();
walk_unprotected(root, &protected, &mut |p| {
visited.push(p.to_path_buf());
std::fs::write(root.join("created-during-walk.txt"), b"new").unwrap();
std::fs::remove_file(p)
})
.unwrap();
assert_eq!(visited, vec![root.join("existing.txt")]);
assert!(
root.join("created-during-walk.txt").exists(),
"entry created mid-walk must not be picked up by the same walk"
);
}
#[test]
fn dir_is_removed_bottom_up_after_its_entries_are_individually_processed() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
let sub = root.join("a");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("one.txt"), b"1").unwrap();
std::fs::write(sub.join("two.txt"), b"2").unwrap();
let protected: Vec<PathBuf> = Vec::new();
walk_unprotected(&sub, &protected, &mut |p| {
assert!(
p.is_file(),
"each entry is handed to f individually, never the dir itself"
);
std::fs::remove_file(p)
})
.unwrap();
assert!(
!sub.exists(),
"now-empty dir must be removed by the walk itself"
);
}
}