1use crate::private_index_request::indexed_request;
20use crate::commit_transaction::{TransactionResult, TransactionStop, failed};
21use crate::path_bytes::{os_from_path_bytes, path_message_text};
22use crate::policy_events::PolicyEvent;
23use crate::merge_second_pass::{Conflict, ContextRunner, Merged as MergedFile};
24use crate::child_environment::environment_with_overlay;
25use crate::journald_native::CLIENT_IDENTIFIER;
26use crate::merge_second_pass_tree::{AttributeProbe, announce, attribute_tree_variables, resolve_tree};
27use crate::replay_capture_order::CaptureOrderDecision;
28use crate::replay_merge::{ReplayMerge, merge_replay_tree};
29use crate::replay_shared_paths::{SharedPath, list_shared_paths};
30use crate::replay_subsumption::{SubsumptionBase, subsume_landed_changes};
31use crate::service_messages::NOTHING_CHANGED;
32use crate::service_prepare::Ready;
33use crate::service_protocol::LandedCapture;
34use crate::transaction_git::{GitContext, GitRequest, run_git_checked};
35use std::collections::BTreeMap;
36use std::ffi::OsString;
37use std::path::{Path, PathBuf};
38
39const CORE_ID: &str = "concurrent-commit";
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct Captured {
45 pub base: String,
47 pub tree: String,
49 pub captured: u64,
52 pub landed: Vec<LandedCapture>,
54}
55
56pub fn capture_stamp() -> u64 {
67 #[cfg(unix)]
68 {
69 let mut now = libc::timespec { tv_sec: 0, tv_nsec: 0 };
70 let read: i32 = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &raw mut now) };
72 if read != 0 {
73 return 0;
74 }
75 let seconds: u64 = u64::try_from(now.tv_sec).unwrap_or(0);
76 let nanoseconds: u64 = u64::try_from(now.tv_nsec).unwrap_or(0);
77 return seconds.saturating_mul(1_000_000_000).saturating_add(nanoseconds).max(1);
78 }
79 #[cfg(not(unix))]
80 {
81 return 0;
82 }
83}
84
85pub struct Retry<'a> {
87 pub captured: &'a Captured,
89 pub attempt: u64,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct LandedFirst {
96 pub short: String,
98 pub subject: String,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct ReplayConflict {
105 pub paths: Vec<Vec<u8>>,
107 pub landed: Option<LandedFirst>,
109 pub git_dir: PathBuf,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
115pub enum Merged {
116 Tree(String),
118 Combined {
121 tree: String,
123 files: Vec<MergedFile>,
125 },
126 Conflict(Vec<Vec<u8>>),
128}
129
130pub struct MergeSite<'a> {
132 pub context: &'a GitContext,
134 pub cwd: &'a Path,
136 pub common_dir: &'a Path,
138 pub object_directory: &'a Path,
140 pub scratch: &'a Path,
142}
143
144pub fn retry_of(context: &GitContext, cwd: &Path, ready: &Ready, landed: Vec<LandedCapture>) -> Result<Captured, TransactionStop> {
155 let tree: String = captured_tree(context, cwd, ready.inputs.commit_index.as_path(), ready.inputs.capture.layout.object_directory.as_path())?;
156 return Ok(Captured { base: ready.base.clone(), tree, captured: ready.captured_at, landed });
157}
158
159pub fn captured_tree(context: &GitContext, cwd: &Path, commit_index: &Path, object_directory: &Path) -> Result<String, TransactionStop> {
169 let written = run_git_checked(context, &indexed_request(cwd, &["write-tree"], Some(commit_index), Some(object_directory)))?;
170 return Ok(String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string());
171}
172
173fn latin1(bytes: &[u8]) -> String {
175 return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
176}
177
178pub fn merge_captured(site: &MergeSite<'_>, captured: &Captured, tip: &str, attempt: u64) -> Result<Merged, TransactionStop> {
190 let merge = |base: &str| -> Result<ReplayMerge, TransactionStop> {
191 return merge_replay_tree(site.context, site.common_dir, site.cwd, base, tip, captured.tree.as_str())
192 .map_err(|error| { return failed(format!("merging the commit onto the branch failed: {error:?}")); });
193 };
194 let (conflicting, first_tree, first_conflicts) = match merge(captured.base.as_str())? {
195 ReplayMerge::Merged { tree_oid } => return Ok(Merged::Tree(tree_oid)),
196 ReplayMerge::Conflict { paths, tree_oid, conflicts } => (paths, tree_oid, conflicts),
197 };
198 let names: Vec<String> = conflicting.iter().map(|path| { return latin1(path.as_slice()); }).collect();
199 let shared: Vec<SharedPath> = list_shared_paths(site.context, site.common_dir, site.cwd, captured.base.as_str(), tip, captured.tree.as_str())
200 .map_err(|error| { return failed(format!("listing the paths the branch changed failed: {error:?}")); })?
201 .into_iter()
202 .filter(|entry| { return names.contains(&entry.path); })
203 .collect();
204 let mut order: BTreeMap<String, CaptureOrderDecision> = BTreeMap::new();
205 for entry in &shared {
206 let decision: CaptureOrderDecision = capture_order(site, captured, tip, entry.path.as_str())?;
207 order.insert(entry.path.clone(), decision);
208 }
209 let settled: SubsumptionBase = subsume_landed_changes(
210 site.context,
211 site.common_dir,
212 site.object_directory,
213 site.cwd,
214 site.scratch,
215 attempt,
216 captured.base.as_str(),
217 shared.as_slice(),
218 &order,
219 )
220 .map_err(|error| { return failed(format!("comparing the commit with what landed failed: {error:?}")); })?;
221 if settled.subsumed_paths.is_empty() && settled.kept_landed_paths.is_empty() {
222 return Ok(word_pass(site, tip, (first_tree.as_str(), first_conflicts.as_slice()), conflicting));
223 }
224 return match merge(settled.merge_base.as_str())? {
225 ReplayMerge::Merged { tree_oid } => Ok(Merged::Tree(tree_oid)),
226 ReplayMerge::Conflict { paths, tree_oid, conflicts } => Ok(word_pass(site, tip, (tree_oid.as_str(), conflicts.as_slice()), paths)),
227 };
228}
229
230pub fn word_merge_notice(files: &[MergedFile]) -> Option<PolicyEvent> {
241 if files.is_empty() {
242 return None;
243 }
244 return Some(PolicyEvent::WordMerged { lines: announce("", (CLIENT_IDENTIFIER, "commit-replay"), files) });
245}
246
247fn word_pass(site: &MergeSite<'_>, tip: &str, merge: (&str, &[Conflict]), paths: Vec<Vec<u8>>) -> Merged {
259 let (tree, conflicts): (&str, &[Conflict]) = merge;
260 let mut top_request: GitRequest = GitRequest::new(site.cwd, &["rev-parse", "--show-toplevel"]);
262 top_request.without_prefix = true;
263 let top: PathBuf = match run_git_checked(site.context, &top_request) {
264 Ok(found) => PathBuf::from(String::from_utf8_lossy(found.stdout.as_slice()).trim()),
265 Err(_) => return Merged::Conflict(paths),
266 };
267 let process_environment: Vec<(OsString, OsString)> = std::env::vars_os().collect();
268 let variables: Vec<(OsString, OsString)> = environment_with_overlay(process_environment.as_slice(), site.context.overlay.as_slice());
269 let probing: ContextRunner<'_> = ContextRunner { context: site.context, cwd: top.as_path(), without_prefix: true, object_directory: None, environment: Vec::new() };
270 let probe: AttributeProbe<'_> = AttributeProbe { directory: top.as_path(), global_prefix: &[], variables: variables.as_slice(), runner: &probing };
271 let runner: ContextRunner<'_> = ContextRunner {
272 context: site.context,
273 cwd: top.as_path(),
274 without_prefix: true,
275 object_directory: Some(site.object_directory),
276 environment: attribute_tree_variables(tip, &probe),
277 };
278 return match resolve_tree(&runner, site.common_dir, tree, conflicts) {
279 Some(resolved) => Merged::Combined { tree: resolved.tree, files: resolved.merged },
280 None => Merged::Conflict(paths),
281 };
282}
283
284fn capture_order(site: &MergeSite<'_>, captured: &Captured, tip: &str, path: &str) -> Result<CaptureOrderDecision, TransactionStop> {
297 if captured.captured == 0 {
298 return Ok(CaptureOrderDecision::Unordered);
299 }
300 let bytes: Vec<u8> = path.chars().filter_map(|character| { return u8::try_from(u32::from(character)).ok(); }).collect();
301 let exclusion: String = format!("^{}", captured.base);
302 let mut request: GitRequest = GitRequest::new(site.cwd, &["log", "--format=%H", "--no-color", tip, exclusion.as_str(), "--"]);
303 let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
304 pathspec.extend_from_slice(bytes.as_slice());
305 request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
306 request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
307 let listed = run_git_checked(site.context, &request)?;
308 let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
309 let stamps: Vec<Option<u64>> = text
310 .lines()
311 .filter(|line| { return !line.is_empty(); })
312 .map(|commit| { return captured.landed.iter().find(|known| { return known.commit == commit; }).map(|known| { return known.captured; }); })
313 .collect();
314 if stamps.is_empty() || stamps.iter().any(Option::is_none) {
315 return Ok(CaptureOrderDecision::Unordered);
316 }
317 let known: Vec<u64> = stamps.into_iter().flatten().collect();
318 if known.iter().all(|stamp| { return *stamp < captured.captured; }) {
319 return Ok(CaptureOrderDecision::Prepared);
320 }
321 if known.iter().all(|stamp| { return *stamp > captured.captured; }) {
322 return Ok(CaptureOrderDecision::Landed);
323 }
324 return Ok(CaptureOrderDecision::Unordered);
325}
326
327pub fn first_landed(context: &GitContext, cwd: &Path, base: &str, tip: &str, paths: &[Vec<u8>]) -> Result<Option<LandedFirst>, TransactionStop> {
337 let exclusion: String = format!("^{base}");
338 let mut request: GitRequest = GitRequest::new(
339 cwd,
340 &["log", "--reverse", "--format=%h%x00%s", "--no-color", "--no-show-signature", tip, exclusion.as_str(), "--"],
341 );
342 for path in paths {
343 let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
344 pathspec.extend_from_slice(path.as_slice());
345 request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
346 }
347 request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
348 let listed = run_git_checked(context, &request)?;
349 let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
350 let Some(first) = text.lines().find(|line| { return !line.is_empty(); }) else { return Ok(None) };
351 let (short, subject) = first.split_once('\0').unwrap_or((first, ""));
352 return Ok(Some(LandedFirst { short: short.to_string(), subject: subject.to_string() }));
353}
354
355fn paths_text(paths: &[Vec<u8>]) -> String {
357 let quoted: Vec<String> = paths.iter().map(|path| { return format!("`{}`", path_message_text(path.as_slice())); }).collect();
358 return match quoted.as_slice() {
359 [only] => format!("the file {only}"),
360 many => format!("these files: {}", many.join(", ")),
361 };
362}
363
364pub fn conflict_text(conflict: &ReplayConflict, saved: Option<&Path>) -> String {
374 let landed: String = match &conflict.landed {
375 Some(first) => format!("the commit {} (\"{}\")", first.short, first.subject),
376 None => String::from("another commit"),
377 };
378 let look: String = match &conflict.landed {
379 Some(first) => format!("Look at what that commit changed with `git show {}`, bring your", first.short),
380 None => String::from("Look at what the branch changed, bring your"),
381 };
382 let again: String = match saved {
383 Some(file) => format!(
384 "Your message is saved in {}. {look} edit up to date with it, and run the commit again with `git commit -F {}` and the same paths.",
385 file.display(),
386 file.display()
387 ),
388 None => format!("{look} edit up to date with it, and run the commit again."),
389 };
390 return format!(
391 "This commit was not made. While it was being prepared, {landed} changed {}, which this commit also changes, and the two \
392 changes cannot be combined. {again} {NOTHING_CHANGED}",
393 paths_text(conflict.paths.as_slice())
394 );
395}
396
397pub fn conflict_result(conflict: &ReplayConflict, saved: Option<&Path>) -> TransactionResult {
399 return TransactionResult {
400 events: vec![PolicyEvent::CoreFinding { core_id: CORE_ID, code: "replay-conflict", message: conflict_text(conflict, saved) }],
401 exit_code: 1,
402 landed_oid: None,
403 uses_lfs: false,
404 };
405}
406
407pub fn already_committed_result(branch: &str) -> TransactionResult {
409 return TransactionResult {
410 events: vec![PolicyEvent::CoreFinding {
411 core_id: CORE_ID,
412 code: "already-committed",
413 message: format!(
414 "This commit was not made. While it was being prepared, other commits put everything it changes on the branch {branch}, \
415 so nothing is left to commit. Run `git log` to see them. {NOTHING_CHANGED}"
416 ),
417 }],
418 exit_code: 1,
419 landed_oid: None,
420 uses_lfs: false,
421 };
422}
423
424#[cfg(test)]
426#[path = "service_replay_tests.rs"]
427mod tests;