Skip to main content

git_policy_cli/
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 super::capture_order::indexed_request;
20use super::commit_transaction::{TransactionResult, TransactionStop, failed};
21use super::path_bytes::{os_from_path_bytes, path_message_text};
22use super::policy_events::PolicyEvent;
23use super::replay_capture_order::CaptureOrderDecision;
24use super::replay_merge::{ReplayMerge, merge_replay_tree};
25use super::replay_shared_paths::{SharedPath, list_shared_paths};
26use super::replay_subsumption::{SubsumptionBase, subsume_landed_changes};
27use super::service_messages::NOTHING_CHANGED;
28use super::service_prepare::Ready;
29use super::service_protocol::LandedCapture;
30use super::transaction_git::{GitContext, GitRequest, run_git_checked};
31use std::collections::BTreeMap;
32use std::ffi::OsString;
33use std::path::{Path, PathBuf};
34
35/// The transform that names the finding a retry ends with, as for every other concurrent-commit finding.
36const CORE_ID: &str = "concurrent-commit";
37
38/// What the first preparation captured, kept for the retry.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct Captured {
41    /// The commit the change was prepared against, which is the merge base of every retry.
42    pub base: String,
43    /// The tree of that base with the captured change applied.
44    pub tree: String,
45    /// When the worktree's bytes were captured (`CommitRequest::captured`); zero when unknown. A retry keeps the first
46    ///  capture's stamp, because the merged bytes are not a new capture.
47    pub captured: u64,
48    /// The commits the service landed since, with the stamps of their captures, as its last answer told.
49    pub landed: Vec<LandedCapture>,
50}
51
52/// What:
53///  The stamp of a capture finished now: nanoseconds of `CLOCK_MONOTONIC`, zero where the platform has none.
54/// Why:
55///   That clock is the host's, one for every process, and never steps back, so a capture made later by another wrapper
56///  process gets a larger stamp (`service_captures.rs`).
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// process.hrtime.bigint()
61/// ```
62pub fn capture_stamp() -> u64 {
63    #[cfg(unix)]
64    {
65        let mut now = libc::timespec { tv_sec: 0, tv_nsec: 0 };
66        // SAFETY: `clock_gettime` writes one `timespec` through a valid pointer.
67        let read: i32 = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &raw mut now) };
68        if read != 0 {
69            return 0;
70        }
71        let seconds: u64 = u64::try_from(now.tv_sec).unwrap_or(0);
72        let nanoseconds: u64 = u64::try_from(now.tv_nsec).unwrap_or(0);
73        return seconds.saturating_mul(1_000_000_000).saturating_add(nanoseconds).max(1);
74    }
75    #[cfg(not(unix))]
76    {
77        return 0;
78    }
79}
80
81/// A retry: the change the first preparation captured, and which retry this is.
82pub struct Retry<'a> {
83    /// What the first preparation captured.
84    pub captured: &'a Captured,
85    /// Which retry this is, from 1, which names the retry's scratch files.
86    pub attempt: u64,
87}
88
89/// The first commit on the branch since the preparation base that changed one of the conflicting paths.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct LandedFirst {
92    /// Its abbreviated name, as Git abbreviates it in this repository.
93    pub short: String,
94    /// Its subject.
95    pub subject: String,
96}
97
98/// A retry that could not combine the captured change with the branch.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct ReplayConflict {
101    /// The conflicting paths in Git's byte order, as the exact bytes Git reported.
102    pub paths: Vec<Vec<u8>>,
103    /// The commit that landed first on them, when it could be found.
104    pub landed: Option<LandedFirst>,
105    /// The Git directory of the worktree, where a saved message is kept.
106    pub git_dir: PathBuf,
107}
108
109/// What merging the captured change onto the branch came to.
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub enum Merged {
112    /// The change applies; the merged tree exists in the repository.
113    Tree(String),
114    /// The change overlaps what landed.
115    Conflict(Vec<Vec<u8>>),
116}
117
118/// What the merge needs to know about where it runs.
119pub struct MergeSite<'a> {
120    /// How to run Git.
121    pub context: &'a GitContext,
122    /// The directory the commit was run from.
123    pub cwd: &'a Path,
124    /// The common Git directory, which holds the objects and which the object-only steps run against.
125    pub common_dir: &'a Path,
126    /// The object store receiving what the steps write.
127    pub object_directory: &'a Path,
128    /// The commit's private directory, where the subsumption keeps its scratch index.
129    pub scratch: &'a Path,
130}
131
132/// What:
133///  What a retry replays, from the preparation that was sent back: its tree on its base, its capture stamp, and the commits
134///  the service says landed since.
135/// Why:
136///   Both the commit the service builds and the commit Git builds are replayed from the tree of their private index.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// await retryOf(context, cwd, ready, landed)
141/// ```
142pub fn retry_of(context: &GitContext, cwd: &Path, ready: &Ready, landed: Vec<LandedCapture>) -> Result<Captured, TransactionStop> {
143    let tree: String = captured_tree(context, cwd, ready.inputs.commit_index.as_path(), ready.inputs.capture.layout.object_directory.as_path())?;
144    return Ok(Captured { base: ready.base.clone(), tree, captured: ready.captured_at, landed });
145}
146
147/// What:
148///  The tree the private index holds, which is the captured change on its base.
149/// Why:
150///   The retry merges this tree, so it is written once, when the service sends the commit back.
151///
152/// In TS you'd write (pseudocode):
153/// ```ts
154/// await capturedTree(context, cwd, commitIndex, objectDirectory)
155/// ```
156pub fn captured_tree(context: &GitContext, cwd: &Path, commit_index: &Path, object_directory: &Path) -> Result<String, TransactionStop> {
157    let written = run_git_checked(context, &indexed_request(cwd, &["write-tree"], Some(commit_index), Some(object_directory)))?;
158    return Ok(String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string());
159}
160
161/// The path as the Latin-1 text the shared-path listing names it by, so every byte survives the comparison.
162fn latin1(bytes: &[u8]) -> String {
163    return bytes.iter().map(|byte| { return char::from(*byte); }).collect();
164}
165
166/// What:
167///  Merges the captured change onto `tip` with the preparation base as the merge base.
168/// Why:
169///   Clean merges, the common case, are one Git run. A conflict is looked at again for the paths whose captured bytes
170///  already contain what landed (the person's worktree held the other commit's edit when this one was run), which the
171///  merge alone reports as a conflict on adjacent edits; only paths still in conflict after that are a conflict.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// await mergeCaptured(site, captured, tip) // { tree } | { conflict: paths }
176/// ```
177pub fn merge_captured(site: &MergeSite<'_>, captured: &Captured, tip: &str, attempt: u64) -> Result<Merged, TransactionStop> {
178    let merge = |base: &str| -> Result<ReplayMerge, TransactionStop> {
179        return merge_replay_tree(site.context, site.common_dir, site.cwd, base, tip, captured.tree.as_str())
180            .map_err(|error| { return failed(format!("merging the commit onto the branch failed: {error:?}")); });
181    };
182    let conflicting: Vec<Vec<u8>> = match merge(captured.base.as_str())? {
183        ReplayMerge::Merged { tree_oid } => return Ok(Merged::Tree(tree_oid)),
184        ReplayMerge::Conflict { paths } => paths,
185    };
186    let names: Vec<String> = conflicting.iter().map(|path| { return latin1(path.as_slice()); }).collect();
187    let shared: Vec<SharedPath> = list_shared_paths(site.context, site.common_dir, site.cwd, captured.base.as_str(), tip, captured.tree.as_str())
188        .map_err(|error| { return failed(format!("listing the paths the branch changed failed: {error:?}")); })?
189        .into_iter()
190        .filter(|entry| { return names.contains(&entry.path); })
191        .collect();
192    let mut order: BTreeMap<String, CaptureOrderDecision> = BTreeMap::new();
193    for entry in &shared {
194        let decision: CaptureOrderDecision = capture_order(site, captured, tip, entry.path.as_str())?;
195        order.insert(entry.path.clone(), decision);
196    }
197    let settled: SubsumptionBase = subsume_landed_changes(
198        site.context,
199        site.common_dir,
200        site.object_directory,
201        site.cwd,
202        site.scratch,
203        attempt,
204        captured.base.as_str(),
205        shared.as_slice(),
206        &order,
207    )
208    .map_err(|error| { return failed(format!("comparing the commit with what landed failed: {error:?}")); })?;
209    if settled.subsumed_paths.is_empty() && settled.kept_landed_paths.is_empty() {
210        return Ok(Merged::Conflict(conflicting));
211    }
212    return match merge(settled.merge_base.as_str())? {
213        ReplayMerge::Merged { tree_oid } => Ok(Merged::Tree(tree_oid)),
214        ReplayMerge::Conflict { paths } => Ok(Merged::Conflict(paths)),
215    };
216}
217
218/// What:
219///  Which of this commit's capture and the landed commits' captures of one path is the later.
220/// Why:
221///   The service remembers the stamps of the commits it landed for this worktree. When every commit that changed the path
222///  since the base is remembered with a stamp, and all are earlier than this capture, this capture holds their edits and
223///  its bytes land; when all are later, theirs hold this commit's edit and the landed bytes stay. Anything else (a commit
224///  the service does not know, a stamp that is missing, one earlier and one later) is not decided by order.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// await captureOrder(site, captured, tip, path) // 'prepared' | 'landed' | 'unordered'
229/// ```
230fn capture_order(site: &MergeSite<'_>, captured: &Captured, tip: &str, path: &str) -> Result<CaptureOrderDecision, TransactionStop> {
231    if captured.captured == 0 {
232        return Ok(CaptureOrderDecision::Unordered);
233    }
234    let bytes: Vec<u8> = path.chars().filter_map(|character| { return u8::try_from(u32::from(character)).ok(); }).collect();
235    let exclusion: String = format!("^{}", captured.base);
236    let mut request: GitRequest = GitRequest::new(site.cwd, &["log", "--format=%H", "--no-color", tip, exclusion.as_str(), "--"]);
237    let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
238    pathspec.extend_from_slice(bytes.as_slice());
239    request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
240    request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
241    let listed = run_git_checked(site.context, &request)?;
242    let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
243    let stamps: Vec<Option<u64>> = text
244        .lines()
245        .filter(|line| { return !line.is_empty(); })
246        .map(|commit| { return captured.landed.iter().find(|known| { return known.commit == commit; }).map(|known| { return known.captured; }); })
247        .collect();
248    if stamps.is_empty() || stamps.iter().any(Option::is_none) {
249        return Ok(CaptureOrderDecision::Unordered);
250    }
251    let known: Vec<u64> = stamps.into_iter().flatten().collect();
252    if known.iter().all(|stamp| { return *stamp < captured.captured; }) {
253        return Ok(CaptureOrderDecision::Prepared);
254    }
255    if known.iter().all(|stamp| { return *stamp > captured.captured; }) {
256        return Ok(CaptureOrderDecision::Landed);
257    }
258    return Ok(CaptureOrderDecision::Unordered);
259}
260
261/// What:
262///  The first commit after `base`, up to `tip`, that changed one of `paths`, with its abbreviated name and subject.
263/// Why:
264///   The conflict names the commit that landed first, in the words the person sees in their own log.
265///
266/// In TS you'd write (pseudocode):
267/// ```ts
268/// await firstLanded(context, cwd, base, tip, paths) // { short, subject } | undefined
269/// ```
270pub fn first_landed(context: &GitContext, cwd: &Path, base: &str, tip: &str, paths: &[Vec<u8>]) -> Result<Option<LandedFirst>, TransactionStop> {
271    let exclusion: String = format!("^{base}");
272    let mut request: GitRequest = GitRequest::new(
273        cwd,
274        &["log", "--reverse", "--format=%h%x00%s", "--no-color", "--no-show-signature", tip, exclusion.as_str(), "--"],
275    );
276    for path in paths {
277        let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
278        pathspec.extend_from_slice(path.as_slice());
279        request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
280    }
281    request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
282    let listed = run_git_checked(context, &request)?;
283    let text: String = String::from_utf8_lossy(listed.stdout.as_slice()).into_owned();
284    let Some(first) = text.lines().find(|line| { return !line.is_empty(); }) else { return Ok(None) };
285    let (short, subject) = first.split_once('\0').unwrap_or((first, ""));
286    return Ok(Some(LandedFirst { short: short.to_string(), subject: subject.to_string() }));
287}
288
289/// The paths as the person reads them: one in quotes, several as a list.
290fn paths_text(paths: &[Vec<u8>]) -> String {
291    let quoted: Vec<String> = paths.iter().map(|path| { return format!("`{}`", path_message_text(path.as_slice())); }).collect();
292    return match quoted.as_slice() {
293        [only] => format!("the file {only}"),
294        many => format!("these files: {}", many.join(", ")),
295    };
296}
297
298/// What:
299///  The words for a conflict: what overlapped, which commit landed first, where the message is saved, how to commit again.
300/// Why:
301///   Plain words, every input named and every way forward given, nothing about how the commit is landed.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// conflictText(conflict, savedMessage)
306/// ```
307pub fn conflict_text(conflict: &ReplayConflict, saved: Option<&Path>) -> String {
308    let landed: String = match &conflict.landed {
309        Some(first) => format!("the commit {} (\"{}\")", first.short, first.subject),
310        None => String::from("another commit"),
311    };
312    let look: String = match &conflict.landed {
313        Some(first) => format!("Look at what that commit changed with `git show {}`, bring your", first.short),
314        None => String::from("Look at what the branch changed, bring your"),
315    };
316    let again: String = match saved {
317        Some(file) => format!(
318            "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.",
319            file.display(),
320            file.display()
321        ),
322        None => format!("{look} edit up to date with it, and run the commit again."),
323    };
324    return format!(
325        "This commit was not made. While it was being prepared, {landed} changed {}, which this commit also changes, and the two \
326         changes cannot be combined. {again} {NOTHING_CHANGED}",
327        paths_text(conflict.paths.as_slice())
328    );
329}
330
331/// The finding a conflict ends the command with.
332pub fn conflict_result(conflict: &ReplayConflict, saved: Option<&Path>) -> TransactionResult {
333    return TransactionResult {
334        events: vec![PolicyEvent::CoreFinding { core_id: CORE_ID, code: "replay-conflict", message: conflict_text(conflict, saved) }],
335        exit_code: 1,
336        landed_oid: None,
337        uses_lfs: false,
338    };
339}
340
341/// The ending of a retry whose merged result is what the branch already holds.
342pub fn already_committed_result(branch: &str) -> TransactionResult {
343    return TransactionResult {
344        events: vec![PolicyEvent::CoreFinding {
345            core_id: CORE_ID,
346            code: "already-committed",
347            message: format!(
348                "This commit was not made. While it was being prepared, other commits put everything it changes on the branch {branch}, \
349                 so nothing is left to commit. Run `git log` to see them. {NOTHING_CHANGED}"
350            ),
351        }],
352        exit_code: 1,
353        landed_oid: None,
354        uses_lfs: false,
355    };
356}
357
358/// Replay controls stay out of the release executable.
359#[cfg(test)]
360#[path = "service_replay_tests.rs"]
361mod tests;