Skip to main content

amont_runtime/
land.rs

1//! `amont land` — push the current branch, then prove the remote ref moved.
2//!
3//! A push that exits 0 says the remote ACCEPTED something. It does not say the
4//! branch now points where you pushed it: a forge that loses an accepted ref
5//! update, a replica that has not caught up, and a `| tail` that threw the
6//! exit status away all end the same way, a green terminal and a stale
7//! branch. The proof is one question asked of the remote afterwards, and this
8//! verb asks it every time, so nobody has to remember to.
9//!
10//! It wraps the push and the proof and nothing else. The gates, the stamps,
11//! the rehearsal wait and the attestation all stay in the pre-push hook the
12//! push runs; `land` neither re-implements nor skips them.
13//!
14//! Exit codes (`2` stays the binary-wide usage error):
15//!
16//! | code | status         | meaning                                              |
17//! |------|----------------|------------------------------------------------------|
18//! | 0    | `landed`       | the remote ref equals the pushed sha                 |
19//! | 1    | `refused`      | a hook, the server, or a precondition said no        |
20//! | 2    | `usage`        | wrong invocation                                     |
21//! | 3    | `unreachable`  | the remote could not be reached, or the push was    |
22//! |      | / `unverified` | accepted but could not be checked                    |
23//! | 4    | `not-observed` | accepted, but the expected tip was not observed      |
24//! | 128+n| `interrupted`  | killed by signal n                                   |
25//!
26//! `4` is deliberately worded as "not observed". Equality proves the tip was
27//! seen; inequality does not prove an update was lost, because another writer
28//! may have advanced or replaced the ref in between. The observed sha is
29//! reported so the person can tell which.
30
31use std::ffi::OsString;
32use std::io::{IsTerminal, Read, Write};
33use std::path::PathBuf;
34use std::process::{Command, Stdio};
35use std::time::{Duration, SystemTime, UNIX_EPOCH};
36
37use crate::attest::{classify_push_stderr, PushFailure};
38use crate::git::{self, Probe};
39use crate::hooks::pull_rebase::PUSH_AGAIN;
40use crate::json;
41
42/// Re-reads of the remote after a mismatch, and the pause between them.
43/// Spent only when the first read disagrees: at most 6 s.
44const REREADS: u32 = 3;
45const REREAD_PAUSE: Duration = Duration::from_secs(2);
46
47/// A landing receipt older than this is deleted by the next land.
48const RECEIPT_KEEP: Duration = Duration::from_secs(7 * 24 * 60 * 60);
49
50/// Whether a non-fast-forward may replace the remote ref.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Mode {
53    /// A plain push: the server refuses anything but a fast-forward.
54    Plain,
55    /// `--force`: replace the ref, but only if it still holds the tip that
56    /// was read (`--force-with-lease` on that exact sha).
57    Force,
58}
59
60/// How the run ended. One value per exit code family.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Status {
63    Landed,
64    Refused,
65    Usage,
66    Unreachable,
67    Unverified,
68    NotObserved,
69    /// Killed by this signal number.
70    Interrupted(i32),
71}
72
73impl Status {
74    pub fn code(self) -> i32 {
75        match self {
76            Status::Landed => 0,
77            Status::Refused => 1,
78            Status::Usage => 2,
79            Status::Unreachable | Status::Unverified => 3,
80            Status::NotObserved => 4,
81            Status::Interrupted(sig) => 128 + sig,
82        }
83    }
84
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Status::Landed => "landed",
88            Status::Refused => "refused",
89            Status::Usage => "usage",
90            Status::Unreachable => "unreachable",
91            Status::Unverified => "unverified",
92            Status::NotObserved => "not-observed",
93            Status::Interrupted(_) => "interrupted",
94        }
95    }
96}
97
98/// Everything the output is made of. Filled in as the run learns things, so
99/// a run that stops early still prints what it knew.
100#[derive(Debug, Clone)]
101pub struct Report {
102    pub branch: String,
103    pub remote: String,
104    pub push_url: String,
105    pub pushed: String,
106    /// `refs/heads/<rb>`, the ref the push writes; empty until known.
107    pub dst: String,
108    pub remote_before: String,
109    pub remote_after: String,
110    pub retried: bool,
111    pub status: Status,
112    /// Commands the person can run next, printed whole, one per line.
113    pub next: Vec<String>,
114}
115
116impl Report {
117    fn new(status: Status) -> Self {
118        Report {
119            branch: String::new(),
120            remote: String::new(),
121            push_url: String::new(),
122            pushed: String::new(),
123            dst: String::new(),
124            remote_before: String::new(),
125            remote_after: String::new(),
126            retried: false,
127            status,
128            next: Vec::new(),
129        }
130    }
131
132    fn json(&self) -> String {
133        json::object(&[
134            json::string_field("branch", &self.branch),
135            json::string_field("remote", &self.remote),
136            json::string_field("push_url", &self.push_url),
137            json::string_field("pushed", &self.pushed),
138            json::string_field("remote_before", &self.remote_before),
139            json::string_field("remote_after", &self.remote_after),
140            json::bool_field("retried", self.retried),
141            json::string_field("status", self.status.as_str()),
142        ])
143    }
144}
145
146/// The widest the closing verdict line may be.
147const VERDICT_WIDTH: usize = 80;
148
149/// `…` in the middle of `s` so it fits `max` columns. Counts chars, which is
150/// right for branch names (ASCII in practice, and a wide glyph only makes the
151/// line shorter than the budget, never longer).
152pub fn truncate_middle(s: &str, max: usize) -> String {
153    let n = s.chars().count();
154    if n <= max {
155        return s.to_string();
156    }
157    if max <= 1 {
158        return "…".chars().take(max).collect();
159    }
160    let keep = max - 1;
161    let head = keep.div_ceil(2);
162    let tail = keep - head;
163    let front: String = s.chars().take(head).collect();
164    let back: String = s.chars().skip(n - tail).collect();
165    format!("{front}…{back}")
166}
167
168fn short(sha: &str) -> String {
169    if sha.is_empty() {
170        "-------".to_string()
171    } else {
172        sha.chars().take(7).collect()
173    }
174}
175
176/// The last line printed: plain text, no colour, no glyphs, at most 80
177/// columns whatever the branch is called. Outcome word, the two shas, then
178/// the branch in whatever room is left.
179pub fn verdict(status: Status, before: &str, after: &str, branch: &str) -> String {
180    verdict_word(status.as_str(), before, after, branch)
181}
182
183fn verdict_word(word: &str, before: &str, after: &str, branch: &str) -> String {
184    let head = format!("{word} {}..{}", short(before), short(after));
185    let room = VERDICT_WIDTH.saturating_sub(head.chars().count() + 1);
186    if branch.is_empty() || room == 0 {
187        return head;
188    }
189    format!("{head} {}", truncate_middle(branch, room))
190}
191
192/// The tip `ls-remote <url> <ref>` printed; empty when it printed nothing
193/// (the ref does not exist).
194pub fn parse_tip(ls_remote: &str) -> String {
195    ls_remote
196        .lines()
197        .find_map(|l| l.split_whitespace().next())
198        .unwrap_or("")
199        .to_string()
200}
201
202/// What a read of the remote ref came to.
203enum Seen {
204    Tip(String),
205    Unreachable,
206}
207
208fn read_tip(url: &str, rb: &str, budget: u64) -> Seen {
209    let refname = format!("refs/heads/{rb}");
210    match git::probe_remote_stdout(&["ls-remote", url, &refname], budget) {
211        (Probe::Exit(0), out) => Seen::Tip(parse_tip(&out)),
212        _ => Seen::Unreachable,
213    }
214}
215
216/// `amont land [--force] [--json] [--dry-run]`.
217pub fn command(args: &[OsString]) -> i32 {
218    let json_out = args.iter().any(|a| a == "--json");
219    let dry_run = args.iter().any(|a| a == "--dry-run");
220    let mode = if args.iter().any(|a| a == "--force") {
221        Mode::Force
222    } else {
223        Mode::Plain
224    };
225    if args.iter().any(|a| {
226        let a = a.to_string_lossy();
227        a != "--force" && a != "--json" && a != "--dry-run"
228    }) {
229        eprintln!("amont: land: takes no arguments, only [--force] [--json] [--dry-run]");
230        return finish(&Report::new(Status::Usage), json_out);
231    }
232    if dry_run {
233        return dry_run_command(mode, json_out);
234    }
235    let started = now_nanos();
236    let report = land(mode);
237    write_receipt(&report, started, now_nanos());
238    finish(&report, json_out)
239}
240
241/// The JSON document a usage error prints under `--json`, so a script that
242/// parses stdout never has to special-case exit 2.
243pub fn usage_json() -> String {
244    Report::new(Status::Usage).json()
245}
246
247fn finish(report: &Report, json_out: bool) -> i32 {
248    for line in &report.next {
249        eprintln!("    {line}");
250    }
251    eprintln!(
252        "{}",
253        verdict(
254            report.status,
255            &report.remote_before,
256            &report.remote_after,
257            &report.branch
258        )
259    );
260    if json_out {
261        println!("{}", report.json());
262    }
263    report.status.code()
264}
265
266fn refuse(mut report: Report, why: &str, next: &[&str]) -> Report {
267    eprintln!("amont: land: {why}");
268    report.status = Status::Refused;
269    report.next = next.iter().map(|s| s.to_string()).collect();
270    report
271}
272
273/// What steps 1 and 2 learned: where the push goes and what it is called there.
274struct Resolved {
275    remote: String,
276    rb: String,
277    url: String,
278    has_upstream: bool,
279}
280
281/// Steps 1 and 2: the branch, its upstream, the refusals, and the one push
282/// URL. Reads local git state only: no network, nothing written. `Err` is the
283/// refused report, already explained on stderr.
284fn resolve() -> Result<(Resolved, Report), Box<Report>> {
285    let mut report = Report::new(Status::Refused);
286
287    // 1. What are we pushing, and where to.
288    let Some(branch) = git::stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]) else {
289        return Err(Box::new(refuse(
290            report,
291            "HEAD is detached; there is no branch to land",
292            &[],
293        )));
294    };
295    report.branch = branch.clone();
296    let upstream_remote = git::stdout(&["config", "--get", &format!("branch.{branch}.remote")]);
297    let upstream_merge = git::stdout(&["config", "--get", &format!("branch.{branch}.merge")]);
298    // `.` is a branch tracking another LOCAL branch (`branch.autoSetupMerge`
299    // does that for `git switch -c`): not a remote upstream, so it names
300    // neither where to push nor what to call the branch there.
301    let (upstream_remote, upstream_merge) = match upstream_remote.as_deref() {
302        Some(".") => (None, None),
303        _ => (upstream_remote, upstream_merge),
304    };
305    // A branch cut from `main` tracks `main` (`git worktree add -b <b>
306    // origin/main` does that): its base, not where it publishes. Following
307    // it would refuse every such branch as a push to `main`; plain `git
308    // push` refuses the name mismatch too. So it lands under its own name on
309    // that remote, and the upstream is set to it afterwards, as `git push -u`
310    // would. A renamed upstream that is not `main` or `master` still decides.
311    let upstream_merge = upstream_merge.filter(|m| {
312        let tracked = m.strip_prefix("refs/heads/").unwrap_or(m);
313        !is_protected(tracked) || is_protected(&branch)
314    });
315    let has_upstream = upstream_remote.is_some() && upstream_merge.is_some();
316    let remote = upstream_remote.unwrap_or_else(|| "origin".to_string());
317    let rb = upstream_merge
318        .as_deref()
319        .and_then(|m| m.strip_prefix("refs/heads/"))
320        .unwrap_or(&branch)
321        .to_string();
322    report.remote = remote.clone();
323    report.dst = format!("refs/heads/{rb}");
324
325    if is_protected(&branch) || is_protected(&rb) {
326        return Err(Box::new(refuse(
327            report,
328            "main and master change by pull request, never by a push",
329            &["git switch -c <name>"],
330        )));
331    }
332    if git::stdout(&["status", "--porcelain", "--untracked-files=no"])
333        .is_some_and(|s| !s.is_empty())
334    {
335        return Err(Box::new(refuse(
336            report,
337            "tracked files have uncommitted changes; the push would not include them",
338            &["git status", "git stash"],
339        )));
340    }
341
342    // 2. The endpoint the push will really use.
343    let Some(urls) = git::stdout(&["remote", "get-url", "--push", "--all", &remote]) else {
344        return Err(Box::new(refuse(
345            report,
346            &format!("no remote named {remote:?}"),
347            &["git remote -v"],
348        )));
349    };
350    let urls: Vec<&str> = urls.lines().filter(|l| !l.is_empty()).collect();
351    if urls.len() != 1 {
352        return Err(Box::new(refuse(
353            report,
354            &format!(
355                "{remote:?} has {} push destinations; land verifies exactly one",
356                urls.len()
357            ),
358            &[&format!("git remote get-url --push --all {remote}")],
359        )));
360    }
361    let url = urls[0].to_string();
362    report.push_url = url.clone();
363    Ok((
364        Resolved {
365            remote,
366            rb,
367            url,
368            has_upstream,
369        },
370        report,
371    ))
372}
373
374fn land(mode: Mode) -> Report {
375    let settings = crate::config::Settings::default();
376    let budget = crate::hooks::common::network_probe_budget(&settings);
377    let (
378        Resolved {
379            remote,
380            rb,
381            url,
382            has_upstream,
383        },
384        mut report,
385    ) = match resolve() {
386        Ok(r) => r,
387        Err(refused) => return *refused,
388    };
389
390    // Where the ref stands.
391    let mut expected_old = match read_tip(&url, &rb, budget) {
392        Seen::Tip(t) => t,
393        Seen::Unreachable => {
394            report.status = Status::Unreachable;
395            eprintln!("amont: land: could not read {rb} from {remote}");
396            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
397            return report;
398        }
399    };
400    report.remote_before = expected_old.clone();
401
402    // 3. Push, once, and once more only if the hook moved HEAD for us.
403    let mut attempt = 0;
404    let pushed = loop {
405        let Some(sha) = git::stdout(&["rev-parse", "HEAD"]) else {
406            return refuse(report, "cannot resolve HEAD", &[]);
407        };
408        report.pushed = sha.clone();
409        let lease = match mode {
410            Mode::Force => Some(expected_old.as_str()),
411            Mode::Plain => None,
412        };
413        let run = push(&url, &sha, &rb, lease);
414        match run.outcome {
415            PushOutcome::Done => break sha,
416            PushOutcome::Signal(sig) => {
417                report.status = Status::Interrupted(sig);
418                return report;
419            }
420            PushOutcome::Failed => {}
421        }
422        let head_now = git::stdout(&["rev-parse", "HEAD"]).unwrap_or_default();
423        let hook_moved_head = run.seen.contains(PUSH_AGAIN)
424            && head_now != sha
425            && (expected_old.is_empty()
426                || git::succeeds(&["merge-base", "--is-ancestor", &expected_old, &head_now]));
427        if attempt == 0 && hook_moved_head {
428            attempt = 1;
429            report.retried = true;
430            eprintln!(
431                "amont: land: retrying once: pre-push hook fast-forwarded HEAD {} -> {}",
432                short(&sha),
433                short(&head_now)
434            );
435            match read_tip(&url, &rb, budget) {
436                Seen::Tip(t) => {
437                    expected_old = t;
438                    report.remote_before = expected_old.clone();
439                }
440                Seen::Unreachable => {
441                    report.status = Status::Unreachable;
442                    report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
443                    return report;
444                }
445            }
446            continue;
447        }
448        report.status = match classify_push_stderr(&run.stderr) {
449            PushFailure::Unreachable => Status::Unreachable,
450            PushFailure::Raced | PushFailure::Refused => Status::Refused,
451        };
452        return report;
453    };
454
455    // 4. The proof.
456    let mut observed = Seen::Unreachable;
457    for i in 0..=REREADS {
458        if i > 0 {
459            std::thread::sleep(REREAD_PAUSE);
460        }
461        observed = read_tip(&url, &rb, budget);
462        if matches!(&observed, Seen::Tip(t) if *t == pushed) {
463            break;
464        }
465    }
466    match observed {
467        Seen::Tip(t) if t == pushed => {
468            report.remote_after = t;
469            report.status = Status::Landed;
470            move_tracking_ref(&remote, &rb, &pushed);
471            if !has_upstream {
472                set_upstream(&remote, &rb);
473            }
474        }
475        Seen::Tip(t) => {
476            report.remote_after = t;
477            report.status = Status::NotObserved;
478            eprintln!(
479                "amont: land: the remote accepted the push but {rb} reads {}, not {}",
480                short(&report.remote_after),
481                short(&pushed)
482            );
483            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
484        }
485        Seen::Unreachable => {
486            report.status = Status::Unverified;
487            eprintln!("amont: land: the push was accepted but {rb} could not be read back");
488            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
489        }
490    }
491    report
492}
493
494/// `--dry-run`: steps 1 and 2 of a land and nothing else. No network, no push,
495/// no ref written, no receipt.
496fn dry_run_command(mode: Mode, json_out: bool) -> i32 {
497    let (mut report, would) = match resolve() {
498        Ok((_, report)) => (report, true),
499        Err(refused) => (*refused, false),
500    };
501    if would {
502        report.pushed = git::stdout(&["rev-parse", "HEAD"]).unwrap_or_default();
503    }
504    let word = if would { "would-land" } else { "refused" };
505    for line in &report.next {
506        eprintln!("    {line}");
507    }
508    eprintln!("{}", verdict_word(word, "", &report.pushed, &report.branch));
509    if json_out {
510        let dir = receipt_dir()
511            .map(|d| d.to_string_lossy().into_owned())
512            .unwrap_or_default();
513        println!(
514            "{}",
515            json::object(&[
516                json::string_field("branch", &report.branch),
517                json::string_field("remote", &report.remote),
518                json::string_field("push_url", &report.push_url),
519                json::string_field("pushed", &report.pushed),
520                json::string_field("dst", &report.dst),
521                json::bool_field("retried", false),
522                json::string_field("status", word),
523                json::string_field("receipt_dir", &dir),
524                json::bool_field("lease", mode == Mode::Force),
525            ])
526        );
527    }
528    if would {
529        0
530    } else {
531        1
532    }
533}
534
535fn now_nanos() -> i64 {
536    SystemTime::now()
537        .duration_since(UNIX_EPOCH)
538        .ok()
539        .and_then(|d| i64::try_from(d.as_nanos()).ok())
540        .unwrap_or(0)
541}
542
543/// `<git-dir>/amont/land`, absolute and per worktree.
544fn receipt_dir() -> Option<PathBuf> {
545    git::stdout(&["rev-parse", "--absolute-git-dir"])
546        .map(|d| PathBuf::from(d).join("amont").join("land"))
547}
548
549/// The receipt is the evidence of what a land pushed, independent of the
550/// tracking ref. One per land that got past resolving the branch, written with
551/// the final status. A receipt that cannot be written is a warning, never a
552/// change of exit code.
553fn write_receipt(report: &Report, started: i64, ended: i64) {
554    if report.branch.is_empty() {
555        return;
556    }
557    let Some(dir) = receipt_dir() else {
558        eprintln!("amont: land: could not find the git directory; no landing receipt written");
559        return;
560    };
561    if let Err(e) = std::fs::create_dir_all(&dir) {
562        eprintln!(
563            "amont: land: could not write a landing receipt in {}: {e}",
564            dir.display()
565        );
566        return;
567    }
568    prune_receipts(&dir);
569    let path = dir.join(format!("{started}-{}.json", std::process::id()));
570    let body = json::object(&[
571        json::int_field("started", started),
572        json::int_field("ended", ended),
573        json::string_field("branch", &report.branch),
574        json::string_field("remote", &report.remote),
575        json::string_field("push_url", &report.push_url),
576        json::string_field("dst", &report.dst),
577        json::string_field("pushed", &report.pushed),
578        json::bool_field("retried", report.retried),
579        json::string_field("status", report.status.as_str()),
580    ]);
581    if let Err(e) = std::fs::write(&path, format!("{body}\n")) {
582        eprintln!(
583            "amont: land: could not write the landing receipt {}: {e}",
584            path.display()
585        );
586    }
587}
588
589/// Deletes receipts older than [`RECEIPT_KEEP`].
590fn prune_receipts(dir: &std::path::Path) {
591    let Ok(entries) = std::fs::read_dir(dir) else {
592        return;
593    };
594    for entry in entries.flatten() {
595        let path = entry.path();
596        if path.extension().map_or(true, |e| e != "json") {
597            continue;
598        }
599        let old = entry
600            .metadata()
601            .and_then(|m| m.modified())
602            .ok()
603            .and_then(|t| t.elapsed().ok())
604            .is_some_and(|age| age > RECEIPT_KEEP);
605        if old {
606            if let Err(e) = std::fs::remove_file(&path) {
607                eprintln!(
608                    "amont: land: could not delete the old receipt {}: {e}",
609                    path.display()
610                );
611            }
612        }
613    }
614}
615
616fn is_protected(branch: &str) -> bool {
617    matches!(branch, "main" | "master")
618}
619
620/// A push to a URL updates no tracking ref, so after a verified landing the
621/// local view of the remote is moved to what was just proven. The landing
622/// already happened, so a failure here is a warning, not an exit code.
623fn move_tracking_ref(remote: &str, rb: &str, sha: &str) {
624    let tracking = format!("refs/remotes/{remote}/{rb}");
625    if !git::succeeds(&["update-ref", &tracking, sha]) {
626        eprintln!(
627            "amont: land: landed, but could not move {tracking} to {}",
628            short(sha)
629        );
630    }
631}
632
633/// The first landing of a branch with no upstream sets one.
634fn set_upstream(remote: &str, rb: &str) {
635    let upstream = format!("{remote}/{rb}");
636    if !git::succeeds(&["branch", "--set-upstream-to", &upstream]) {
637        eprintln!("amont: land: landed, but could not set the upstream to {upstream}");
638    }
639}
640
641enum PushOutcome {
642    Done,
643    Failed,
644    Signal(i32),
645}
646
647struct PushRun {
648    outcome: PushOutcome,
649    /// stderr alone, for classification.
650    stderr: String,
651    /// stdout and stderr, for finding the hook's `push again`.
652    seen: String,
653}
654
655/// `git push <url> <sha>:refs/heads/<rb>` with the pre-push hooks running.
656///
657/// An explicit sha, so the commit that is verified is the commit that was
658/// pushed even if HEAD moves underneath. Both of the child's streams are
659/// copied to OUR stderr as they arrive, in chunks rather than lines so git's
660/// `\r` progress still moves, and a copy is kept for classification; stdout
661/// of the process stays free for `--json`. No overall deadline: the
662/// pre-push hook runs the whole gate, which takes minutes. Without a
663/// terminal the push gets the no-prompt environment, which bounds the
664/// network legs only: a custom ssh command, a credential helper or an
665/// established session can still stall, and Ctrl-C is the answer.
666fn push(url: &str, sha: &str, rb: &str, lease: Option<&str>) -> PushRun {
667    let mut cmd = Command::new("git");
668    if !std::io::stdin().is_terminal() {
669        let (pre, env) = git::remote_env(git::configured_ssh());
670        cmd.args(pre).envs(env);
671    }
672    cmd.arg("push");
673    if let Some(old) = lease {
674        cmd.arg(format!("--force-with-lease=refs/heads/{rb}:{old}"));
675    }
676    cmd.arg(url)
677        .arg(format!("{sha}:refs/heads/{rb}"))
678        .stdin(Stdio::inherit())
679        .stdout(Stdio::piped())
680        .stderr(Stdio::piped());
681    let mut child = match cmd.spawn() {
682        Ok(c) => c,
683        Err(e) => {
684            let why = format!("could not run git push: {e}\n");
685            eprint!("amont: land: {why}");
686            return PushRun {
687                outcome: PushOutcome::Failed,
688                stderr: why.clone(),
689                seen: why,
690            };
691        }
692    };
693    let tee = |mut from: Box<dyn Read + Send>| {
694        std::thread::spawn(move || {
695            let mut kept = Vec::new();
696            let mut buf = [0u8; 4096];
697            let mut err = std::io::stderr();
698            while let Ok(n) = from.read(&mut buf) {
699                if n == 0 {
700                    break;
701                }
702                let _ = err.write_all(&buf[..n]);
703                let _ = err.flush();
704                kept.extend_from_slice(&buf[..n]);
705            }
706            String::from_utf8_lossy(&kept).into_owned()
707        })
708    };
709    let out_thread = child.stdout.take().map(|s| tee(Box::new(s)));
710    let err_thread = child.stderr.take().map(|s| tee(Box::new(s)));
711    let status = child.wait();
712    let out = out_thread.and_then(|t| t.join().ok()).unwrap_or_default();
713    let err = err_thread.and_then(|t| t.join().ok()).unwrap_or_default();
714    let outcome = match status {
715        Ok(s) if s.success() => PushOutcome::Done,
716        Ok(s) => match signal_of(&s) {
717            Some(sig) => PushOutcome::Signal(sig),
718            None => PushOutcome::Failed,
719        },
720        Err(_) => PushOutcome::Failed,
721    };
722    PushRun {
723        outcome,
724        seen: format!("{out}{err}"),
725        stderr: err,
726    }
727}
728
729#[cfg(unix)]
730fn signal_of(s: &std::process::ExitStatus) -> Option<i32> {
731    use std::os::unix::process::ExitStatusExt;
732    s.signal()
733}
734
735#[cfg(not(unix))]
736fn signal_of(_: &std::process::ExitStatus) -> Option<i32> {
737    None
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn exit_codes_keep_usage_apart_from_the_lost_ref() {
746        assert_eq!(Status::Landed.code(), 0);
747        assert_eq!(Status::Refused.code(), 1);
748        assert_eq!(Status::Usage.code(), 2);
749        assert_eq!(Status::Unreachable.code(), 3);
750        assert_eq!(Status::Unverified.code(), 3);
751        assert_eq!(Status::NotObserved.code(), 4);
752        assert_eq!(Status::Interrupted(2).code(), 130);
753    }
754
755    #[test]
756    fn every_status_has_its_own_word() {
757        let all = [
758            Status::Landed,
759            Status::Refused,
760            Status::Usage,
761            Status::Unreachable,
762            Status::Unverified,
763            Status::NotObserved,
764            Status::Interrupted(15),
765        ];
766        let words: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
767        assert_eq!(words.len(), all.len());
768    }
769
770    #[test]
771    fn the_verdict_fits_eighty_columns_for_any_branch() {
772        let long = "feature/".to_string() + &"x".repeat(120);
773        for status in [Status::Landed, Status::NotObserved, Status::Unverified] {
774            let line = verdict(status, "0123456789abcdef", "fedcba9876543210", &long);
775            assert!(line.chars().count() <= 80, "{line}");
776            assert!(line.contains('…'), "{line}");
777            assert!(line.starts_with(status.as_str()));
778        }
779    }
780
781    #[test]
782    fn a_short_branch_is_shown_whole_and_a_new_ref_has_a_placeholder() {
783        let line = verdict(Status::Landed, "", "fedcba9876543210", "feat/x");
784        assert_eq!(line, "landed -------..fedcba9 feat/x");
785    }
786
787    #[test]
788    fn the_verdict_is_plain_text() {
789        let line = verdict(Status::Refused, "a", "b", "feat/ünï");
790        assert!(!line.contains('\u{1b}'));
791    }
792
793    #[test]
794    fn truncation_keeps_both_ends() {
795        assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
796        assert_eq!(truncate_middle("abc", 5), "abc");
797        assert_eq!(truncate_middle("abcdefghij", 1), "…");
798    }
799
800    #[test]
801    fn the_tip_is_the_first_field_and_absence_is_empty() {
802        assert_eq!(parse_tip("abc123\trefs/heads/x\n"), "abc123");
803        assert_eq!(parse_tip(""), "");
804    }
805
806    #[test]
807    fn json_carries_every_field_the_contract_names() {
808        let mut r = Report::new(Status::NotObserved);
809        r.branch = "feat/x".into();
810        r.retried = true;
811        let j = r.json();
812        for key in [
813            "branch",
814            "remote",
815            "push_url",
816            "pushed",
817            "remote_before",
818            "remote_after",
819            "retried",
820            "status",
821        ] {
822            assert!(j.contains(&format!("\"{key}\"")), "{key} missing in {j}");
823        }
824        assert!(j.contains("\"status\":\"not-observed\""), "{j}");
825        assert!(j.contains("\"retried\":true"), "{j}");
826        assert!(usage_json().contains("\"status\":\"usage\""));
827    }
828}