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;
17
18use crate::clap_shim;
19use crate::config;
20use crate::exit;
21use crate::format::JsonObject;
22use crate::remote_dispatch::{self, PushLease};
23
24#[derive(Debug, Clone, Copy, ValueEnum)]
25enum PushFormat {
26    Default,
27    Json,
28}
29
30#[derive(Debug, Parser)]
31#[command(
32    name = "mkit push",
33    about = "Push the current branch to its upstream (or --all branches)."
34)]
35#[allow(clippy::struct_excessive_bools)]
36struct PushOpts {
37    /// Remote name to push to (defaults to the branch's upstream remote,
38    /// else the configured default remote).
39    remote: Option<String>,
40    /// Mirror every local branch instead of just the current one.
41    #[arg(long)]
42    all: bool,
43    /// Overwrite the remote branch unconditionally (skip CAS).
44    #[arg(short = 'f', long)]
45    force: bool,
46    /// Record the pushed remote as this branch's upstream, even if one is
47    /// already set (`git push -u` / `--set-upstream`).
48    #[arg(short = 'u', long = "set-upstream")]
49    set_upstream: bool,
50    /// Overwrite only if the remote hasn't moved past our last-seen tip.
51    #[arg(long)]
52    force_with_lease: bool,
53    /// Print what would be pushed without contacting the remote.
54    #[arg(long)]
55    dry_run: bool,
56    /// Emit a machine-readable JSON result object to stdout:
57    /// `{"ok":true,"remote":"...","endpoint":"...","branch":"...",
58    /// "remote_branch":"...","old":"<hex>|null","new":"<hex>",
59    /// "forced":<bool>,"up_to_date":<bool>}` on success, or
60    /// `{"ok":false,"error":"...","rejected":<bool>,...}` on a
61    /// non-fast-forward (CAS) rejection.
62    #[arg(long, value_enum, default_value = "default")]
63    format: PushFormat,
64    /// Suppress transfer progress output on stderr (#711).
65    #[arg(short = 'q', long)]
66    quiet: bool,
67}
68
69#[must_use]
70pub fn run(args: &[String]) -> u8 {
71    let opts = match clap_shim::parse::<PushOpts>("mkit push", args) {
72        Ok(o) => o,
73        Err(code) => return code,
74    };
75    if opts.force && opts.force_with_lease {
76        return emit_err(
77            "--force and --force-with-lease are mutually exclusive",
78            exit::USAGE,
79        );
80    }
81    let cwd = match std::env::current_dir() {
82        Ok(p) => p,
83        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
84    };
85    let layout = match super::resolve_layout(&cwd) {
86        Ok(layout) => layout,
87        Err(code) => return code,
88    };
89    let cfg = match config::read_layered(&layout) {
90        Ok(c) => c,
91        Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
92    };
93
94    if opts.all {
95        push_all(&layout, &cfg, &opts)
96    } else {
97        push_current(&layout, &cfg, &opts)
98    }
99}
100
101/// Default push: current branch → its upstream, CAS-protected.
102#[allow(clippy::too_many_lines)] // linear flow: resolve + no-op + push + report
103fn push_current(layout: &RepoLayout, cfg: &config::LayeredConfig, opts: &PushOpts) -> u8 {
104    let json = matches!(opts.format, PushFormat::Json);
105    let branch = match mkit_core::refs::read_head(layout) {
106        Ok(mkit_core::refs::Head::Branch(b)) => b,
107        Ok(mkit_core::refs::Head::Detached(_)) => {
108            return emit_err_json(
109                "cannot push a detached HEAD; check out a branch first",
110                exit::CONFIG_ERROR,
111                json,
112            );
113        }
114        Err(e) => return emit_err_json(&format!("read HEAD: {e}"), exit::CONFIG_ERROR, json),
115    };
116
117    // Resolve the (remote, remote-branch) to push to. An explicit
118    // `mkit push <remote> [branch]`-style positional remote overrides
119    // the configured upstream; otherwise fall back to the upstream.
120    let (remote_name, remote_branch) = match &opts.remote {
121        Some(name) => (name.clone(), branch.clone()),
122        None => match config::resolve_upstream(cfg, &branch) {
123            Some(up) => (up.remote, up.branch),
124            None => {
125                return emit_err_json(
126                    &format!(
127                        "no upstream configured for branch '{branch}' and no default remote; \
128                         run `mkit push <remote>` to push it (the upstream will be remembered)"
129                    ),
130                    exit::CONFIG_ERROR,
131                    json,
132                );
133            }
134        },
135    };
136
137    let Some(resolved) = config::resolve_remote(cfg, &remote_name) else {
138        return emit_err_json(
139            &format!(
140                "unknown remote '{remote_name}' — add it with `mkit remote add {remote_name} <url>`"
141            ),
142            exit::CONFIG_ERROR,
143            json,
144        );
145    };
146
147    // Snapshot the local tip and the last-seen remote-tracking ref so we
148    // can render git's ref-update summary block and detect a no-op push.
149    let local_tip = mkit_core::refs::read_ref(layout, &branch).ok().flatten();
150    let old_tracked = mkit_core::refs::read_remote_ref(layout, &resolved.name, &remote_branch)
151        .ok()
152        .flatten();
153    // Nothing to do when the remote-tracking ref already matches the local
154    // tip (and we're not forcing). Matches git's `Everything up-to-date`.
155    if !opts.force && local_tip.is_some() && local_tip == old_tracked {
156        let mut stderr = std::io::stderr().lock();
157        let _ = writeln!(stderr, "Everything up-to-date");
158        if json {
159            let mut obj = JsonObject::new();
160            obj.field_bool("ok", true)
161                .field_str("remote", &resolved.name)
162                .field_str("endpoint", &resolved.endpoint)
163                .field_str("branch", &branch)
164                .field_str("remote_branch", &remote_branch)
165                .field_opt_hash("old", old_tracked.as_ref())
166                .field_opt_hash("new", old_tracked.as_ref())
167                .field_bool("forced", false)
168                .field_bool("up_to_date", true);
169            emit_json_stdout(obj);
170        }
171        return exit::OK;
172    }
173
174    let lease = lease_for(opts);
175    if opts.dry_run {
176        let mut stderr = std::io::stderr().lock();
177        let _ = writeln!(
178            stderr,
179            "(dry-run) would push {branch} -> {}:{remote_branch} ({})",
180            resolved.name, resolved.endpoint
181        );
182        if json {
183            let mut obj = JsonObject::new();
184            obj.field_bool("ok", true)
185                .field_bool("dry_run", true)
186                .field_str("remote", &resolved.name)
187                .field_str("endpoint", &resolved.endpoint)
188                .field_str("branch", &branch)
189                .field_str("remote_branch", &remote_branch);
190            emit_json_stdout(obj);
191        }
192        return exit::OK;
193    }
194
195    let tx = match remote_dispatch::open_trusted(
196        &resolved.endpoint,
197        resolved.repo_chosen,
198        cfg,
199        layout,
200    ) {
201        Ok(tx) => tx,
202        Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
203            return emit_err_json(&msg, exit::CONFIG_ERROR, json);
204        }
205        Err(e) => return emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
206    };
207
208    let push_outcome = {
209        // Scoped tightly around the transfer call so the progress
210        // guard's final `, done.` line lands before the git-shaped
211        // `To <url>` / ref-update summary printed below, not after it.
212        let _progress = crate::progress::start(
213            "Writing objects",
214            None,
215            crate::progress::should_report(opts.quiet),
216        );
217        remote_dispatch::push_branch_tracked(
218            layout.worktree_root(),
219            tx.as_ref(),
220            &resolved.name,
221            &branch,
222            &remote_branch,
223            lease,
224        )
225    };
226    match push_outcome {
227        Ok(new_tip) => {
228            // Remember the upstream so a bare `mkit push` works next
229            // time (Git-like first-push convenience). Only persisted
230            // when not already set, and never for a detached/forced
231            // overwrite of an unrelated branch.
232            record_upstream(
233                layout,
234                cfg,
235                &branch,
236                &resolved.name,
237                &remote_branch,
238                opts.set_upstream,
239            );
240            // git-style ref-update summary block: `To <url>` then one
241            // `<old>..<new>` / `* [new branch]` / `+ …(forced)` line.
242            // On a store error during the ancestry check, assume a
243            // fast-forward (don't mislabel an ordinary push as forced).
244            let forced =
245                !remote_dispatch::is_fast_forward(layout.worktree_root(), old_tracked, new_tip)
246                    .unwrap_or(true);
247            let mut stderr = std::io::stderr().lock();
248            let _ = writeln!(stderr, "To {}", resolved.endpoint);
249            let _ = writeln!(
250                stderr,
251                "{}",
252                crate::format::ref_update_line(
253                    old_tracked.as_ref(),
254                    &new_tip,
255                    &branch,
256                    &remote_branch,
257                    forced,
258                )
259            );
260            if json {
261                let mut obj = JsonObject::new();
262                obj.field_bool("ok", true)
263                    .field_str("remote", &resolved.name)
264                    .field_str("endpoint", &resolved.endpoint)
265                    .field_str("branch", &branch)
266                    .field_str("remote_branch", &remote_branch)
267                    .field_opt_hash("old", old_tracked.as_ref())
268                    .field_hash("new", &new_tip)
269                    .field_bool("forced", forced)
270                    .field_bool("up_to_date", false);
271                emit_json_stdout(obj);
272            }
273            exit::OK
274        }
275        Err(remote_dispatch::DispatchError::NonFastForwardPush { branch: rejected }) => {
276            let mut stderr = std::io::stderr().lock();
277            let _ = writeln!(stderr, "To {}", resolved.endpoint);
278            let _ = writeln!(
279                stderr,
280                "{}",
281                crate::format::ref_rejected_line(&rejected, &rejected)
282            );
283            drop(stderr);
284            let msg = format!(
285                "updates were rejected for '{rejected}' (non-fast-forward); \
286                 `mkit fetch` and merge/rebase first, or re-run with --force-with-lease / --force"
287            );
288            if json {
289                let mut obj = JsonObject::new();
290                obj.field_bool("ok", false)
291                    .field_bool("rejected", true)
292                    .field_str("remote", &resolved.name)
293                    .field_str("endpoint", &resolved.endpoint)
294                    .field_str("branch", &rejected)
295                    .field_str("remote_branch", &remote_branch)
296                    .field_str("error", &msg);
297                emit_json_stdout(obj);
298            }
299            emit_err(&msg, exit::GENERAL_ERROR)
300        }
301        Err(remote_dispatch::DispatchError::Interrupted) => {
302            emit_err_json("push: interrupted", exit::TEMPFAIL, json)
303        }
304        Err(e) => emit_err_json(&format!("push: {e}"), exit::GENERAL_ERROR, json),
305    }
306}
307
308/// `--all`: mirror every local branch to the remote (CAS-safe).
309fn push_all(layout: &RepoLayout, cfg: &config::LayeredConfig, opts: &PushOpts) -> u8 {
310    let json = matches!(opts.format, PushFormat::Json);
311    let remote_name = opts
312        .remote
313        .clone()
314        .unwrap_or_else(|| config::DEFAULT_REMOTE_NAME.to_owned());
315    let Some(resolved) = config::resolve_remote(cfg, &remote_name) else {
316        return emit_err_json(
317            "no remote configured — use `mkit remote add <url>`",
318            exit::CONFIG_ERROR,
319            json,
320        );
321    };
322    if opts.dry_run {
323        let mut stderr = std::io::stderr().lock();
324        let _ = writeln!(
325            stderr,
326            "(dry-run) would mirror all branches to {} ({})",
327            resolved.name, resolved.endpoint
328        );
329        if json {
330            let mut obj = JsonObject::new();
331            obj.field_bool("ok", true)
332                .field_bool("dry_run", true)
333                .field_str("remote", &resolved.name)
334                .field_str("endpoint", &resolved.endpoint);
335            emit_json_stdout(obj);
336        }
337        return exit::OK;
338    }
339    let tx = match remote_dispatch::open_trusted(
340        &resolved.endpoint,
341        resolved.repo_chosen,
342        cfg,
343        layout,
344    ) {
345        Ok(tx) => tx,
346        Err(remote_dispatch::DispatchError::UntrustedRemote(msg)) => {
347            return emit_err_json(&msg, exit::CONFIG_ERROR, json);
348        }
349        Err(e) => return emit_err_json(&format!("open remote: {e}"), exit::PROTOCOL_ERROR, json),
350    };
351    let push_outcome = {
352        let _progress = crate::progress::start(
353            "Writing objects",
354            None,
355            crate::progress::should_report(opts.quiet),
356        );
357        remote_dispatch::push_all_with(
358            layout.worktree_root(),
359            tx.as_ref(),
360            Some(&resolved.name),
361            opts.force,
362        )
363    };
364    match push_outcome {
365        Ok(n) => {
366            let mut stderr = std::io::stderr().lock();
367            let _ = writeln!(
368                stderr,
369                "pushed {n} ref(s) to {} ({})",
370                resolved.name, resolved.endpoint
371            );
372            if json {
373                let mut obj = JsonObject::new();
374                obj.field_bool("ok", true)
375                    .field_str("remote", &resolved.name)
376                    .field_str("endpoint", &resolved.endpoint)
377                    .field_u64("ref_count", n as u64);
378                emit_json_stdout(obj);
379            }
380            exit::OK
381        }
382        Err(remote_dispatch::DispatchError::NonFastForwardPush { branch }) => {
383            let msg = format!(
384                "updates were rejected for '{branch}' (non-fast-forward); \
385                 `mkit fetch` first, or re-run with --force"
386            );
387            if json {
388                let mut obj = JsonObject::new();
389                obj.field_bool("ok", false)
390                    .field_bool("rejected", true)
391                    .field_str("remote", &resolved.name)
392                    .field_str("endpoint", &resolved.endpoint)
393                    .field_str("branch", &branch)
394                    .field_str("error", &msg);
395                emit_json_stdout(obj);
396            }
397            emit_err(&msg, exit::GENERAL_ERROR)
398        }
399        Err(remote_dispatch::DispatchError::Interrupted) => {
400            emit_err_json("push: interrupted", exit::TEMPFAIL, json)
401        }
402        Err(e) => emit_err_json(&format!("push: {e}"), exit::GENERAL_ERROR, json),
403    }
404}
405
406/// Consume a [`JsonObject`] and print it as one line to stdout.
407fn emit_json_stdout(obj: JsonObject) {
408    let mut stdout = std::io::stdout().lock();
409    let _ = writeln!(stdout, "{}", obj.finish());
410}
411
412/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
413/// line on stdout — so every exit path (not just the documented
414/// CAS-rejection shape) leaves `--format=json` callers with a
415/// self-contained stdout payload.
416fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
417    if json {
418        let mut obj = JsonObject::new();
419        obj.field_bool("ok", false).field_str("error", msg);
420        emit_json_stdout(obj);
421    }
422    emit_err(msg, code)
423}
424
425fn lease_for(opts: &PushOpts) -> PushLease {
426    if opts.force {
427        PushLease::Force
428    } else if opts.force_with_lease {
429        PushLease::WithLease
430    } else {
431        PushLease::FastForward
432    }
433}
434
435/// Persist `branch.<b>.{remote,merge}` after a successful first push, so
436/// a subsequent bare `mkit push` resolves the upstream. Best-effort: a
437/// write failure is non-fatal (the push already succeeded).
438fn record_upstream(
439    layout: &RepoLayout,
440    cfg: &config::LayeredConfig,
441    branch: &str,
442    remote: &str,
443    remote_branch: &str,
444    force: bool,
445) {
446    // Without `-u`, only record on the FIRST push (git-like convenience);
447    // `-u`/`--set-upstream` re-points the upstream even if already set.
448    if !force
449        && cfg
450            .merged
451            .branch_upstreams
452            .get(branch)
453            .is_some_and(|u| !u.remote.is_empty())
454    {
455        return;
456    }
457    // Re-read the on-disk REPO config (not the merged view) and add the
458    // upstream entry without disturbing the existing remotes / flat
459    // fields. Using the repo layer ensures user-scoped values (e.g. a
460    // private `user.email`) are never materialized into `.mkit/config`.
461    let Ok(layered) = config::read_layered(layout) else {
462        return;
463    };
464    let mut on_disk = layered.repo;
465    on_disk.branch_upstreams.insert(
466        branch.to_owned(),
467        config::Upstream {
468            remote: remote.to_owned(),
469            branch: remote_branch.to_owned(),
470        },
471    );
472    let _ = config::write(layout, &on_disk);
473}
474
475use super::error as emit_err;