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::process::{Command, Stdio};
34use std::time::Duration;
35
36use crate::attest::{classify_push_stderr, PushFailure};
37use crate::git::{self, Probe};
38use crate::hooks::pull_rebase::PUSH_AGAIN;
39use crate::json;
40
41/// Re-reads of the remote after a mismatch, and the pause between them.
42/// Spent only when the first read disagrees: at most 6 s.
43const REREADS: u32 = 3;
44const REREAD_PAUSE: Duration = Duration::from_secs(2);
45
46/// Whether a non-fast-forward may replace the remote ref.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Mode {
49    /// A plain push: the server refuses anything but a fast-forward.
50    Plain,
51    /// `--force`: replace the ref, but only if it still holds the tip that
52    /// was read (`--force-with-lease` on that exact sha).
53    Force,
54}
55
56/// How the run ended. One value per exit code family.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Status {
59    Landed,
60    Refused,
61    Usage,
62    Unreachable,
63    Unverified,
64    NotObserved,
65    /// Killed by this signal number.
66    Interrupted(i32),
67}
68
69impl Status {
70    pub fn code(self) -> i32 {
71        match self {
72            Status::Landed => 0,
73            Status::Refused => 1,
74            Status::Usage => 2,
75            Status::Unreachable | Status::Unverified => 3,
76            Status::NotObserved => 4,
77            Status::Interrupted(sig) => 128 + sig,
78        }
79    }
80
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Status::Landed => "landed",
84            Status::Refused => "refused",
85            Status::Usage => "usage",
86            Status::Unreachable => "unreachable",
87            Status::Unverified => "unverified",
88            Status::NotObserved => "not-observed",
89            Status::Interrupted(_) => "interrupted",
90        }
91    }
92}
93
94/// Everything the output is made of. Filled in as the run learns things, so
95/// a run that stops early still prints what it knew.
96#[derive(Debug, Clone)]
97pub struct Report {
98    pub branch: String,
99    pub remote: String,
100    pub push_url: String,
101    pub pushed: String,
102    pub remote_before: String,
103    pub remote_after: String,
104    pub retried: bool,
105    pub status: Status,
106    /// Commands the person can run next, printed whole, one per line.
107    pub next: Vec<String>,
108}
109
110impl Report {
111    fn new(status: Status) -> Self {
112        Report {
113            branch: String::new(),
114            remote: String::new(),
115            push_url: String::new(),
116            pushed: String::new(),
117            remote_before: String::new(),
118            remote_after: String::new(),
119            retried: false,
120            status,
121            next: Vec::new(),
122        }
123    }
124
125    fn json(&self) -> String {
126        json::object(&[
127            json::string_field("branch", &self.branch),
128            json::string_field("remote", &self.remote),
129            json::string_field("push_url", &self.push_url),
130            json::string_field("pushed", &self.pushed),
131            json::string_field("remote_before", &self.remote_before),
132            json::string_field("remote_after", &self.remote_after),
133            json::bool_field("retried", self.retried),
134            json::string_field("status", self.status.as_str()),
135        ])
136    }
137}
138
139/// The widest the closing verdict line may be.
140const VERDICT_WIDTH: usize = 80;
141
142/// `…` in the middle of `s` so it fits `max` columns. Counts chars, which is
143/// right for branch names (ASCII in practice, and a wide glyph only makes the
144/// line shorter than the budget, never longer).
145pub fn truncate_middle(s: &str, max: usize) -> String {
146    let n = s.chars().count();
147    if n <= max {
148        return s.to_string();
149    }
150    if max <= 1 {
151        return "…".chars().take(max).collect();
152    }
153    let keep = max - 1;
154    let head = keep.div_ceil(2);
155    let tail = keep - head;
156    let front: String = s.chars().take(head).collect();
157    let back: String = s.chars().skip(n - tail).collect();
158    format!("{front}…{back}")
159}
160
161fn short(sha: &str) -> String {
162    if sha.is_empty() {
163        "-------".to_string()
164    } else {
165        sha.chars().take(7).collect()
166    }
167}
168
169/// The last line printed: plain text, no colour, no glyphs, at most 80
170/// columns whatever the branch is called. Outcome word, the two shas, then
171/// the branch in whatever room is left.
172pub fn verdict(status: Status, before: &str, after: &str, branch: &str) -> String {
173    let head = format!("{} {}..{}", status.as_str(), short(before), short(after));
174    let room = VERDICT_WIDTH.saturating_sub(head.chars().count() + 1);
175    if branch.is_empty() || room == 0 {
176        return head;
177    }
178    format!("{head} {}", truncate_middle(branch, room))
179}
180
181/// The tip `ls-remote <url> <ref>` printed; empty when it printed nothing
182/// (the ref does not exist).
183pub fn parse_tip(ls_remote: &str) -> String {
184    ls_remote
185        .lines()
186        .find_map(|l| l.split_whitespace().next())
187        .unwrap_or("")
188        .to_string()
189}
190
191/// What a read of the remote ref came to.
192enum Seen {
193    Tip(String),
194    Unreachable,
195}
196
197fn read_tip(url: &str, rb: &str, budget: u64) -> Seen {
198    let refname = format!("refs/heads/{rb}");
199    match git::probe_remote_stdout(&["ls-remote", url, &refname], budget) {
200        (Probe::Exit(0), out) => Seen::Tip(parse_tip(&out)),
201        _ => Seen::Unreachable,
202    }
203}
204
205/// `amont land [--force] [--json]`.
206pub fn command(args: &[OsString]) -> i32 {
207    let json_out = args.iter().any(|a| a == "--json");
208    let mode = if args.iter().any(|a| a == "--force") {
209        Mode::Force
210    } else {
211        Mode::Plain
212    };
213    let report = if args.iter().any(|a| {
214        let a = a.to_string_lossy();
215        a != "--force" && a != "--json"
216    }) {
217        eprintln!("amont: `land` takes no arguments, only [--force] [--json]");
218        Report::new(Status::Usage)
219    } else {
220        land(mode)
221    };
222    finish(&report, json_out)
223}
224
225/// The JSON document a usage error prints under `--json`, so a script that
226/// parses stdout never has to special-case exit 2.
227pub fn usage_json() -> String {
228    Report::new(Status::Usage).json()
229}
230
231fn finish(report: &Report, json_out: bool) -> i32 {
232    for line in &report.next {
233        eprintln!("    {line}");
234    }
235    eprintln!(
236        "{}",
237        verdict(
238            report.status,
239            &report.remote_before,
240            &report.remote_after,
241            &report.branch
242        )
243    );
244    if json_out {
245        println!("{}", report.json());
246    }
247    report.status.code()
248}
249
250fn refuse(mut report: Report, why: &str, next: &[&str]) -> Report {
251    eprintln!("amont: land: {why}");
252    report.status = Status::Refused;
253    report.next = next.iter().map(|s| s.to_string()).collect();
254    report
255}
256
257fn land(mode: Mode) -> Report {
258    let settings = crate::config::Settings::default();
259    let budget = crate::hooks::common::network_probe_budget(&settings);
260    let mut report = Report::new(Status::Refused);
261
262    // 1. What are we pushing, and where to.
263    let Some(branch) = git::stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]) else {
264        return refuse(report, "HEAD is detached; there is no branch to land", &[]);
265    };
266    report.branch = branch.clone();
267    let upstream_remote = git::stdout(&["config", "--get", &format!("branch.{branch}.remote")]);
268    let upstream_merge = git::stdout(&["config", "--get", &format!("branch.{branch}.merge")]);
269    // `.` is a branch tracking another LOCAL branch (`branch.autoSetupMerge`
270    // does that for `git switch -c`): not a remote upstream, so it names
271    // neither where to push nor what to call the branch there.
272    let (upstream_remote, upstream_merge) = match upstream_remote.as_deref() {
273        Some(".") => (None, None),
274        _ => (upstream_remote, upstream_merge),
275    };
276    let has_upstream = upstream_remote.is_some() && upstream_merge.is_some();
277    let remote = upstream_remote.unwrap_or_else(|| "origin".to_string());
278    let rb = upstream_merge
279        .as_deref()
280        .and_then(|m| m.strip_prefix("refs/heads/"))
281        .unwrap_or(&branch)
282        .to_string();
283    report.remote = remote.clone();
284
285    if is_protected(&branch) || is_protected(&rb) {
286        return refuse(
287            report,
288            "main and master change by pull request, never by a push",
289            &["git switch -c <name>"],
290        );
291    }
292    if git::stdout(&["status", "--porcelain", "--untracked-files=no"])
293        .is_some_and(|s| !s.is_empty())
294    {
295        return refuse(
296            report,
297            "tracked files have uncommitted changes; the push would not include them",
298            &["git status", "git stash"],
299        );
300    }
301
302    // 2. The endpoint the push will really use, and where the ref stands.
303    let Some(urls) = git::stdout(&["remote", "get-url", "--push", "--all", &remote]) else {
304        return refuse(
305            report,
306            &format!("no remote named {remote:?}"),
307            &["git remote -v"],
308        );
309    };
310    let urls: Vec<&str> = urls.lines().filter(|l| !l.is_empty()).collect();
311    if urls.len() != 1 {
312        return refuse(
313            report,
314            &format!(
315                "{remote:?} has {} push destinations; land verifies exactly one",
316                urls.len()
317            ),
318            &[&format!("git remote get-url --push --all {remote}")],
319        );
320    }
321    let url = urls[0].to_string();
322    report.push_url = url.clone();
323    let mut expected_old = match read_tip(&url, &rb, budget) {
324        Seen::Tip(t) => t,
325        Seen::Unreachable => {
326            report.status = Status::Unreachable;
327            eprintln!("amont: land: could not read {rb} from {remote}");
328            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
329            return report;
330        }
331    };
332    report.remote_before = expected_old.clone();
333
334    // 3. Push, once, and once more only if the hook moved HEAD for us.
335    let mut attempt = 0;
336    let pushed = loop {
337        let Some(sha) = git::stdout(&["rev-parse", "HEAD"]) else {
338            return refuse(report, "cannot resolve HEAD", &[]);
339        };
340        report.pushed = sha.clone();
341        let lease = match mode {
342            Mode::Force => Some(expected_old.as_str()),
343            Mode::Plain => None,
344        };
345        let run = push(&url, &sha, &rb, lease);
346        match run.outcome {
347            PushOutcome::Done => break sha,
348            PushOutcome::Signal(sig) => {
349                report.status = Status::Interrupted(sig);
350                return report;
351            }
352            PushOutcome::Failed => {}
353        }
354        let head_now = git::stdout(&["rev-parse", "HEAD"]).unwrap_or_default();
355        let hook_moved_head = run.seen.contains(PUSH_AGAIN)
356            && head_now != sha
357            && (expected_old.is_empty()
358                || git::succeeds(&["merge-base", "--is-ancestor", &expected_old, &head_now]));
359        if attempt == 0 && hook_moved_head {
360            attempt = 1;
361            report.retried = true;
362            eprintln!(
363                "amont: land: retrying once: pre-push hook fast-forwarded HEAD {} -> {}",
364                short(&sha),
365                short(&head_now)
366            );
367            match read_tip(&url, &rb, budget) {
368                Seen::Tip(t) => {
369                    expected_old = t;
370                    report.remote_before = expected_old.clone();
371                }
372                Seen::Unreachable => {
373                    report.status = Status::Unreachable;
374                    report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
375                    return report;
376                }
377            }
378            continue;
379        }
380        report.status = match classify_push_stderr(&run.stderr) {
381            PushFailure::Unreachable => Status::Unreachable,
382            PushFailure::Raced | PushFailure::Refused => Status::Refused,
383        };
384        return report;
385    };
386
387    // 4. The proof.
388    let mut observed = Seen::Unreachable;
389    for i in 0..=REREADS {
390        if i > 0 {
391            std::thread::sleep(REREAD_PAUSE);
392        }
393        observed = read_tip(&url, &rb, budget);
394        if matches!(&observed, Seen::Tip(t) if *t == pushed) {
395            break;
396        }
397    }
398    match observed {
399        Seen::Tip(t) if t == pushed => {
400            report.remote_after = t;
401            report.status = Status::Landed;
402            move_tracking_ref(&remote, &rb, &pushed);
403            if !has_upstream {
404                set_upstream(&remote, &rb);
405            }
406        }
407        Seen::Tip(t) => {
408            report.remote_after = t;
409            report.status = Status::NotObserved;
410            eprintln!(
411                "amont: land: the remote accepted the push but {rb} reads {}, not {}",
412                short(&report.remote_after),
413                short(&pushed)
414            );
415            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
416        }
417        Seen::Unreachable => {
418            report.status = Status::Unverified;
419            eprintln!("amont: land: the push was accepted but {rb} could not be read back");
420            report.next = vec![format!("git ls-remote {remote} refs/heads/{rb}")];
421        }
422    }
423    report
424}
425
426fn is_protected(branch: &str) -> bool {
427    matches!(branch, "main" | "master")
428}
429
430/// A push to a URL updates no tracking ref, so after a verified landing the
431/// local view of the remote is moved to what was just proven. The landing
432/// already happened, so a failure here is a warning, not an exit code.
433fn move_tracking_ref(remote: &str, rb: &str, sha: &str) {
434    let tracking = format!("refs/remotes/{remote}/{rb}");
435    if !git::succeeds(&["update-ref", &tracking, sha]) {
436        eprintln!(
437            "amont: land: landed, but could not move {tracking} to {}",
438            short(sha)
439        );
440    }
441}
442
443/// The first landing of a branch with no upstream sets one.
444fn set_upstream(remote: &str, rb: &str) {
445    let upstream = format!("{remote}/{rb}");
446    if !git::succeeds(&["branch", "--set-upstream-to", &upstream]) {
447        eprintln!("amont: land: landed, but could not set the upstream to {upstream}");
448    }
449}
450
451enum PushOutcome {
452    Done,
453    Failed,
454    Signal(i32),
455}
456
457struct PushRun {
458    outcome: PushOutcome,
459    /// stderr alone, for classification.
460    stderr: String,
461    /// stdout and stderr, for finding the hook's `push again`.
462    seen: String,
463}
464
465/// `git push <url> <sha>:refs/heads/<rb>` with the pre-push hooks running.
466///
467/// An explicit sha, so the commit that is verified is the commit that was
468/// pushed even if HEAD moves underneath. Both of the child's streams are
469/// copied to OUR stderr as they arrive, in chunks rather than lines so git's
470/// `\r` progress still moves, and a copy is kept for classification; stdout
471/// of the process stays free for `--json`. No overall deadline: the
472/// pre-push hook runs the whole gate, which takes minutes. Without a
473/// terminal the push gets the no-prompt environment, which bounds the
474/// network legs only: a custom ssh command, a credential helper or an
475/// established session can still stall, and Ctrl-C is the answer.
476fn push(url: &str, sha: &str, rb: &str, lease: Option<&str>) -> PushRun {
477    let mut cmd = Command::new("git");
478    if !std::io::stdin().is_terminal() {
479        let (pre, env) = git::remote_env(git::configured_ssh());
480        cmd.args(pre).envs(env);
481    }
482    cmd.arg("push");
483    if let Some(old) = lease {
484        cmd.arg(format!("--force-with-lease=refs/heads/{rb}:{old}"));
485    }
486    cmd.arg(url)
487        .arg(format!("{sha}:refs/heads/{rb}"))
488        .stdin(Stdio::inherit())
489        .stdout(Stdio::piped())
490        .stderr(Stdio::piped());
491    let mut child = match cmd.spawn() {
492        Ok(c) => c,
493        Err(e) => {
494            let why = format!("could not run git push: {e}\n");
495            eprint!("amont: land: {why}");
496            return PushRun {
497                outcome: PushOutcome::Failed,
498                stderr: why.clone(),
499                seen: why,
500            };
501        }
502    };
503    let tee = |mut from: Box<dyn Read + Send>| {
504        std::thread::spawn(move || {
505            let mut kept = Vec::new();
506            let mut buf = [0u8; 4096];
507            let mut err = std::io::stderr();
508            while let Ok(n) = from.read(&mut buf) {
509                if n == 0 {
510                    break;
511                }
512                let _ = err.write_all(&buf[..n]);
513                let _ = err.flush();
514                kept.extend_from_slice(&buf[..n]);
515            }
516            String::from_utf8_lossy(&kept).into_owned()
517        })
518    };
519    let out_thread = child.stdout.take().map(|s| tee(Box::new(s)));
520    let err_thread = child.stderr.take().map(|s| tee(Box::new(s)));
521    let status = child.wait();
522    let out = out_thread.and_then(|t| t.join().ok()).unwrap_or_default();
523    let err = err_thread.and_then(|t| t.join().ok()).unwrap_or_default();
524    let outcome = match status {
525        Ok(s) if s.success() => PushOutcome::Done,
526        Ok(s) => match signal_of(&s) {
527            Some(sig) => PushOutcome::Signal(sig),
528            None => PushOutcome::Failed,
529        },
530        Err(_) => PushOutcome::Failed,
531    };
532    PushRun {
533        outcome,
534        seen: format!("{out}{err}"),
535        stderr: err,
536    }
537}
538
539#[cfg(unix)]
540fn signal_of(s: &std::process::ExitStatus) -> Option<i32> {
541    use std::os::unix::process::ExitStatusExt;
542    s.signal()
543}
544
545#[cfg(not(unix))]
546fn signal_of(_: &std::process::ExitStatus) -> Option<i32> {
547    None
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn exit_codes_keep_usage_apart_from_the_lost_ref() {
556        assert_eq!(Status::Landed.code(), 0);
557        assert_eq!(Status::Refused.code(), 1);
558        assert_eq!(Status::Usage.code(), 2);
559        assert_eq!(Status::Unreachable.code(), 3);
560        assert_eq!(Status::Unverified.code(), 3);
561        assert_eq!(Status::NotObserved.code(), 4);
562        assert_eq!(Status::Interrupted(2).code(), 130);
563    }
564
565    #[test]
566    fn every_status_has_its_own_word() {
567        let all = [
568            Status::Landed,
569            Status::Refused,
570            Status::Usage,
571            Status::Unreachable,
572            Status::Unverified,
573            Status::NotObserved,
574            Status::Interrupted(15),
575        ];
576        let words: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
577        assert_eq!(words.len(), all.len());
578    }
579
580    #[test]
581    fn the_verdict_fits_eighty_columns_for_any_branch() {
582        let long = "feature/".to_string() + &"x".repeat(120);
583        for status in [Status::Landed, Status::NotObserved, Status::Unverified] {
584            let line = verdict(status, "0123456789abcdef", "fedcba9876543210", &long);
585            assert!(line.chars().count() <= 80, "{line}");
586            assert!(line.contains('…'), "{line}");
587            assert!(line.starts_with(status.as_str()));
588        }
589    }
590
591    #[test]
592    fn a_short_branch_is_shown_whole_and_a_new_ref_has_a_placeholder() {
593        let line = verdict(Status::Landed, "", "fedcba9876543210", "feat/x");
594        assert_eq!(line, "landed -------..fedcba9 feat/x");
595    }
596
597    #[test]
598    fn the_verdict_is_plain_text() {
599        let line = verdict(Status::Refused, "a", "b", "feat/ünï");
600        assert!(!line.contains('\u{1b}'));
601    }
602
603    #[test]
604    fn truncation_keeps_both_ends() {
605        assert_eq!(truncate_middle("abcdefghij", 5), "ab…ij");
606        assert_eq!(truncate_middle("abc", 5), "abc");
607        assert_eq!(truncate_middle("abcdefghij", 1), "…");
608    }
609
610    #[test]
611    fn the_tip_is_the_first_field_and_absence_is_empty() {
612        assert_eq!(parse_tip("abc123\trefs/heads/x\n"), "abc123");
613        assert_eq!(parse_tip(""), "");
614    }
615
616    #[test]
617    fn json_carries_every_field_the_contract_names() {
618        let mut r = Report::new(Status::NotObserved);
619        r.branch = "feat/x".into();
620        r.retried = true;
621        let j = r.json();
622        for key in [
623            "branch",
624            "remote",
625            "push_url",
626            "pushed",
627            "remote_before",
628            "remote_after",
629            "retried",
630            "status",
631        ] {
632            assert!(j.contains(&format!("\"{key}\"")), "{key} missing in {j}");
633        }
634        assert!(j.contains("\"status\":\"not-observed\""), "{j}");
635        assert!(j.contains("\"retried\":true"), "{j}");
636        assert!(usage_json().contains("\"status\":\"usage\""));
637    }
638}