Skip to main content

mkit_cli/commands/
push.rs

1//! `mkit push` — push refs/packs to a remote with CAS safety.
2//!
3//! Default (no `--all`): push the current branch to its upstream only,
4//! with non-fast-forward rejection via CAS (the remote-tracking ref is
5//! the lease). `--all` mirrors every `refs/heads/*` (now CAS-safe).
6//! `--force` / `--force-with-lease` control the CAS policy; `--dry-run`
7//! resolves the plan without contacting the remote.
8//!
9//! Every endpoint flows through `remote_dispatch::open_trusted`, so the
10//! #97 per-endpoint credential gate applies to named remotes too —
11//! trust is keyed on the resolved ENDPOINT, never the remote name.
12
13use 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 name to push to (defaults to the branch's upstream remote,
39    /// else the configured default remote).
40    remote: Option<String>,
41    /// Mirror every local branch instead of just the current one.
42    #[arg(long)]
43    all: bool,
44    /// Overwrite the remote branch unconditionally (skip CAS).
45    #[arg(short = 'f', long)]
46    force: bool,
47    /// Record the pushed remote as this branch's upstream, even if one is
48    /// already set (`git push -u` / `--set-upstream`).
49    #[arg(short = 'u', long = "set-upstream")]
50    set_upstream: bool,
51    /// Overwrite only if the remote hasn't moved past our last-seen tip.
52    #[arg(long)]
53    force_with_lease: bool,
54    /// Print what would be pushed without contacting the remote.
55    #[arg(long)]
56    dry_run: bool,
57    /// Emit a machine-readable JSON result object to stdout:
58    /// `{"ok":true,"remote":"...","endpoint":"...","branch":"...",
59    /// "remote_branch":"...","old":"<hex>|null","new":"<hex>",
60    /// "forced":<bool>,"up_to_date":<bool>,"steps":<n>}` on success, or
61    /// `{"ok":false,"error":"...","rejected":<bool>,...}` on a
62    /// non-fast-forward (CAS) rejection.
63    #[arg(long, value_enum, default_value = "default")]
64    format: PushFormat,
65    /// Suppress transfer progress output on stderr (#711).
66    #[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/// Default push: current branch → its upstream, CAS-protected.
115#[allow(clippy::too_many_lines)] // linear flow: resolve + no-op + push + report
116fn 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    // Resolve the (remote, remote-branch) to push to. An explicit
131    // `mkit push <remote> [branch]`-style positional remote overrides
132    // the configured upstream; otherwise fall back to the upstream.
133    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    // Snapshot the local tip and the last-seen remote-tracking ref so we
161    // can render git's ref-update summary block and detect a no-op push.
162    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    // Nothing to do when the remote-tracking ref already matches the local
167    // tip (and we're not forcing). Matches git's `Everything up-to-date`.
168    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        // Scoped tightly around the transfer call so the progress
226        // guard's final `, done.` line lands before the git-shaped
227        // `To <url>` / ref-update summary printed below, not after it.
228        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            // Remember the upstream so a bare `mkit push` works next
247            // time (Git-like first-push convenience). Only persisted
248            // when not already set, and never for a detached/forced
249            // overwrite of an unrelated branch.
250            record_upstream(
251                layout,
252                cfg,
253                &branch,
254                &resolved.name,
255                &remote_branch,
256                opts.set_upstream,
257            );
258            // git-style ref-update summary block: `To <url>` then one
259            // `<old>..<new>` / `* [new branch]` / `+ …(forced)` line.
260            // On a store error during the ancestry check, assume a
261            // fast-forward (don't mislabel an ordinary push as forced).
262            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/// `--all`: mirror every local branch to the remote (CAS-safe).
341#[allow(clippy::too_many_lines)] // Linear branch loop keeps each push result and hint together.
342fn 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
458/// Consume a [`JsonObject`] and print it as one line to stdout.
459fn emit_json_stdout(obj: JsonObject) {
460    let mut stdout = std::io::stdout().lock();
461    let _ = writeln!(stdout, "{}", obj.finish());
462}
463
464/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
465/// line on stdout — so every exit path (not just the documented
466/// CAS-rejection shape) leaves `--format=json` callers with a
467/// self-contained stdout payload.
468fn 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
480/// `msg`, followed by the published prefix a split push left behind, if any.
481fn 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            // The hint helps only when no helper ran; after a helper ran, the
498            // reason (a second challenge or the run limit) says why it stopped.
499            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
531/// Persist `branch.<b>.{remote,merge}` after a successful first push, so
532/// a subsequent bare `mkit push` resolves the upstream. Best-effort: a
533/// write failure is non-fatal (the push already succeeded).
534fn record_upstream(
535    layout: &RepoLayout,
536    cfg: &config::LayeredConfig,
537    branch: &str,
538    remote: &str,
539    remote_branch: &str,
540    force: bool,
541) {
542    // Without `-u`, only record on the FIRST push (git-like convenience);
543    // `-u`/`--set-upstream` re-points the upstream even if already set.
544    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    // Re-read the on-disk REPO config (not the merged view) and add the
554    // upstream entry without disturbing the existing remotes / flat
555    // fields. Using the repo layer ensures user-scoped values (e.g. a
556    // private `user.email`) are never materialized into `.mkit/config`.
557    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}