Skip to main content

git_policy_cli/push/
auto_push_trigger.rs

1//! What:
2//!  Hands a push to the commit service after a command other than `git commit` added commits to the branch: a merge, a
3//!  pull's merge, a cherry-pick, a revert or an applied patch.
4//! Why:
5//!  A clean `git merge` used to stay local until the next commit pushed it. The owner chose (2026-11-02) that any command
6//!  adding commits queues the push as a commit does, when the branch tip moved and the branch is now ahead of its upstream
7//!  and not diverged from it, and that a rebase does not, because a rebased branch would need a forced push.
8//!  The check reads the branch, its upstream tracking ref and the commit parents from the files, with no Git process:
9//!  the tip is read before and after the forwarded command, and the push is handed over only when the old tip and the
10//!  upstream tip are both ancestors of the new tip. The old tip being an ancestor is what leaves a rebase (a rewritten
11//!  branch) out, `pull --rebase` and `pull.rebase` included, without reading the configuration for it.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const watch = watchTip(worktree); await runGit(args); if (exitCode === 0) await watch.handOverIfAhead();
16//! ```
17
18#![cfg(unix)]
19
20use crate::git_system_config::with_real_git;
21use crate::repo_read_config_list::list_config;
22use crate::repo_read_config_scan::ScopeEnvironment;
23use crate::repo_read_layout::{RepoLayout, read_layout_after_success};
24use crate::repo_read_object::{ObjectKind, from_hex};
25use crate::repo_read_odb::ObjectDatabase;
26use crate::repo_read_query_refs::tracking_ref;
27use crate::repo_read_refs::{RefValue, Refs};
28use crate::repo_read_tree::parse_commit;
29use crate::service_client::{Activation, ClientConfig, START_WAIT, manager_watch_for};
30use crate::service_protocol::HEARTBEAT_SILENCE;
31use crate::service_handoff::{ServiceHandoff, hand_over_push};
32use crate::service_messages::{ClientFailure, render};
33use crate::push_trigger_log::{Decision, Origin, Outcome, record_decision};
34use crate::service_provision::platform_without_manager;
35use crate::service_route::{Route, route_from_environment};
36use std::collections::{HashSet, VecDeque};
37use std::ffi::OsString;
38use std::io::Write;
39use std::path::{Path, PathBuf};
40
41/// The most commits the walk from a tip reads before it gives up and hands nothing over: a branch far ahead, or a history
42/// that never reaches the upstream, is not worth a long walk after every merge. Measured: the 439 most recent merges on `main` of
43///  this repository brought in a median of 8 commits (90th percentile 50, 99th percentile 317, largest 6,542), and only 1 exceeded
44///  this budget, so 438 of 439 are walked in full. Kept as the coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Sizes and ages of the service's own housekeeping").
45pub const WALK_BUDGET: usize = 4096;
46
47/// What the branch looked like before the command: where it pointed and where its repository is.
48///
49/// In TS you'd write (pseudocode):
50/// ```ts
51/// type TipWatch = { layout: RepoLayout; variables: [string, string][]; realGit: string; branchRef: string; before: string };
52/// ```
53#[derive(Clone, Debug)]
54pub struct TipWatch {
55    /// Where the worktree and its repository are.
56    layout: RepoLayout,
57    /// The environment Git would see, which the configuration reader needs for its scopes.
58    variables: Vec<(OsString, OsString)>,
59    /// The real Git the service will push with.
60    real_git: PathBuf,
61    /// The branch `HEAD` named.
62    branch_ref: String,
63    /// The tip of that branch before the command.
64    before: String,
65}
66
67/// Why the trigger did nothing:
68/// the step that declined,
69/// as a stable word,
70/// and the facts a person needs to see why.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type Declined = { reason: string; detail: string };
75/// ```
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct Declined {
78    /// The step,
79    /// in words that do not change (`layout-declined`,
80    /// `head-detached`,
81    /// `upstream-equals-tip`,
82    /// and the others).
83    pub reason: &'static str,
84    /// What the step saw.
85    pub detail: String,
86}
87
88/// A decline of `reason` with what was seen.
89fn declined(reason: &'static str, detail: impl Into<String>) -> Declined {
90    return Declined { reason, detail: detail.into() };
91}
92
93impl TipWatch {
94    /// The common Git directory of the watched repository,
95    /// where the trigger's records are kept.
96    pub fn common_dir(&self) -> &Path {
97        return self.layout.common_dir.as_path();
98    }
99
100    /// The worktree the command runs in, as a record names it.
101    pub fn origin(&self) -> Origin<'_> {
102        return Origin { worktree: self.layout.worktree_root.as_path(), git_dir: self.layout.git_dir.as_path() };
103    }
104
105    /// The branch `HEAD` named before the command.
106    pub fn branch_ref(&self) -> &str {
107        return self.branch_ref.as_str();
108    }
109
110    /// The tip of that branch before the command.
111    pub fn tip_before(&self) -> &str {
112        return self.before.as_str();
113    }
114}
115
116/// The branch `HEAD` names and its tip,
117/// or the step that found none:
118/// a layout the reader declines,
119/// a detached `HEAD`,
120/// an unborn branch.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// function currentBranch(layout): { branchRef: string; tip: string } | Declined;
125/// ```
126fn current_branch(layout: &RepoLayout) -> Result<(String, String), Declined> {
127    let refs: Refs = Refs::new(layout.common_dir.as_path(), layout.object_format.hex_length());
128    let head: RefValue = refs
129        .lookup_head(layout.git_dir.as_path())
130        .ok_or_else(|| { return declined("head-unreadable", format!("HEAD in {} is not a plain symbolic or direct ref", layout.git_dir.display())); })?;
131    let RefValue::Symbolic(target) = head else {
132        return Err(declined("head-detached", "HEAD names a commit, not a branch"));
133    };
134    if !target.starts_with("refs/heads/") {
135        return Err(declined("head-not-a-branch", format!("HEAD names {target}")));
136    }
137    let tip: String = refs.resolve(target.as_str()).ok_or_else(|| { return declined("branch-has-no-tip", format!("{target} does not resolve")); })?;
138    return Ok((target, tip));
139}
140
141/// What:
142///  Start watching the branch of a worktree before a command runs.
143/// Why:
144///   The command may be a merge or a pull, which move the tip; what the tip was is what a rebase is told apart by.
145///   The layout is read as for a caller that follows a Git run that succeeds (`read_layout_after_success`):
146/// a configuration value the reader cannot prove is no reason not to push once the command that moved the tip succeeded
147/// (the files are read again,
148/// not the snapshot that command saw,
149/// and the final push is Git's).
150///
151/// In TS you'd write (pseudocode):
152/// ```ts
153/// function watchTip(worktreeRoot, realGit, overlay, environment): TipWatch | Declined;
154/// ```
155pub fn try_watch_tip(
156    worktree_root: &Path,
157    real_git: &Path,
158    overlay: &[(OsString, OsString)],
159    environment: &[(OsString, OsString)],
160) -> Result<TipWatch, Declined> {
161    // The readers learn the system configuration file from the real Git the child would run.
162    let named: Vec<(OsString, OsString)> = with_real_git(overlay, real_git);
163    let layout: RepoLayout = read_layout_after_success(worktree_root, named.as_slice())
164        .ok_or_else(|| { return declined("layout-declined", format!("the files of {} are not a layout the reader takes", worktree_root.display())); })?;
165    let (branch_ref, before): (String, String) = current_branch(&layout)?;
166    let mut variables: Vec<(OsString, OsString)> = environment.to_vec();
167    variables.extend(named);
168    return Ok(TipWatch { layout, variables, real_git: real_git.to_path_buf(), branch_ref, before });
169}
170
171/// `try_watch_tip` without the reason:
172/// nothing when the watch could not start.
173pub fn watch_tip(
174    worktree_root: &Path,
175    real_git: &Path,
176    overlay: &[(OsString, OsString)],
177    environment: &[(OsString, OsString)],
178) -> Option<TipWatch> {
179    return try_watch_tip(worktree_root, real_git, overlay, environment).ok();
180}
181
182/// The tip the upstream tracking ref of the branch holds,
183/// or the step that found no single plain upstream on a remote.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// function upstreamTip(watch: TipWatch, refs: Refs): string | Declined;
188/// ```
189fn upstream_tip(watch: &TipWatch, refs: &Refs) -> Result<String, Declined> {
190    let name: &str = watch.branch_ref.strip_prefix("refs/heads/").ok_or_else(|| { return declined("upstream-no-branch-name", watch.branch_ref.clone()); })?;
191    let scope: ScopeEnvironment = ScopeEnvironment::from_variables(watch.variables.as_slice());
192    let listed = list_config(watch.layout.common_dir.as_path(), watch.layout.git_dir.as_path(), &scope, watch.variables.as_slice())
193        .ok_or_else(|| { return declined("upstream-config-unreadable", "the configuration listing was declined by the reader"); })?;
194    let values = |suffix: &str| -> Vec<String> {
195        let key: String = format!("branch.{name}.{suffix}");
196        return listed
197            .iter()
198            .filter(|item| { return item.entry.key == key; })
199            .filter_map(|item| { return item.entry.value.as_deref().and_then(|bytes| { return std::str::from_utf8(bytes).ok().map(str::to_owned); }); })
200            .collect();
201    };
202    let remotes: Vec<String> = values("remote");
203    let merges: Vec<String> = values("merge");
204    let ([remote], [merge]) = (remotes.as_slice(), merges.as_slice()) else {
205        return Err(declined("upstream-not-single", format!("branch.{name}.remote has {} values and branch.{name}.merge has {}", remotes.len(), merges.len())));
206    };
207    if remote == "." {
208        return Err(declined("upstream-is-local", format!("branch.{name}.remote is the repository itself")));
209    }
210    let tracking: String = tracking_ref(listed.as_slice(), remote, merge)
211        .ok_or_else(|| { return declined("upstream-no-tracking-ref", format!("no remote.{remote}.fetch maps {merge}")); })?;
212    return refs.resolve(tracking.as_str()).ok_or_else(|| { return declined("upstream-tracking-ref-missing", tracking); });
213}
214
215/// What:
216///  Whether `target` is `from` or one of its ancestors, within the walk budget; nothing when the walk could not tell.
217/// Why:
218///   Ahead and not diverged is "the upstream tip is an ancestor of the branch tip"; the walk goes from the branch tip, which
219///  reaches the upstream within the commits the branch is ahead by, and stops at the budget on a history that never does.
220///
221/// In TS you'd write (pseudocode):
222/// ```ts
223/// function reaches(database, from: string, target: string, budget: number): boolean | undefined;
224/// ```
225pub fn reaches(database: &ObjectDatabase, layout: &RepoLayout, from: &str, target: &str, budget: usize) -> Option<bool> {
226    let start: Vec<u8> = from_hex(from.as_bytes(), layout.object_format)?;
227    let wanted: Vec<u8> = from_hex(target.as_bytes(), layout.object_format)?;
228    let mut seen: HashSet<Vec<u8>> = HashSet::new();
229    let mut queue: VecDeque<Vec<u8>> = VecDeque::new();
230    seen.insert(start.clone());
231    queue.push_back(start);
232    let mut read: usize = 0;
233    while let Some(name) = queue.pop_front() {
234        if name == wanted {
235            return Some(true);
236        }
237        if read >= budget {
238            return None;
239        }
240        read += 1;
241        let object = database.read(name.as_slice())?;
242        if object.kind != ObjectKind::Commit {
243            return None;
244        }
245        for parent in parse_commit(object.bytes.as_slice(), layout.object_format)?.parents {
246            if seen.insert(parent.clone()) {
247                queue.push_back(parent);
248            }
249        }
250    }
251    return Some(false);
252}
253
254/// What:
255///  The tip to hand over after the command,
256/// or the step that found the branch not a case the owner chose.
257/// Why:
258///   The branch must be the same one, its tip must have moved forward from the old tip (a rewritten branch is a rebase),
259///  and the new tip must be ahead of its upstream and not diverged: the upstream tip is an ancestor of it and not it.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// function tipToPushOrWhy(watch: TipWatch): string | Declined;
264/// ```
265pub fn tip_to_push_or_why(watch: &TipWatch) -> Result<String, Declined> {
266    let (branch_ref, after): (String, String) = current_branch(&watch.layout)?;
267    if branch_ref != watch.branch_ref {
268        return Err(declined("branch-changed", format!("the command started on {} and ended on {branch_ref}", watch.branch_ref)));
269    }
270    if after == watch.before {
271        return Err(declined("tip-unchanged", format!("{branch_ref} stayed at {after}")));
272    }
273    let refs: Refs = Refs::new(watch.layout.common_dir.as_path(), watch.layout.object_format.hex_length());
274    let upstream: String = upstream_tip(watch, &refs)?;
275    if upstream == after {
276        return Err(declined("upstream-equals-tip", format!("{after} is already the upstream tip")));
277    }
278    let database: ObjectDatabase = ObjectDatabase::open(watch.layout.common_dir.as_path(), None, watch.layout.object_format)
279        .ok_or_else(|| { return declined("object-database-unopened", watch.layout.common_dir.display().to_string()); })?;
280    let grew: bool = reaches(&database, &watch.layout, after.as_str(), watch.before.as_str(), WALK_BUDGET)
281        .ok_or_else(|| { return declined("walk-undecided-growth", format!("from {after} to the old tip {}", watch.before)); })?;
282    if !grew {
283        return Err(declined("not-grown", format!("the old tip {} is not an ancestor of {after}: a rewritten branch", watch.before)));
284    }
285    let ahead: bool = reaches(&database, &watch.layout, after.as_str(), upstream.as_str(), WALK_BUDGET)
286        .ok_or_else(|| { return declined("walk-undecided-ahead", format!("from {after} to the upstream tip {upstream}")); })?;
287    if !ahead {
288        return Err(declined("not-ahead", format!("the upstream tip {upstream} is not an ancestor of {after}: diverged")));
289    }
290    return Ok(after);
291}
292
293/// `tip_to_push_or_why` without the reason.
294pub fn tip_to_push(watch: &TipWatch) -> Option<String> {
295    return tip_to_push_or_why(watch).ok();
296}
297
298/// What:
299///  After a command that succeeded: hand the push over when the branch grew and is ahead of its upstream.
300/// Why:
301///   The person's command already finished; a service that cannot be reached is said plainly and changes nothing.
302///   The record is the same push record a commit leaves, so the service pushes the branch by name and coalesces it.
303///   Every ending is recorded with its reason (`push_trigger_log.rs`),
304/// so a push that did not happen can be explained.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// await watch.handOverIfAhead(command, stderr);
309/// ```
310pub fn hand_over_if_ahead(watch: &TipWatch, command: &str, stderr: &mut dyn Write) {
311    let common: &Path = watch.layout.common_dir.as_path();
312    let branch: &str = watch.branch_ref.as_str();
313    let decide = |outcome: Outcome, reason: &str, detail: &str| {
314        record_decision(&Decision { common_dir: Some(common), origin: Some(watch.origin()), command, outcome, reason, detail, branch: Some(branch) });
315    };
316    let tip: String = match tip_to_push_or_why(watch) {
317        Ok(tip) => tip,
318        Err(Declined { reason, detail }) => {
319            decide(Outcome::Declined, reason, detail.as_str());
320            return;
321        }
322    };
323    let Some(Route { address, provision }) = route_from_environment(watch.real_git.as_path(), watch.layout.git_dir.as_path()) else {
324        decide(Outcome::Declined, "no-route", "no way to reach the commit service");
325        // Terminal output error: the message is the only report of a push that was not handed over.
326        let _written = writeln!(stderr, "{}", render(&ClientFailure::NoActivation(platform_without_manager())));
327        return;
328    };
329    let handoff: ServiceHandoff = ServiceHandoff {
330        config: ClientConfig {
331            address,
332            activation: Activation::Managed,
333            start_wait: START_WAIT,
334            heartbeat_silence: HEARTBEAT_SILENCE,
335            start_watch: provision.as_ref().map(manager_watch_for),
336            keep_fresh: None, answer_ceiling: crate::wait_ceilings::ANSWER_CEILING, withdraw_ceiling: crate::wait_ceilings::WITHDRAW_CEILING, extra_wait: crate::wait_ceilings::SHORT_EXTRA_WAIT, start_ceiling: crate::wait_ceilings::START_CEILING,
337        },
338        repository: watch.layout.worktree_root.clone(),
339        git_dir: watch.layout.git_dir.clone(),
340        reference: watch.branch_ref.clone(),
341        parent: watch.before.clone(),
342    };
343    if let Err(failure) = hand_over_push(&handoff, tip.as_str(), stderr) {
344        decide(Outcome::Declined, "handover-failed", render(&failure).as_str());
345        // Terminal output error: the message is the only report of a push that was not handed over.
346        let _written = writeln!(stderr, "{}", render(&failure));
347        return;
348    }
349    decide(Outcome::HandedOver, "ahead-of-upstream", format!("pushed {tip} of {branch}").as_str());
350}
351
352/// The trigger's controls stay out of the release executable.
353#[cfg(test)]
354#[path = "auto_push_trigger_tests.rs"]
355mod tests;