Skip to main content

git_policy_cli/landing/
service_replay.rs

1//! What:
2//!  The retry of a commit the service sent back because a path it names changed on the branch since its base: the change
3//!  the first preparation captured is merged onto the branch as it is now, and the worktree is not read again.
4//! Why:
5//!  A commit lands the bytes that were there when the person ran the command (`doc/decision/cli-git-concurrent-commits.md`,
6//!  "Serial landing"; `SPEC.md`: "Cli-git never re-prepares a commit from current worktree bytes"). Preparing again from
7//!  the worktree landed bytes written after the command, and bytes another command meant. The merge base is the
8//!  preparation base, and a path the other commits changed that the captured change already contains is kept as captured
9//!  (the same subsumption rule the earlier landing path applies, `replay_subsumption.rs`). The first merge is one Git
10//!  run; the subsumption runs only for the paths that conflicted, so a retry that merges cleanly costs three runs here:
11//!  `write-tree` for the captured tree, `merge-tree`, and `read-tree` of the merged tree into the private index.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const captured = await captureTree(readyCommit);              // after the service sent the commit back
16//! const merged = await mergeOntoBranch(captured, currentTip);   // { tree } | { conflict }
17//! ```
18
19use 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
39/// The transform that names the finding a retry ends with, as for every other concurrent-commit finding.
40const CORE_ID: &str = "concurrent-commit";
41
42/// What the first preparation captured, kept for the retry.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct Captured {
45    /// The commit the change was prepared against, which is the merge base of every retry.
46    pub base: String,
47    /// The tree of that base with the captured change applied.
48    pub tree: String,
49    /// When the worktree's bytes were captured (`CommitRequest::captured`); zero when unknown. A retry keeps the first
50    ///  capture's stamp, because the merged bytes are not a new capture.
51    pub captured: u64,
52    /// The commits the service landed since, with the stamps of their captures, as its last answer told.
53    pub landed: Vec<LandedCapture>,
54}
55
56/// What:
57///  The stamp of a capture finished now: nanoseconds of `CLOCK_MONOTONIC`, zero where the platform has none.
58/// Why:
59///   That clock is the host's, one for every process, and never steps back, so a capture made later by another wrapper
60///  process gets a larger stamp (`service_captures.rs`).
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// process.hrtime.bigint()
65/// ```
66pub fn capture_stamp() -> u64 {
67    #[cfg(unix)]
68    {
69        let mut now = libc::timespec { tv_sec: 0, tv_nsec: 0 };
70        // SAFETY: `clock_gettime` writes one `timespec` through a valid pointer.
71        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
85/// A retry: the change the first preparation captured, and which retry this is.
86pub struct Retry<'a> {
87    /// What the first preparation captured.
88    pub captured: &'a Captured,
89    /// Which retry this is, from 1, which names the retry's scratch files.
90    pub attempt: u64,
91}
92
93/// The first commit on the branch since the preparation base that changed one of the conflicting paths.
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct LandedFirst {
96    /// Its abbreviated name, as Git abbreviates it in this repository.
97    pub short: String,
98    /// Its subject.
99    pub subject: String,
100}
101
102/// A retry that could not combine the captured change with the branch.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct ReplayConflict {
105    /// The conflicting paths in Git's byte order, as the exact bytes Git reported.
106    pub paths: Vec<Vec<u8>>,
107    /// The commit that landed first on them, when it could be found.
108    pub landed: Option<LandedFirst>,
109    /// The Git directory of the worktree, where a saved message is kept.
110    pub git_dir: PathBuf,
111}
112
113/// What merging the captured change onto the branch came to.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub enum Merged {
116    /// The change applies; the merged tree exists in the repository.
117    Tree(String),
118    /// The change applies after the word-level second pass merged every file Git's line merge left in conflict; the tree
119    ///  exists in the repository, and the files are listed to the person and in the journal.
120    Combined {
121        /// The merged tree.
122        tree: String,
123        /// The files the word pass merged, with their parts.
124        files: Vec<MergedFile>,
125    },
126    /// The change overlaps what landed.
127    Conflict(Vec<Vec<u8>>),
128}
129
130/// What the merge needs to know about where it runs.
131pub struct MergeSite<'a> {
132    /// How to run Git.
133    pub context: &'a GitContext,
134    /// The directory the commit was run from.
135    pub cwd: &'a Path,
136    /// The common Git directory, which holds the objects and which the object-only steps run against.
137    pub common_dir: &'a Path,
138    /// The object store receiving what the steps write.
139    pub object_directory: &'a Path,
140    /// The commit's private directory, where the subsumption keeps its scratch index.
141    pub scratch: &'a Path,
142}
143
144/// What:
145///  What a retry replays, from the preparation that was sent back: its tree on its base, its capture stamp, and the commits
146///  the service says landed since.
147/// Why:
148///   Both the commit the service builds and the commit Git builds are replayed from the tree of their private index.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// await retryOf(context, cwd, ready, landed)
153/// ```
154pub 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
159/// What:
160///  The tree the private index holds, which is the captured change on its base.
161/// Why:
162///   The retry merges this tree, so it is written once, when the service sends the commit back.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// await capturedTree(context, cwd, commitIndex, objectDirectory)
167/// ```
168pub 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
173/// The path as the Latin-1 text the shared-path listing names it by, so every byte survives the comparison.
174fn latin1(bytes: &[u8]) -> String {
175    return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
176}
177
178/// What:
179///  Merges the captured change onto `tip` with the preparation base as the merge base.
180/// Why:
181///   Clean merges, the common case, are one Git run. A conflict is looked at again for the paths whose captured bytes
182///  already contain what landed (the person's worktree held the other commit's edit when this one was run), which the
183///  merge alone reports as a conflict on adjacent edits; only paths still in conflict after that are a conflict.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// await mergeCaptured(site, captured, tip) // { tree } | { conflict: paths }
188/// ```
189pub 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
230/// What:
231///  The event that tells the person which files the word pass merged for a replayed commit, or nothing when none were.
232/// Why:
233///   The replayed commit carries the person's own message, which is not changed; the parts are told next to the commit's other
234///  events, and the journal gets one entry per file at the same time.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// wordMergeNotice(files) // { type: 'word-merged', lines } | undefined
239/// ```
240pub 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
247/// What:
248///  The last resort for a merge that still conflicts: the word-level second pass over its conflicted files.
249/// Why:
250///   Git's line merge and the subsumption rules have both had their say; what is left is conflicts of adjacent lines and
251///  of different words, which the owner decided (2026-11-03) the wrapper's own merges re-merge by word, all or none. The
252///  attributes of the landed tip apply, because the merge reads no worktree.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// wordPass(site, tip, { tree, conflicts }, paths) // { combined: { tree, files } } | { conflict: paths }
257/// ```
258fn word_pass(site: &MergeSite<'_>, tip: &str, merge: (&str, &[Conflict]), paths: Vec<Vec<u8>>) -> Merged {
259    let (tree, conflicts): (&str, &[Conflict]) = merge;
260    // The commit may have been run from a subdirectory; the second pass reads paths from the top of the worktree.
261    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
284/// What:
285///  Which of this commit's capture and the landed commits' captures of one path is the later.
286/// Why:
287///   The service remembers the stamps of the commits it landed for this worktree. When every commit that changed the path
288///  since the base is remembered with a stamp, and all are earlier than this capture, this capture holds their edits and
289///  its bytes land; when all are later, theirs hold this commit's edit and the landed bytes stay. Anything else (a commit
290///  the service does not know, a stamp that is missing, one earlier and one later) is not decided by order.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// await captureOrder(site, captured, tip, path) // 'prepared' | 'landed' | 'unordered'
295/// ```
296fn 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
327/// What:
328///  The first commit after `base`, up to `tip`, that changed one of `paths`, with its abbreviated name and subject.
329/// Why:
330///   The conflict names the commit that landed first, in the words the person sees in their own log.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// await firstLanded(context, cwd, base, tip, paths) // { short, subject } | undefined
335/// ```
336pub 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
355/// The paths as the person reads them: one in quotes, several as a list.
356fn 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
364/// What:
365///  The words for a conflict: what overlapped, which commit landed first, where the message is saved, how to commit again.
366/// Why:
367///   Plain words, every input named and every way forward given, nothing about how the commit is landed.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// conflictText(conflict, savedMessage)
372/// ```
373pub 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
397/// The finding a conflict ends the command with.
398pub 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
407/// The ending of a retry whose merged result is what the branch already holds.
408pub 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/// Replay controls stay out of the release executable.
425#[cfg(test)]
426#[path = "service_replay_tests.rs"]
427mod tests;