1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! What:
//! Hands a push to the commit service after a command other than `git commit` added commits to the branch: a merge, a
//! pull's merge, a cherry-pick, a revert or an applied patch.
//! Why:
//! A clean `git merge` used to stay local until the next commit pushed it. The owner chose (2026-11-02) that any command
//! adding commits queues the push as a commit does, when the branch tip moved and the branch is now ahead of its upstream
//! and not diverged from it, and that a rebase does not, because a rebased branch would need a forced push.
//! The check reads the branch, its upstream tracking ref and the commit parents from the files, with no Git process:
//! the tip is read before and after the forwarded command, and the push is handed over only when the old tip and the
//! upstream tip are both ancestors of the new tip. The old tip being an ancestor is what leaves a rebase (a rewritten
//! branch) out, `pull --rebase` and `pull.rebase` included, without reading the configuration for it.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const watch = watchTip(worktree); await runGit(args); if (exitCode === 0) await watch.handOverIfAhead();
//! ```
#![cfg(unix)]
use super::git_system_config::with_real_git;
use super::repo_read_config_list::list_config;
use super::repo_read_config_scan::ScopeEnvironment;
use super::repo_read_layout::{RepoLayout, read_layout_in};
use super::repo_read_object::{ObjectKind, from_hex};
use super::repo_read_odb::ObjectDatabase;
use super::repo_read_query_refs::tracking_ref;
use super::repo_read_refs::{RefValue, Refs};
use super::repo_read_tree::parse_commit;
use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT};
use super::service_handoff::{ServiceHandoff, hand_over_push};
use super::service_messages::{ClientFailure, render};
use super::service_provision::platform_without_manager;
use super::service_route::{Route, route_from_environment};
use std::collections::{HashSet, VecDeque};
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
/// The most commits the walk from a tip reads before it gives up and hands nothing over: a branch far ahead, or a history
/// that never reaches the upstream, is not worth a long walk after every merge.
pub const WALK_BUDGET: usize = 4096;
/// What the branch looked like before the command: where it pointed and where its repository is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type TipWatch = { layout: RepoLayout; variables: [string, string][]; realGit: string; branchRef: string; before: string };
/// ```
#[derive(Clone, Debug)]
pub struct TipWatch {
/// Where the worktree and its repository are.
layout: RepoLayout,
/// The environment Git would see, which the configuration reader needs for its scopes.
variables: Vec<(OsString, OsString)>,
/// The real Git the service will push with.
real_git: PathBuf,
/// The branch `HEAD` named.
branch_ref: String,
/// The tip of that branch before the command.
before: String,
}
/// The branch `HEAD` names and its tip, or nothing for a detached `HEAD`, an unborn branch or a layout the reader declines.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function currentBranch(layout): { branchRef: string; tip: string } | undefined;
/// ```
fn current_branch(layout: &RepoLayout) -> Option<(String, String)> {
let refs: Refs = Refs::new(layout.common_dir.as_path(), layout.object_format.hex_length());
let RefValue::Symbolic(target) = refs.lookup_head(layout.git_dir.as_path())? else {
return None;
};
if !target.starts_with("refs/heads/") {
return None;
}
let tip: String = refs.resolve(target.as_str())?;
return Some((target, tip));
}
/// What:
/// Start watching the branch of a worktree before a command runs.
/// Why:
/// 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.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function watchTip(worktreeRoot, realGit, overlay, environment): TipWatch | undefined;
/// ```
pub fn watch_tip(
worktree_root: &Path,
real_git: &Path,
overlay: &[(OsString, OsString)],
environment: &[(OsString, OsString)],
) -> Option<TipWatch> {
// The readers learn the system configuration file from the real Git the child would run.
let named: Vec<(OsString, OsString)> = with_real_git(overlay, real_git);
let layout: RepoLayout = read_layout_in(worktree_root, named.as_slice())?;
let (branch_ref, before): (String, String) = current_branch(&layout)?;
let mut variables: Vec<(OsString, OsString)> = environment.to_vec();
variables.extend(named);
return Some(TipWatch { layout, variables, real_git: real_git.to_path_buf(), branch_ref, before });
}
/// The tip the upstream tracking ref of the branch holds, or nothing without a single plain upstream on a remote.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function upstreamTip(watch: TipWatch, refs: Refs): string | undefined;
/// ```
fn upstream_tip(watch: &TipWatch, refs: &Refs) -> Option<String> {
let name: &str = watch.branch_ref.strip_prefix("refs/heads/")?;
let scope: ScopeEnvironment = ScopeEnvironment::from_variables(watch.variables.as_slice());
let listed = list_config(watch.layout.common_dir.as_path(), watch.layout.git_dir.as_path(), &scope, watch.variables.as_slice())?;
let values = |suffix: &str| -> Vec<String> {
let key: String = format!("branch.{name}.{suffix}");
return listed
.iter()
.filter(|item| { return item.entry.key == key; })
.filter_map(|item| { return item.entry.value.as_deref().and_then(|bytes| { return std::str::from_utf8(bytes).ok().map(str::to_owned); }); })
.collect();
};
let remotes: Vec<String> = values("remote");
let merges: Vec<String> = values("merge");
let ([remote], [merge]) = (remotes.as_slice(), merges.as_slice()) else {
return None;
};
if remote == "." {
return None;
}
let tracking: String = tracking_ref(listed.as_slice(), remote, merge)?;
return refs.resolve(tracking.as_str());
}
/// What:
/// Whether `target` is `from` or one of its ancestors, within the walk budget; nothing when the walk could not tell.
/// Why:
/// Ahead and not diverged is "the upstream tip is an ancestor of the branch tip"; the walk goes from the branch tip, which
/// reaches the upstream within the commits the branch is ahead by, and stops at the budget on a history that never does.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function reaches(database, from: string, target: string, budget: number): boolean | undefined;
/// ```
pub fn reaches(database: &ObjectDatabase, layout: &RepoLayout, from: &str, target: &str, budget: usize) -> Option<bool> {
let start: Vec<u8> = from_hex(from.as_bytes(), layout.object_format)?;
let wanted: Vec<u8> = from_hex(target.as_bytes(), layout.object_format)?;
let mut seen: HashSet<Vec<u8>> = HashSet::new();
let mut queue: VecDeque<Vec<u8>> = VecDeque::new();
seen.insert(start.clone());
queue.push_back(start);
let mut read: usize = 0;
while let Some(name) = queue.pop_front() {
if name == wanted {
return Some(true);
}
if read >= budget {
return None;
}
read += 1;
let object = database.read(name.as_slice())?;
if object.kind != ObjectKind::Commit {
return None;
}
for parent in parse_commit(object.bytes.as_slice(), layout.object_format)?.parents {
if seen.insert(parent.clone()) {
queue.push_back(parent);
}
}
}
return Some(false);
}
/// What:
/// The tip to hand over after the command, or nothing when the branch is not a case the owner chose.
/// Why:
/// The branch must be the same one, its tip must have moved forward from the old tip (a rewritten branch is a rebase),
/// and the new tip must be ahead of its upstream and not diverged: the upstream tip is an ancestor of it and not it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function tipToPush(watch: TipWatch): string | undefined;
/// ```
pub fn tip_to_push(watch: &TipWatch) -> Option<String> {
let (branch_ref, after): (String, String) = current_branch(&watch.layout)?;
if branch_ref != watch.branch_ref || after == watch.before {
return None;
}
let refs: Refs = Refs::new(watch.layout.common_dir.as_path(), watch.layout.object_format.hex_length());
let upstream: String = upstream_tip(watch, &refs)?;
if upstream == after {
return None;
}
let database: ObjectDatabase = ObjectDatabase::open(watch.layout.common_dir.as_path(), None, watch.layout.object_format)?;
let grew: bool = reaches(&database, &watch.layout, after.as_str(), watch.before.as_str(), WALK_BUDGET)?;
let ahead: bool = reaches(&database, &watch.layout, after.as_str(), upstream.as_str(), WALK_BUDGET)?;
return (grew && ahead).then_some(after);
}
/// What:
/// After a command that succeeded: hand the push over when the branch grew and is ahead of its upstream.
/// Why:
/// The person's command already finished; a service that cannot be reached is said plainly and changes nothing.
/// The record is the same push record a commit leaves, so the service pushes the branch by name and coalesces it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await watch.handOverIfAhead(stderr);
/// ```
pub fn hand_over_if_ahead(watch: &TipWatch, stderr: &mut dyn Write) {
let Some(tip) = tip_to_push(watch) else {
return;
};
let Some(Route { address, .. }) = route_from_environment(watch.real_git.as_path(), watch.layout.git_dir.as_path()) else {
// Terminal output error: the message is the only report of a push that was not handed over.
let _written = writeln!(stderr, "{}", render(&ClientFailure::NoActivation(platform_without_manager())));
return;
};
let handoff: ServiceHandoff = ServiceHandoff {
config: ClientConfig { address, activation: Activation::Managed, start_wait: START_WAIT, commit_wait: COMMIT_WAIT },
repository: watch.layout.worktree_root.clone(),
git_dir: watch.layout.git_dir.clone(),
reference: watch.branch_ref.clone(),
parent: watch.before.clone(),
};
if let Err(failure) = hand_over_push(&handoff, tip.as_str(), stderr) {
// Terminal output error: the message is the only report of a push that was not handed over.
let _written = writeln!(stderr, "{}", render(&failure));
}
}
/// The trigger's controls stay out of the release executable.
#[cfg(test)]
#[path = "auto_push_trigger_tests.rs"]
mod tests;