1use 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
42const REREADS: u32 = 3;
45const REREAD_PAUSE: Duration = Duration::from_secs(2);
46
47const RECEIPT_KEEP: Duration = Duration::from_secs(7 * 24 * 60 * 60);
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Mode {
53 Plain,
55 Force,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Status {
63 Landed,
64 Refused,
65 Usage,
66 Unreachable,
67 Unverified,
68 NotObserved,
69 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#[derive(Debug, Clone)]
101pub struct Report {
102 pub branch: String,
103 pub remote: String,
104 pub push_url: String,
105 pub pushed: String,
106 pub dst: String,
108 pub remote_before: String,
109 pub remote_after: String,
110 pub retried: bool,
111 pub status: Status,
112 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
146const VERDICT_WIDTH: usize = 80;
148
149pub 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
176pub 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
192pub 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
202enum 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
216pub 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
241pub 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
273struct Resolved {
275 remote: String,
276 rb: String,
277 url: String,
278 has_upstream: bool,
279}
280
281fn resolve() -> Result<(Resolved, Report), Box<Report>> {
285 let mut report = Report::new(Status::Refused);
286
287 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 let (upstream_remote, upstream_merge) = match upstream_remote.as_deref() {
302 Some(".") => (None, None),
303 _ => (upstream_remote, upstream_merge),
304 };
305 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 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 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 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 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
494fn 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
543fn receipt_dir() -> Option<PathBuf> {
545 git::stdout(&["rev-parse", "--absolute-git-dir"])
546 .map(|d| PathBuf::from(d).join("amont").join("land"))
547}
548
549fn 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
589fn 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
620fn 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
633fn 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: String,
651 seen: String,
653}
654
655fn 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}