1#![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
41pub const WALK_BUDGET: usize = 4096;
46
47#[derive(Clone, Debug)]
54pub struct TipWatch {
55 layout: RepoLayout,
57 variables: Vec<(OsString, OsString)>,
59 real_git: PathBuf,
61 branch_ref: String,
63 before: String,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct Declined {
78 pub reason: &'static str,
84 pub detail: String,
86}
87
88fn declined(reason: &'static str, detail: impl Into<String>) -> Declined {
90 return Declined { reason, detail: detail.into() };
91}
92
93impl TipWatch {
94 pub fn common_dir(&self) -> &Path {
97 return self.layout.common_dir.as_path();
98 }
99
100 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 pub fn branch_ref(&self) -> &str {
107 return self.branch_ref.as_str();
108 }
109
110 pub fn tip_before(&self) -> &str {
112 return self.before.as_str();
113 }
114}
115
116fn 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
141pub 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 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
171pub 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
182fn 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
215pub 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
254pub 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
293pub fn tip_to_push(watch: &TipWatch) -> Option<String> {
295 return tip_to_push_or_why(watch).ok();
296}
297
298pub 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 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, 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 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#[cfg(test)]
354#[path = "auto_push_trigger_tests.rs"]
355mod tests;