Skip to main content

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