1use std::io::Write;
7
8use clap::{Parser, Subcommand, ValueEnum};
9
10use crate::clap_shim;
11use crate::config::{self, Config, RemoteEntry};
12use crate::exit;
13use crate::format;
14
15const ACCEPTED_SCHEMES: &[(&str, &str)] = &[
16 ("mkit+file://", "file"),
17 ("mkit+https://", "http"),
18 ("mkit+s3://", "s3"),
19 ("mkit+ssh://", "ssh"),
20 ("mkit+memory://", "memory"),
21 ("git+https://", "git"),
25 ("git+ssh://", "git"),
26 ("git+file://", "git"),
27];
28
29#[derive(Debug, Clone, Copy, ValueEnum)]
30enum RemoteFormat {
31 Default,
32 Json,
33}
34
35#[derive(Debug, Parser)]
36#[command(name = "mkit remote", about = "Show or configure the remote.")]
37struct RemoteOpts {
38 #[arg(long, value_enum, default_value = "default")]
40 format: RemoteFormat,
41 #[command(subcommand)]
42 sub: Option<RemoteCmd>,
43}
44
45#[derive(Debug, Subcommand)]
46enum RemoteCmd {
47 Add {
52 name_or_url: String,
53 url: Option<String>,
54 },
55 Set {
57 name_or_url: String,
58 url: Option<String>,
59 },
60 #[command(alias = "rm")]
63 Remove { name: String },
64 #[command(alias = "mv")]
67 Rename { old: String, new: String },
68}
69
70#[must_use]
71#[allow(clippy::too_many_lines)] pub fn run(args: &[String]) -> u8 {
73 let opts = match clap_shim::parse::<RemoteOpts>("mkit remote", args) {
74 Ok(o) => o,
75 Err(code) => return code,
76 };
77 let cwd = match std::env::current_dir() {
78 Ok(p) => p,
79 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
80 };
81 let layered = match config::read_layered(&cwd) {
82 Ok(c) => c,
83 Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
84 };
85 if opts.sub.is_none() {
90 return show(&layered.merged, matches!(opts.format, RemoteFormat::Json));
91 }
92 let mut cfg = layered.repo;
93
94 match opts.sub {
95 None => unreachable!("handled above"),
96 Some(RemoteCmd::Add { name_or_url, url } | RemoteCmd::Set { name_or_url, url }) => {
97 let (name, url) = match url {
101 Some(url) => (Some(name_or_url), url),
102 None => (None, name_or_url),
103 };
104 if config::validate_value(&url).is_err() {
109 return emit_err(
110 &format!("invalid remote URL '{url}': contains control characters"),
111 exit::PROTOCOL_ERROR,
112 );
113 }
114 let Some(scheme) = validate_url(&url) else {
115 return emit_err(
116 &format!(
117 "invalid remote URL '{url}': must start with 'mkit+<scheme>://'\n\
118 hint: URL must start with mkit+<scheme>:// (e.g. mkit+https://, mkit+ssh://, mkit+file://, mkit+s3://)",
119 ),
120 exit::PROTOCOL_ERROR,
121 );
122 };
123 if let Some(name) = name {
124 if let Err(code) = validate_remote_name(&name) {
125 return code;
126 }
127 cfg.remotes.insert(
128 name,
129 RemoteEntry {
130 url,
131 remote_type: scheme.to_owned(),
132 },
133 );
134 } else {
135 cfg.remote_endpoint = url;
136 scheme.clone_into(&mut cfg.remote_type);
137 }
138 match config::write(&cwd, &cfg) {
139 Ok(()) => exit::OK,
140 Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
141 }
142 }
143 Some(RemoteCmd::Remove { name }) => {
144 if name == config::DEFAULT_REMOTE_NAME {
152 if cfg.remote_endpoint.is_empty() {
153 return emit_err("no default remote configured", exit::GENERAL_ERROR);
154 }
155 cfg.remote_endpoint.clear();
156 cfg.remote_type.clear();
157 cfg.remote_bucket.clear();
158 } else if cfg.remotes.remove(&name).is_none() {
159 return emit_err(&format!("remote '{name}' not found"), exit::GENERAL_ERROR);
160 }
161 match config::write(&cwd, &cfg) {
162 Ok(()) => {
163 remove_tracking_refs(&cwd, &name);
166 warn_orphaned_bridge_state(&cwd, &name);
167 exit::OK
168 }
169 Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
170 }
171 }
172 Some(RemoteCmd::Rename { old, new }) => {
173 if old == config::DEFAULT_REMOTE_NAME || new == config::DEFAULT_REMOTE_NAME {
174 return emit_err(
175 "cannot rename the reserved `default` remote; use `remote add`/`remote remove`",
176 exit::PROTOCOL_ERROR,
177 );
178 }
179 if let Err(code) = validate_remote_name(&new) {
180 return code;
181 }
182 let Some(entry) = cfg.remotes.remove(&old) else {
183 return emit_err(&format!("remote '{old}' not found"), exit::GENERAL_ERROR);
184 };
185 if cfg.remotes.contains_key(&new) {
186 cfg.remotes.insert(old, entry);
188 return emit_err(&format!("remote '{new}' already exists"), exit::CANTCREAT);
189 }
190 cfg.remotes.insert(new.clone(), entry);
191 for up in cfg.branch_upstreams.values_mut() {
193 if up.remote == old {
194 up.remote.clone_from(&new);
195 }
196 }
197 match config::write(&cwd, &cfg) {
198 Ok(()) => {
199 move_tracking_refs(&cwd, &old, &new);
200 move_bridge_state(&cwd, &old, &new);
201 exit::OK
202 }
203 Err(e) => emit_err(&format!("write: {e}"), exit::CANTCREAT),
204 }
205 }
206 }
207}
208
209fn move_tracking_refs(cwd: &std::path::Path, old: &str, new: &str) {
213 let remotes = cwd
214 .join(mkit_core::MKIT_DIR)
215 .join(mkit_core::refs::REMOTES_DIR);
216 let (src, dst) = (remotes.join(old), remotes.join(new));
217 if src.is_dir()
218 && let Err(e) = std::fs::rename(&src, &dst)
219 {
220 let mut stderr = std::io::stderr().lock();
221 let _ = writeln!(
222 stderr,
223 "warning: could not move tracking refs {old} -> {new}: {e}; \
224 run `mkit fetch {new}` to repopulate"
225 );
226 }
227}
228
229fn move_bridge_state(cwd: &std::path::Path, old: &str, new: &str) {
232 let base = cwd.join(mkit_core::MKIT_DIR).join("git");
233 let (src, dst) = (base.join(old), base.join(new));
234 if src.is_dir()
235 && let Err(e) = std::fs::rename(&src, &dst)
236 {
237 let mut stderr = std::io::stderr().lock();
238 let _ = writeln!(
239 stderr,
240 "warning: could not move git-bridge state {old} -> {new}: {e}"
241 );
242 }
243}
244
245fn warn_orphaned_bridge_state(cwd: &std::path::Path, name: &str) {
249 let dir = cwd.join(mkit_core::MKIT_DIR).join("git").join(name);
250 if dir.is_dir() {
251 let mut stderr = std::io::stderr().lock();
252 let _ = writeln!(
253 stderr,
254 "note: git-bridge state for '{name}' remains at .mkit/git/{name}/ \
255 (staging mirror + provenance); delete it manually if unwanted"
256 );
257 }
258}
259
260fn remove_tracking_refs(cwd: &std::path::Path, name: &str) {
262 let dir = cwd
263 .join(mkit_core::MKIT_DIR)
264 .join(mkit_core::refs::REMOTES_DIR)
265 .join(name);
266 if dir.is_dir()
267 && let Err(e) = std::fs::remove_dir_all(&dir)
268 {
269 let mut stderr = std::io::stderr().lock();
270 let _ = writeln!(
271 stderr,
272 "warning: could not remove tracking refs for '{name}': {e}"
273 );
274 }
275}
276
277fn validate_remote_name(name: &str) -> Result<(), u8> {
282 if config::validate_value(name).is_err() {
283 return Err(emit_err(
284 &format!("invalid remote name '{name}': contains control characters"),
285 exit::PROTOCOL_ERROR,
286 ));
287 }
288 if !mkit_core::refs::validate_ref_name(name)
289 || name.contains('.')
290 || name == config::DEFAULT_REMOTE_NAME
291 {
292 return Err(emit_err(
293 &format!(
294 "invalid remote name '{name}': must be a dot-free ref-safe name \
295 (and not the reserved `default`)"
296 ),
297 exit::PROTOCOL_ERROR,
298 ));
299 }
300 Ok(())
301}
302
303fn validate_url(url: &str) -> Option<&'static str> {
304 for (prefix, kind) in ACCEPTED_SCHEMES {
305 if url.starts_with(prefix) {
306 return Some(kind);
307 }
308 }
309 None
310}
311
312fn show(cfg: &Config, json: bool) -> u8 {
313 let has_default = !cfg.remote_endpoint.is_empty();
314 if !has_default && cfg.remotes.is_empty() {
315 if !json {
318 let mut stderr = std::io::stderr().lock();
319 let _ = writeln!(stderr, "(no remote configured)");
320 }
321 return exit::OK;
322 }
323 let mut stdout = std::io::stdout().lock();
324 if json {
325 if has_default && cfg.remotes.is_empty() {
331 let _ = stdout.write_all(b"{");
332 let _ = write!(
333 stdout,
334 "\"url\":\"{}\"",
335 format::json_escape(&cfg.remote_endpoint)
336 );
337 let _ = write!(
338 stdout,
339 ",\"transport\":\"{}\"",
340 format::json_escape(&cfg.remote_type)
341 );
342 let _ = stdout.write_all(b"}\n");
343 return exit::OK;
344 }
345 if has_default {
346 let _ = writeln!(
347 stdout,
348 "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
349 config::DEFAULT_REMOTE_NAME,
350 format::json_escape(&cfg.remote_endpoint),
351 format::json_escape(&cfg.remote_type)
352 );
353 }
354 for (name, entry) in &cfg.remotes {
355 let _ = writeln!(
356 stdout,
357 "{{\"name\":\"{}\",\"url\":\"{}\",\"transport\":\"{}\"}}",
358 format::json_escape(name),
359 format::json_escape(&entry.url),
360 format::json_escape(&entry.remote_type)
361 );
362 }
363 return exit::OK;
364 }
365 if has_default && cfg.remotes.is_empty() {
368 let _ = writeln!(stdout, "remote_endpoint = {}", cfg.remote_endpoint);
369 let _ = writeln!(stdout, "remote_type = {}", cfg.remote_type);
370 return exit::OK;
371 }
372 if has_default {
373 let _ = writeln!(
374 stdout,
375 "{}\t{} ({})",
376 config::DEFAULT_REMOTE_NAME,
377 cfg.remote_endpoint,
378 cfg.remote_type
379 );
380 }
381 for (name, entry) in &cfg.remotes {
382 let _ = writeln!(stdout, "{name}\t{} ({})", entry.url, entry.remote_type);
383 }
384 exit::OK
385}
386
387fn emit_err(msg: &str, code: u8) -> u8 {
388 let mut stderr = std::io::stderr().lock();
389 let _ = writeln!(stderr, "error: {msg}");
390 code
391}