amont_runtime/hooks/pull_rebase.rs
1//! pre-push-pull-rebase — sync a branch with ITS OWN upstream before pushing,
2//! and warn (never act) when the default branch has moved ahead.
3//!
4//! The hardening in the shell version is the point, and is preserved exactly:
5//! an older version ran `git pull --rebase origin HEAD`, where the remote ref
6//! `HEAD` resolves to the remote's DEFAULT branch — so every push silently
7//! rebased onto main, autostashing uncommitted work. Hence: never touch a dirty
8//! tree, rebase only onto the branch's own upstream, and abort cleanly on
9//! conflict rather than leaving a half-rebased state.
10
11use crate::check::Outcome;
12use crate::git;
13use crate::ui::{error_sign, highlight, valid_sign, warning_sign};
14
15/// `ahead[[:space:]]+N,[[:space:]]*behind[[:space:]]+M` over `git status -sb`.
16///
17/// Both counts present means the branch and its upstream have diverged, and an
18/// automatic rebase is exactly the wrong move. The COUNTS come back too, not
19/// just the fact: "how far apart" is the first thing anyone wants to know, and
20/// the predicate used to throw it away.
21/// `word[[:space:]]+N` at the first eligible occurrence in `s` — shared by
22/// [`divergence`] and [`behind_count`] so the question "does an `ahead`
23/// or `behind` token here actually mean a count" has exactly one answer
24/// rather than two copies that could drift.
25fn count_after(s: &str, word: &str) -> Option<(u64, usize)> {
26 let i = s.find(word)?;
27 let after = &s[i + word.len()..];
28 let trimmed = after.trim_start_matches([' ', '\t']);
29 if trimmed.len() == after.len() {
30 return None; // `word` must be followed by whitespace
31 }
32 let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
33 if digits.is_empty() {
34 return None;
35 }
36 let consumed = i + word.len() + (after.len() - trimmed.len()) + digits.len();
37 Some((digits.parse().ok()?, consumed))
38}
39
40pub fn divergence(status: &str) -> Option<(u64, u64)> {
41 for line in status.lines() {
42 let mut rest = line;
43 while let Some((ahead, used)) = count_after(rest, "ahead") {
44 let tail = &rest[used..];
45 if let Some(t) = tail.strip_prefix(',') {
46 if let Some((behind, _)) = count_after(t, "behind") {
47 // `behind` must be the NEXT token, not merely present later
48 // on the line.
49 if t.trim_start_matches([' ', '\t']).starts_with("behind") {
50 return Some((ahead, behind));
51 }
52 }
53 }
54 rest = tail;
55 }
56 }
57 None
58}
59
60/// `behind[[:space:]]+N` over `git status -sb`, present or not — UNLIKE
61/// [`divergence`], which only answers when `ahead` is ALSO on the line.
62///
63/// `git status -sb` prints a bare `[behind M]` (no `ahead` at all) when the
64/// branch has nothing of its own to push yet is missing upstream commits —
65/// exactly "a sync would do something", independent of whether the branch is
66/// also ahead. `divergence` cannot answer that question; it was built to
67/// answer a narrower one (are the two DIVERGED, which needs both counts).
68pub fn behind_count(status: &str) -> Option<u64> {
69 status
70 .lines()
71 .find_map(|line| count_after(line, "behind").map(|(n, _)| n))
72}
73
74/// `^[[:space:]*+]+<name>$` over `git branch` output — the indentation git
75/// always prints, plus the markers it puts in column 0.
76///
77/// `+` is not decoration: since git 2.23, a branch checked out in ANOTHER
78/// WORKTREE is printed `+ main` rather than ` main`. That is a completely
79/// ordinary state in this repository's own workflow, and without `+` in the
80/// trim set the whole default-branch advisory (step 4) silently never fired —
81/// `lists_branch` answered false for both `main` and `master`, and the function
82/// returned before reaching it.
83///
84/// The `body.len() < stripped.len()` guard STAYS, and is why `for-each-ref` is
85/// not the answer here: it is what stops a `## main...origin/main` status line
86/// matching, and `+ main` still strips two characters, so the guard costs
87/// nothing. `for-each-ref` produces undecorated lines the guard would reject.
88pub fn lists_branch(branch_list: &str, name: &str) -> bool {
89 branch_list.lines().any(|line| {
90 let stripped = line.trim_end_matches(['\r']);
91 let body = stripped.trim_start_matches([' ', '\t', '*', '+']);
92 !body.is_empty() && body.len() < stripped.len() && body == name
93 })
94}
95
96/// Left side of `git rev-list --left-right --count <a>...<b>`: how far the
97/// default branch is ahead of us.
98pub fn ahead_count(rev_list: &str) -> Option<u64> {
99 rev_list.split_whitespace().next()?.parse().ok()
100}
101
102pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
103 // 1. Never auto-rebase a dirty tree: that autostashes real work and can
104 // leave a broken mid-rebase state during a push.
105 if !git::stdout(&["status", "--porcelain"])
106 .unwrap_or_default()
107 .is_empty()
108 {
109 crate::say!(
110 "{} Uncommitted changes — skipping pre-push pull-rebase.",
111 warning_sign()
112 );
113 return Outcome::Passed;
114 }
115
116 // 2. Only sync a branch that HAS an upstream, and rebase onto that — never
117 // the default branch. A brand-new branch has nothing to sync.
118 let Some(upstream) =
119 git::stdout(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
120 else {
121 return Outcome::Passed;
122 };
123
124 // 2a. The upstream has to be a REAL remote, not merely something with a
125 // `/` in its name. `branch.<name>.remote` is what `git pull`/`fetch`
126 // itself reads to resolve `@{u}` — asking IT, rather than guessing
127 // the remote by splitting `upstream` on '/' and falling back to
128 // "origin", is the fix for a real incident: `git worktree add -b x
129 // <path> main` sets the new branch's upstream to the LOCAL branch
130 // `main` (no slash at all — `@{u}` prints bare `main`), the old
131 // fallback silently read that as `origin/main`, and the bare
132 // `git pull --rebase` two steps down synced from LOCAL main — not
133 // origin — while every check and message around it talked about
134 // origin. Move `main` in the meantime (a `git reset --hard` on it in
135 // another worktree, say) and the next push silently rebases onto
136 // wherever it ended up, no divergence check catching it because the
137 // hand-rolled remote guess was never asking the real question.
138 let Some(current) = git::stdout(&["symbolic-ref", "--short", "HEAD"]) else {
139 return Outcome::Passed; // detached HEAD: nothing to sync
140 };
141 let remote =
142 git::stdout(&["config", "--get", &format!("branch.{current}.remote")]).unwrap_or_default();
143 let is_a_real_remote = !remote.is_empty()
144 && git::stdout(&["remote"])
145 .unwrap_or_default()
146 .lines()
147 .any(|r| r == remote);
148 if !is_a_real_remote {
149 crate::say!(
150 "{} {upstream} is not a remote-tracking branch — skipping sync.",
151 warning_sign()
152 );
153 return Outcome::Passed;
154 }
155 let branch = upstream
156 .strip_prefix(&format!("{remote}/"))
157 .unwrap_or(&upstream);
158
159 // Whether this check may MUTATE and use the NETWORK. Off, it becomes a
160 // pure advisor over your last fetch: no ls-remote round-trip per push, no
161 // rebase you did not type — the shape a check is expected to have. On (the
162 // default, and the behaviour every install so far has had), it syncs a
163 // behind branch for you and then asks for a second push.
164 let auto = crate::config::boolean_or("amont.autoRebase", true);
165
166 // 2b. The upstream can be configured locally but GONE on the remote — the
167 // normal state right after a PR squash-merges with delete-on-merge.
168 // `git pull --rebase` would fail on the missing ref and read as a
169 // conflict, wrongly blocking the push. Only asked when a rebase may
170 // actually happen: it is this check's per-push network round-trip.
171 if auto {
172 // Bounded, and the exit codes kept apart: `--exit-code` exits 2 for
173 // "connected, the ref is gone" and 128 for "could not connect", and
174 // this used to read both as the first — offline was reported as
175 // "upstream deleted", which is a diagnosis someone acts on.
176 let budget = super::common::network_probe_budget();
177 match git::probe(
178 &["ls-remote", "--exit-code", "--heads", &remote, branch],
179 budget,
180 ) {
181 git::Probe::Exit(0) => {} // upstream exists — carry on to the sync
182 git::Probe::Exit(2) => {
183 crate::say!(
184 "{} Upstream {upstream} no longer exists on the remote (merged + auto-deleted?) — skipping sync.", warning_sign()
185 );
186 return Outcome::Passed;
187 }
188 git::Probe::Exit(_) | git::Probe::Failed => {
189 crate::say!(
190 "{} Could not reach {remote} (offline?) — skipping sync.",
191 warning_sign()
192 );
193 return Outcome::Passed;
194 }
195 git::Probe::TimedOut(secs) => {
196 crate::say!(
197 "{} {remote} did not answer within {secs}s — skipping sync.",
198 warning_sign()
199 );
200 return Outcome::Passed;
201 }
202 }
203 }
204
205 // 3. Diverged → warn and DO NOT rebase, but carry on to the default-branch
206 // check below (the shell fell through here too).
207 let status = git::stdout(&["status", "-sb"]).unwrap_or_default();
208 if let Some((ahead, behind)) = divergence(&status) {
209 // Divergence has two causes and they want OPPOSITE actions, so this
210 // says what it saw and lets you pick. The old copy prescribed
211 // `git pull --rebase` unconditionally, which after a local rebase or
212 // amend is the one command that undoes the work you are pushing — it
213 // replays the upstream commits you just rewrote.
214 //
215 // The hook cannot tell the two apart: git does not tell a pre-push hook
216 // whether `--force` was passed, and both cases are non-fast-forward.
217 // Guessing wrong here costs someone their rebase, so it does not guess.
218 crate::say!(
219 "{} Branch and upstream have diverged ({ahead} ahead, {behind} behind) — not auto-rebasing.",
220 warning_sign()
221 );
222 crate::say!(
223 " Rebased or amended locally? That is expected — push with {}.",
224 highlight("git push --force-with-lease")
225 );
226 crate::say!(
227 " Someone else pushed here? Reconcile first with {} (or {}).",
228 highlight("git pull --rebase"),
229 highlight("git merge")
230 );
231 } else if behind_count(&status).unwrap_or(0) > 0 {
232 if !auto {
233 // Advisory, then STOP — before the test suite spends minutes on a
234 // push the server will refuse as non-fast-forward anyway. Judged
235 // from the local tracking ref (the last fetch): with auto off
236 // this check does no network I/O at all.
237 crate::say!(
238 "{} Behind {upstream} — not auto-rebasing ({} is off).",
239 warning_sign(),
240 highlight("amont.autoRebase")
241 );
242 crate::say!(" Sync first: {}", highlight("git pull --rebase"));
243 return Outcome::Failed;
244 }
245 // `behind_count > 0` gates the attempt itself, not just its outcome:
246 // with nothing behind, the upstream has no commits to reconcile with,
247 // so `pull --rebase` has nothing to do BY DEFINITION — and attempting
248 // it anyway is not merely wasted work, it is a real subprocess rebase
249 // that can choke on the local branch's own history for a
250 // reconciliation nothing needed. A branch carrying a merge commit
251 // (recording a hand-resolved reconciliation with a THIRD branch, say)
252 // is exactly such a shape: `pull --rebase` tries to linearize it by
253 // full reachability rather than first-parent, replays commits that
254 // were already incorporated, and fails loudly over a push that was a
255 // clean fast-forward all along.
256 //
257 // Explicit remote and branch, not a bare `pull --rebase` trusting
258 // ambient `branch.*.remote`/`.merge` — `remote`/`branch` are already
259 // the verified pair from 2a/2b, and repeating them here means this
260 // sync can never again silently use something other than what was
261 // just checked.
262 // Under the full `amont.timeout` deadline — this is real work (a
263 // fetch plus a rebase), not a probe, but it is still a network verb
264 // and a hung fetch used to hold the push hostage with no clock
265 // anywhere. The probe above only proved the remote ANSWERS; it says
266 // nothing about how the transfer goes.
267 match git::probe(
268 &["pull", "--rebase", &remote, branch],
269 super::common::check_timeout(),
270 ) {
271 git::Probe::Exit(0) => {
272 // HEAD moved; the oids git handed THIS push on stdin have
273 // not. Carrying on would run the suite against packages
274 // selected from commits git is no longer pushing, and the
275 // server refuses the stale objects as non-fast-forward
276 // regardless. Fail fast, with the good news first.
277 crate::say!(
278 "{} Rebased onto {upstream}. This push's refs predate the rebase — push again.",
279 warning_sign()
280 );
281 return Outcome::Failed;
282 }
283 git::Probe::TimedOut(secs) => {
284 // Abort so the tree is never left half-rebased.
285 let _ = git::succeeds(&["rebase", "--abort"]);
286 crate::say!(
287 "{} pull --rebase did not finish within {secs}s (rebase aborted, tree restored).",
288 error_sign()
289 );
290 crate::say!(" Sync manually: {}", highlight("git pull --rebase"));
291 return Outcome::Failed;
292 }
293 git::Probe::Exit(_) | git::Probe::Failed => {
294 // Abort so the tree is never left half-rebased.
295 let _ = git::succeeds(&["rebase", "--abort"]);
296 crate::say!(
297 "{} pull --rebase hit conflicts (rebase aborted, tree restored).",
298 error_sign()
299 );
300 crate::say!(" Resolve manually: {}", highlight("git pull --rebase"));
301 return Outcome::Failed;
302 }
303 }
304 } else {
305 crate::say!("{} Branch is in sync with its upstream", valid_sign());
306 }
307
308 // 4. Informational only — never acts.
309 // UNTRIMMED, and that is not a detail. `git::stdout` trims the whole
310 // buffer, which strips the two leading spaces off the FIRST line — so when
311 // `git branch` happened to list the default branch first, `lists_branch`'s
312 // decoration guard rejected it and the advisory below never fired. Whether
313 // it fired depended on the alphabetical position of the branch you were on,
314 // which is why it looked intermittent rather than broken.
315 let branch_list = git::stdout_raw(&["branch"])
316 .map(|out| String::from_utf8_lossy(&out).into_owned())
317 .unwrap_or_default();
318 let default_branch = if lists_branch(&branch_list, "main") {
319 "main"
320 } else if lists_branch(&branch_list, "master") {
321 "master"
322 } else {
323 return Outcome::Passed;
324 };
325
326 // `remote`, not a hardcoded "origin". Step 2 above spends thirteen lines of
327 // comment establishing `remote` as the VERIFIED one for this branch, and
328 // then this step ignored it: in a repository whose remote is `upstream`,
329 // the fetch failed (ignored) and `rev-list origin/<branch>...HEAD` returned
330 // None, so the advisory was silently skipped.
331 // The advisory's fetch is the other network round-trip; with auto off it
332 // reads the last fetch's refs instead — stale is fine for an advisory.
333 if auto {
334 let _ = git::succeeds(&["fetch", &remote, default_branch]);
335 }
336 let range = format!("{remote}/{default_branch}...HEAD");
337 if let Some(n) =
338 git::stdout(&["rev-list", "--left-right", "--count", &range]).and_then(|s| ahead_count(&s))
339 {
340 if n > 0 {
341 // The shell took `head -c 1` of this count — the FIRST CHARACTER —
342 // so 12 commits ahead printed "1". The test was only ever
343 // non-zero/zero, so the wrong number went unnoticed. Parsed properly
344 // here.
345 crate::say!(
346 "{} {remote}/{default_branch} is ahead by {n} commit(s).",
347 warning_sign()
348 );
349 crate::say!(
350 " Consider before merging: {}",
351 highlight(&format!("git merge {remote}/{default_branch}"))
352 );
353 }
354 }
355 Outcome::Passed
356}
357
358#[cfg(test)]
359mod tests {
360 use super::{ahead_count, behind_count, divergence, lists_branch};
361
362 #[test]
363 fn detects_divergence_only_when_both_counts_are_present() {
364 assert!(divergence("## feat/x...origin/feat/x [ahead 1, behind 2]").is_some());
365 assert!(divergence("## a...b [ahead 12, behind 3]").is_some());
366 // ahead only, or behind only, is NOT divergence — a rebase is fine
367 assert!(divergence("## feat/x...origin/feat/x [ahead 3]").is_none());
368 assert!(divergence("## feat/x...origin/feat/x [behind 2]").is_none());
369 assert!(divergence("## feat/x...origin/feat/x").is_none());
370 assert!(divergence("## ahead-of-time...origin/x").is_none());
371 }
372
373 /// The counts are the message now, so a wrong one is a wrong message.
374 /// Two digits especially: this file already carries a bug where a count of
375 /// 12 printed as 1.
376 #[test]
377 fn reports_how_far_apart_the_two_are() {
378 assert_eq!(
379 divergence("## feat/x...origin/feat/x [ahead 1, behind 2]"),
380 Some((1, 2))
381 );
382 assert_eq!(
383 divergence("## a...b [ahead 12, behind 34]"),
384 Some((12, 34))
385 );
386 }
387
388 /// A branch legitimately named `ahead-of-behind` must not be parsed as a
389 /// pair of counts, and `behind` has to be the token straight after the
390 /// comma rather than merely somewhere on the line.
391 #[test]
392 fn branch_names_are_not_mistaken_for_counts() {
393 assert!(divergence("## ahead 3...origin/behind 4").is_none());
394 // The word has to be followed by a SPACE, so a branch whose name runs
395 // straight into digits is not read as a count.
396 assert!(divergence("## a...b [ahead3, behind4]").is_none());
397 assert!(divergence("## ahead12...origin/behind34").is_none());
398 assert!(divergence("## a...b [ahead 3, xbehind 4]").is_none());
399 assert!(divergence("## ahead-of/behind...origin/ahead-of/behind").is_none());
400 }
401
402 /// `git branch` puts a marker in column 0, and there are TWO of them.
403 ///
404 /// `*` is the current branch; `+` — since git 2.23 — is a branch checked
405 /// out in ANOTHER WORKTREE. That second one is an entirely ordinary state
406 /// in this repository's own workflow, and without it in the trim set the
407 /// whole default-branch advisory silently never fired: `lists_branch`
408 /// answered false for `main` and for `master`, and `run` returned before
409 /// reaching step 4.
410 #[test]
411 fn recognises_git_branch_lines() {
412 let list = " feat/x\n* main\n master-ish\n";
413 assert!(lists_branch(list, "main"));
414 assert!(!lists_branch(list, "master")); // master-ish is a different branch
415 assert!(!lists_branch(list, "feat")); // must match the whole name
416 assert!(lists_branch(list, "feat/x"));
417
418 // The worktree marker.
419 let elsewhere = "+ main\n* feat/x\n";
420 assert!(
421 lists_branch(elsewhere, "main"),
422 "a branch checked out in another worktree is still a branch"
423 );
424
425 // The decoration guard is DELIBERATE, not incidental: an undecorated
426 // line is not `git branch` output, and this is what stops a
427 // `## main...origin/main` status line matching. It is also why this
428 // cannot be switched to `for-each-ref`, whose lines carry no marker.
429 assert!(!lists_branch("main\n", "main"));
430 assert!(!lists_branch("## main...origin/main\n", "main"));
431
432 // …which is why `run` must NOT read `git branch` through
433 // `git::stdout`: that trims the whole buffer, eating the FIRST line's
434 // indentation. When git listed the default branch first, this is
435 // exactly the input the guard was handed, and the advisory silently
436 // never fired.
437 assert!(!lists_branch("main\n* feat/x\n", "main"));
438 assert!(lists_branch(" main\n* feat/x\n", "main"));
439 }
440
441 /// A count of 12 must read as 12, not 1 — the shell's `head -c 1`.
442 #[test]
443 fn parses_the_full_ahead_count() {
444 assert_eq!(ahead_count("12\t3"), Some(12));
445 assert_eq!(ahead_count("0\t5"), Some(0));
446 assert_eq!(ahead_count(""), None);
447 }
448
449 /// The whole point: `behind_count`, unlike `divergence`, answers even
450 /// when `ahead` is not on the line at all.
451 #[test]
452 fn behind_count_does_not_require_ahead() {
453 assert_eq!(
454 behind_count("## feat/x...origin/feat/x [behind 2]"),
455 Some(2)
456 );
457 assert_eq!(
458 behind_count("## feat/x...origin/feat/x [ahead 1, behind 2]"),
459 Some(2)
460 );
461 }
462
463 /// Ahead-only, or fully in sync, is "nothing behind" — the shape that
464 /// means a sync has nothing to reconcile.
465 #[test]
466 fn behind_count_is_none_when_there_is_nothing_behind() {
467 assert_eq!(behind_count("## feat/x...origin/feat/x [ahead 3]"), None);
468 assert_eq!(behind_count("## feat/x...origin/feat/x"), None);
469 assert_eq!(behind_count(""), None);
470 }
471
472 /// The same false-positive guard `divergence` has: a branch merely
473 /// NAMED with "behind" in it, glued to non-whitespace, is not a count.
474 #[test]
475 fn behind_count_is_not_fooled_by_a_branch_name() {
476 assert!(behind_count("## my-behind-thing...origin/my-behind-thing").is_none());
477 assert!(behind_count("## a...b [behind4]").is_none());
478 }
479}