1use 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
41const REREADS: u32 = 3;
44const REREAD_PAUSE: Duration = Duration::from_secs(2);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Mode {
49 Plain,
51 Force,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Status {
59 Landed,
60 Refused,
61 Usage,
62 Unreachable,
63 Unverified,
64 NotObserved,
65 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#[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 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
139const VERDICT_WIDTH: usize = 80;
141
142pub 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
169pub 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
181pub 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
191enum 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
205pub 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
225pub 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 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 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 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 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 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
430fn 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
443fn 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: String,
461 seen: String,
463}
464
465fn 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}