1use std::io::Write;
14
15use clap::{Parser, ValueEnum};
16use mkit_core::layout::RepoLayout;
17use mkit_core::protocol::UploadLimits;
18
19use crate::clap_shim;
20use crate::config;
21use crate::exit;
22use crate::format::JsonObject;
23use crate::remote_dispatch::{self, PushLease};
24
25#[derive(Debug, Clone, Copy, ValueEnum)]
26enum PushFormat {
27 Default,
28 Json,
29}
30
31#[derive(Debug, Parser)]
32#[command(
33 name = "mkit push",
34 about = "Push the current branch to its upstream (or --all branches)."
35)]
36#[allow(clippy::struct_excessive_bools)]
37struct PushOpts {
38 remote: Option<String>,
41 #[arg(long)]
43 all: bool,
44 #[arg(short = 'f', long)]
46 force: bool,
47 #[arg(short = 'u', long = "set-upstream")]
50 set_upstream: bool,
51 #[arg(long)]
53 force_with_lease: bool,
54 #[arg(long)]
56 dry_run: bool,
57 #[arg(long, value_enum, default_value = "default")]
64 format: PushFormat,
65 #[arg(short = 'q', long)]
67 quiet: bool,
68}
69
70fn interrupted_hint(endpoint: &str, limits: UploadLimits) -> &'static str {
71 let connect = endpoint.starts_with("mkit+https://") || endpoint.starts_with("mkit+http://");
72 if connect
73 && limits.tickets_per_advance.is_some()
74 && limits.ticket_threshold_bytes != Some(u64::MAX)
75 {
76 "push: interrupted; if BeginUpload issued a ticket, re-run push to resume the upload"
77 } else {
78 "push: interrupted; re-run push to retry"
79 }
80}
81
82#[must_use]
83pub fn run(args: &[String]) -> u8 {
84 let opts = match clap_shim::parse::<PushOpts>("mkit push", args) {
85 Ok(o) => o,
86 Err(code) => return code,
87 };
88 if opts.force && opts.force_with_lease {
89 return emit_err(
90 "--force and --force-with-lease are mutually exclusive",
91 exit::USAGE,
92 );
93 }
94 let cwd = match std::env::current_dir() {
95 Ok(p) => p,
96 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
97 };
98 let layout = match super::resolve_layout(&cwd) {
99 Ok(layout) => layout,
100 Err(code) => return code,
101 };
102 let cfg = match config::read_layered(&layout) {
103 Ok(c) => c,
104 Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
105 };
106
107 if opts.all {
108 push_all(&layout, &cfg, &opts)
109 } else {
110 push_current(&layout, &cfg, &opts)
111 }
112}
113
114#[allow(clippy::too_many_lines)] fn push_current(layout: &RepoLayout, cfg: &config::LayeredConfig, opts: &PushOpts) -> u8 {
117 let json = matches!(opts.format, PushFormat::Json);
118 let branch = match mkit_core::refs::read_head(layout) {
119 Ok(mkit_core::refs::Head::Branch(b)) => b,
120 Ok(mkit_core::refs::Head::Detached(_)) => {
121 return emit_err_json(
122 "cannot push a detached HEAD; check out a branch first",
123 exit::CONFIG_ERROR,
124 json,
125 );
126 }
127 Err(e) => return emit_err_json(&format!("read HEAD: {e}"), exit::CONFIG_ERROR, json),
128 };
129
130 let (remote_name, remote_branch) = match &opts.remote {
134 Some(name) => (name.clone(), branch.clone()),
135 None => match config::resolve_upstream(cfg, &branch) {
136 Some(up) => (up.remote, up.branch),
137 None => {
138 return emit_err_json(
139 &format!(
140 "no upstream configured for branch '{branch}' and no default remote; \
141 run `mkit push <remote>` to push it (the upstream will be remembered)"
142 ),
143 exit::CONFIG_ERROR,
144 json,
145 );
146 }
147 },
148 };
149
150 let Some(resolved) = config::resolve_remote(cfg, &remote_name) else {
151 return emit_err_json(
152 &format!(
153 "unknown remote '{remote_name}' — add it with `mkit remote add {remote_name} <url>`"
154 ),
155 exit::CONFIG_ERROR,
156 json,
157 );
158 };
159
160 let local_tip = mkit_core::refs::read_ref(layout, &branch).ok().flatten();
163 let old_tracked = mkit_core::refs::read_remote_ref(layout, &resolved.name, &remote_branch)
164 .ok()
165 .flatten();
166 if !opts.force && local_tip.is_some() && local_tip == old_tracked {
169 let mut stderr = std::io::stderr().lock();
170 let _ = writeln!(stderr, "Everything up-to-date");
171 if json {
172 let mut obj = JsonObject::new();
173 obj.field_bool("ok", true)
174 .field_str("remote", &resolved.name)
175 .field_str("endpoint", &resolved.endpoint)
176 .field_str("branch", &branch)
177 .field_str("remote_branch", &remote_branch)
178 .field_opt_hash("old", old_tracked.as_ref())
179 .field_opt_hash("new", old_tracked.as_ref())
180 .field_bool("forced", false)
181 .field_bool("up_to_date", true)
182 .field_u64("steps", 0);
183 emit_json_stdout(obj);
184 }
185 return exit::OK;
186 }
187
188 let lease = lease_for(opts);
189 if opts.dry_run {
190 let mut stderr = std::io::stderr().lock();
191 let _ = writeln!(
192 stderr,
193 "(dry-run) would push {branch} -> {}:{remote_branch} ({})",
194 resolved.name, resolved.endpoint
195 );
196 if json {
197 let mut obj = JsonObject::new();
198 obj.field_bool("ok", true)
199 .field_bool("dry_run", true)
200 .field_str("remote", &resolved.name)
201 .field_str("endpoint", &resolved.endpoint)
202 .field_str("branch", &branch)
203 .field_str("remote_branch", &remote_branch);
204 emit_json_stdout(obj);
205 }
206 return exit::OK;
207 }
208
209 let remote = match remote_dispatch::open_trusted_for_push(
210 &resolved.endpoint,
211 &resolved.name,
212 resolved.repo_chosen,
213 cfg,
214 layout,
215 ) {
216 Ok(remote) => remote,
217 Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
218 return emit_err_json(&msg, exit::CONFIG_ERROR, json);
219 }
220 Err(e) => return emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
221 };
222 let tx = remote.tx;
223
224 let push_outcome = {
225 let _progress = crate::progress::start(
229 "Writing objects",
230 None,
231 crate::progress::should_report(opts.quiet),
232 opts.quiet,
233 );
234 remote_dispatch::push_branch_tracked(
235 layout.worktree_root(),
236 tx.as_ref(),
237 &resolved.name,
238 &branch,
239 &remote_branch,
240 lease,
241 remote.authority.as_deref(),
242 )
243 };
244 match push_outcome.map_err(remote_dispatch::DispatchError::into_published_prefix) {
245 Ok((new_tip, steps)) => {
246 record_upstream(
251 layout,
252 cfg,
253 &branch,
254 &resolved.name,
255 &remote_branch,
256 opts.set_upstream,
257 );
258 let forced =
263 !remote_dispatch::is_fast_forward(layout.worktree_root(), old_tracked, new_tip)
264 .unwrap_or(true);
265 let mut stderr = std::io::stderr().lock();
266 let _ = writeln!(stderr, "To {}", resolved.endpoint);
267 let _ = writeln!(
268 stderr,
269 "{}",
270 crate::format::ref_update_line(
271 old_tracked.as_ref(),
272 &new_tip,
273 &branch,
274 &remote_branch,
275 forced,
276 )
277 );
278 if json {
279 let mut obj = JsonObject::new();
280 obj.field_bool("ok", true)
281 .field_str("remote", &resolved.name)
282 .field_str("endpoint", &resolved.endpoint)
283 .field_str("branch", &branch)
284 .field_str("remote_branch", &remote_branch)
285 .field_opt_hash("old", old_tracked.as_ref())
286 .field_hash("new", &new_tip)
287 .field_bool("forced", forced)
288 .field_bool("up_to_date", false)
289 .field_u64("steps", steps as u64);
290 emit_json_stdout(obj);
291 }
292 exit::OK
293 }
294 Err((remote_dispatch::DispatchError::NonFastForwardPush { branch: rejected }, prefix)) => {
295 let mut stderr = std::io::stderr().lock();
296 let _ = writeln!(stderr, "To {}", resolved.endpoint);
297 let _ = writeln!(
298 stderr,
299 "{}",
300 crate::format::ref_rejected_line(&rejected, &rejected)
301 );
302 drop(stderr);
303 let msg = with_prefix(
304 format!(
305 "updates were rejected for '{rejected}' (non-fast-forward); \
306 `mkit fetch` and merge/rebase first, or re-run with --force-with-lease / --force"
307 ),
308 prefix.as_ref(),
309 );
310 if json {
311 let mut obj = JsonObject::new();
312 obj.field_bool("ok", false)
313 .field_bool("rejected", true)
314 .field_str("remote", &resolved.name)
315 .field_str("endpoint", &resolved.endpoint)
316 .field_str("branch", &rejected)
317 .field_str("remote_branch", &remote_branch)
318 .field_str("error", &msg);
319 emit_json_stdout(obj);
320 }
321 emit_err(&msg, exit::GENERAL_ERROR)
322 }
323 Err((remote_dispatch::DispatchError::UploadInterrupted(message), prefix)) => emit_err_json(
324 &with_prefix(format!("push: {message}"), prefix.as_ref()),
325 exit::TEMPFAIL,
326 json,
327 ),
328 Err((remote_dispatch::DispatchError::Interrupted, prefix)) => emit_err_json(
329 &with_prefix(
330 interrupted_hint(&resolved.endpoint, tx.upload_limits()).to_owned(),
331 prefix.as_ref(),
332 ),
333 exit::TEMPFAIL,
334 json,
335 ),
336 Err((e, prefix)) => emit_push_error(e, prefix.as_ref(), json),
337 }
338}
339
340#[allow(clippy::too_many_lines)] fn push_all(layout: &RepoLayout, cfg: &config::LayeredConfig, opts: &PushOpts) -> u8 {
343 let json = matches!(opts.format, PushFormat::Json);
344 let remote_name = opts
345 .remote
346 .clone()
347 .unwrap_or_else(|| config::DEFAULT_REMOTE_NAME.to_owned());
348 let Some(resolved) = config::resolve_remote(cfg, &remote_name) else {
349 return emit_err_json(
350 "no remote configured — use `mkit remote add <url>`",
351 exit::CONFIG_ERROR,
352 json,
353 );
354 };
355 if opts.dry_run {
356 let mut stderr = std::io::stderr().lock();
357 let _ = writeln!(
358 stderr,
359 "(dry-run) would mirror all branches to {} ({})",
360 resolved.name, resolved.endpoint
361 );
362 if json {
363 let mut obj = JsonObject::new();
364 obj.field_bool("ok", true)
365 .field_bool("dry_run", true)
366 .field_str("remote", &resolved.name)
367 .field_str("endpoint", &resolved.endpoint);
368 emit_json_stdout(obj);
369 }
370 return exit::OK;
371 }
372 let remote = match remote_dispatch::open_trusted_for_push(
373 &resolved.endpoint,
374 &resolved.name,
375 resolved.repo_chosen,
376 cfg,
377 layout,
378 ) {
379 Ok(remote) => remote,
380 Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
381 return emit_err_json(&msg, exit::CONFIG_ERROR, json);
382 }
383 Err(e) => return emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
384 };
385 let tx = remote.tx;
386 let push_outcome = {
387 let _progress = crate::progress::start(
388 "Writing objects",
389 None,
390 crate::progress::should_report(opts.quiet),
391 opts.quiet,
392 );
393 remote_dispatch::push_all_with(
394 layout.worktree_root(),
395 tx.as_ref(),
396 Some(&resolved.name),
397 opts.force,
398 remote.authority.as_deref(),
399 )
400 };
401 match push_outcome.map_err(remote_dispatch::DispatchError::into_published_prefix) {
402 Ok(pushed) => {
403 let n = pushed.refs;
404 let mut stderr = std::io::stderr().lock();
405 let _ = writeln!(
406 stderr,
407 "pushed {n} ref(s) to {} ({})",
408 resolved.name, resolved.endpoint
409 );
410 if json {
411 let mut obj = JsonObject::new();
412 obj.field_bool("ok", true)
413 .field_str("remote", &resolved.name)
414 .field_str("endpoint", &resolved.endpoint)
415 .field_u64("ref_count", n as u64)
416 .field_u64("steps", pushed.steps as u64);
417 emit_json_stdout(obj);
418 }
419 exit::OK
420 }
421 Err((remote_dispatch::DispatchError::NonFastForwardPush { branch }, prefix)) => {
422 let msg = with_prefix(
423 format!(
424 "updates were rejected for '{branch}' (non-fast-forward); \
425 `mkit fetch` first, or re-run with --force"
426 ),
427 prefix.as_ref(),
428 );
429 if json {
430 let mut obj = JsonObject::new();
431 obj.field_bool("ok", false)
432 .field_bool("rejected", true)
433 .field_str("remote", &resolved.name)
434 .field_str("endpoint", &resolved.endpoint)
435 .field_str("branch", &branch)
436 .field_str("error", &msg);
437 emit_json_stdout(obj);
438 }
439 emit_err(&msg, exit::GENERAL_ERROR)
440 }
441 Err((remote_dispatch::DispatchError::UploadInterrupted(message), prefix)) => emit_err_json(
442 &with_prefix(format!("push: {message}"), prefix.as_ref()),
443 exit::TEMPFAIL,
444 json,
445 ),
446 Err((remote_dispatch::DispatchError::Interrupted, prefix)) => emit_err_json(
447 &with_prefix(
448 interrupted_hint(&resolved.endpoint, tx.upload_limits()).to_owned(),
449 prefix.as_ref(),
450 ),
451 exit::TEMPFAIL,
452 json,
453 ),
454 Err((e, prefix)) => emit_push_error(e, prefix.as_ref(), json),
455 }
456}
457
458fn emit_json_stdout(obj: JsonObject) {
460 let mut stdout = std::io::stdout().lock();
461 let _ = writeln!(stdout, "{}", obj.finish());
462}
463
464fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
469 if json {
470 let mut obj = JsonObject::new();
471 obj.field_bool("ok", false).field_str("error", msg);
472 if code == exit::NOPERM {
473 obj.field_bool("admission_required", true);
474 }
475 emit_json_stdout(obj);
476 }
477 emit_err(msg, code)
478}
479
480fn with_prefix(msg: String, prefix: Option<&remote_dispatch::PublishedPrefix>) -> String {
482 match prefix {
483 Some(prefix) => format!("{msg}; {}", prefix.note()),
484 None => msg,
485 }
486}
487
488fn emit_push_error(
489 error: remote_dispatch::DispatchError,
490 prefix: Option<&remote_dispatch::PublishedPrefix>,
491 json: bool,
492) -> u8 {
493 match error {
494 remote_dispatch::DispatchError::Transport(
495 mkit_core::protocol::TransportError::AdmissionRequired(required),
496 ) => {
497 let hint = if required.reason.is_none() {
500 "\nhint: configure admission_helper and trust this remote with mkit config trusted_remote_endpoint"
501 } else {
502 ""
503 };
504 emit_err_json(&format!("push: {required}{hint}"), exit::NOPERM, json)
505 }
506 remote_dispatch::DispatchError::Transport(
507 mkit_core::protocol::TransportError::AdmissionConfiguration(message),
508 ) => emit_err_json(
509 &format!("push: admission configuration: {message}"),
510 exit::CONFIG_ERROR,
511 json,
512 ),
513 other => emit_err_json(
514 &with_prefix(format!("push: {other}"), prefix),
515 exit::GENERAL_ERROR,
516 json,
517 ),
518 }
519}
520
521fn lease_for(opts: &PushOpts) -> PushLease {
522 if opts.force {
523 PushLease::Force
524 } else if opts.force_with_lease {
525 PushLease::WithLease
526 } else {
527 PushLease::FastForward
528 }
529}
530
531fn record_upstream(
535 layout: &RepoLayout,
536 cfg: &config::LayeredConfig,
537 branch: &str,
538 remote: &str,
539 remote_branch: &str,
540 force: bool,
541) {
542 if !force
545 && cfg
546 .merged
547 .branch_upstreams
548 .get(branch)
549 .is_some_and(|u| !u.remote.is_empty())
550 {
551 return;
552 }
553 let Ok(layered) = config::read_layered(layout) else {
558 return;
559 };
560 let mut on_disk = layered.repo;
561 on_disk.branch_upstreams.insert(
562 branch.to_owned(),
563 config::Upstream {
564 remote: remote.to_owned(),
565 branch: remote_branch.to_owned(),
566 },
567 );
568 let _ = config::write(layout, &on_disk);
569}
570
571use super::error as emit_err;
572
573#[cfg(test)]
574mod interrupted_hint_tests {
575 use super::*;
576
577 #[test]
578 fn begin_upload_hint_only_for_ticketing_connect_remote() {
579 let ticketing = UploadLimits {
580 tickets_per_advance: Some(7),
581 ticket_threshold_bytes: Some(0),
582 ..UploadLimits::default()
583 };
584 assert!(
585 interrupted_hint("mkit+https://example.test/repo", ticketing).contains("BeginUpload")
586 );
587 assert!(!interrupted_hint("file:///repo", ticketing).contains("BeginUpload"));
588 assert!(
589 !interrupted_hint("mkit+https://example.test/repo", UploadLimits::default())
590 .contains("BeginUpload")
591 );
592 }
593}