Skip to main content

git_policy_cli/
replay_capture_history.rs

1//! What:
2//!  The capture-order inputs of one replay:
3//!       the first-parent commits landed since the preparation base with their changed paths,
4//!       and the decisions for the shared paths read from `captured.json` and the landed-capture records.
5//! Why:
6//!  Two Git processes list the history whatever its length; each landed commit's record says which worktree
7//!       captured which path and when, which is what the decision compares.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await decideSharedPaths({ gitPath, shadowPath, gitDir, transactionDirectory, base, current, paths });
12//! ```
13
14/// The capture-order decision and its parsers.
15use super::replay_capture_order::{CaptureOrderDecision, CaptureView, LandedChange, capture_view, decide_capture_order, parse_commit_changes};
16/// The captured and landed record readers.
17use super::capture_records::{landed_record_directory, parse_landed_capture_record, read_captured_record};
18use super::capture_store::read_store_file;
19use super::replay_shadow::run_shadow_checked;
20use super::transaction_git::{GitContext, GitFailure};
21/// Decisions and records keyed by path or commit.
22use std::collections::BTreeMap;
23/// `Path` names the shadow, the git directory and the transaction.
24use std::path::Path;
25
26/// What:
27///  A capture-order failure:
28///       a Git command failed, or a record or listing could not be read or parsed.
29/// Why:
30///   Replay reports each as `transaction-failed`; a malformed record is never treated as "unordered".
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// throw new CaptureOrderRecordError(message);
35/// ```
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum CaptureHistoryError {
38    /// A Git command failed.
39    Git(GitFailure),
40    /// A record file could not be read, or the listing did not parse.
41    Record(String),
42}
43
44/// What:
45///  Lists the commits landed since the preparation base on the first-parent line with the paths each changed.
46/// Why:
47///   `rev-list --first-parent` gives the line; one `diff-tree --stdin` gives every commit's changes,
48///       so the process count does not depend on the history length.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// await listLandedChanges({ gitPath, shadowPath, base, current });
53/// ```
54pub fn list_landed_changes(
55    context: &GitContext,
56    shadow: &Path,
57    cwd: &Path,
58    base: Option<&str>,
59    current: &str,
60) -> Result<Vec<LandedChange>, CaptureHistoryError> {
61    let exclusion: String = base.map(|oid| { return format!("^{oid}"); }).unwrap_or_default();
62    // `mut` lets the exclusion be included only for a committed base.
63    let mut arguments: Vec<&str> = vec!["rev-list", "--first-parent", current];
64    if base.is_some() {
65        arguments.push(exclusion.as_str());
66    }
67    arguments.push("--");
68    let listed = run_shadow_checked(context, shadow, cwd, &arguments, None, None, &[]).map_err(CaptureHistoryError::Git)?;
69    if listed.stdout.is_empty() {
70        return Ok(Vec::new());
71    }
72    let changes = run_shadow_checked(
73        context,
74        shadow,
75        cwd,
76        &[
77            "diff-tree",
78            "--stdin",
79            "--root",
80            "-r",
81            "-z",
82            "--raw",
83            "--no-renames",
84            "--no-abbrev",
85            "-m",
86            "--first-parent",
87        ],
88        Some(listed.stdout.as_slice()),
89        None,
90        &[],
91    )
92    .map_err(CaptureHistoryError::Git)?;
93    let text: String = changes.stdout.iter().map(|byte| { return char::from(*byte); }).collect();
94    return parse_commit_changes(text.as_str()).map_err(|error| { return CaptureHistoryError::Record(error.0); });
95}
96
97/// What:
98///  Reads the landed-capture record of each listed commit that has one, as capture views keyed by commit.
99/// Why:
100///   A commit without a record leaves the path unordered, so a missing record is skipped, not an error;
101///       a malformed record fails the replay.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// await readLandedCaptures({ gitDir, commits });
106/// ```
107fn read_landed_captures(git_dir: &Path, commits: &[LandedChange]) -> Result<BTreeMap<String, CaptureView>, CaptureHistoryError> {
108    let directory = landed_record_directory(git_dir);
109    // `mut` lets the views be collected one commit at a time.
110    let mut views: BTreeMap<String, CaptureView> = BTreeMap::new();
111    for change in commits {
112        let name: String = format!("{}.json", change.commit);
113        let text: Option<String> = read_store_file(directory.join(&name).as_path())
114            .map_err(|error| { return CaptureHistoryError::Record(error.0); })?;
115        let Some(found) = text else {
116            continue;
117        };
118        let record = parse_landed_capture_record(found.as_str(), &format!("landed/{name}"))
119            .map_err(|error| { return CaptureHistoryError::Record(error.0); })?;
120        views.insert(
121            change.commit.clone(),
122            capture_view(record.worktree_id.as_str(), record.sequence, &record.worktree_paths),
123        );
124    }
125    return Ok(views);
126}
127
128/// What:
129///  Decides every shared path of one replay: the transaction's `captured.json` and the landed records
130///       of the first-parent history since the base.
131/// Why:
132///   When the transaction's capture names none of the shared paths, or the transaction has no capture,
133///       every path is unordered and no history process runs.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// await decideSharedPaths({ gitPath, shadowPath, gitDir, transactionDirectory, base, current, paths });
138/// ```
139pub fn decide_shared_paths(
140    context: &GitContext,
141    shadow: &Path,
142    cwd: &Path,
143    git_dir: &Path,
144    transaction_directory: &Path,
145    base: Option<&str>,
146    current: &str,
147    paths: &[String],
148) -> Result<BTreeMap<String, CaptureOrderDecision>, CaptureHistoryError> {
149    let captured = match read_captured_record(transaction_directory).map_err(|error| { return CaptureHistoryError::Record(error.0); })? {
150        Some(found) => found,
151        None => return Ok(BTreeMap::new()),
152    };
153    let own: CaptureView = capture_view(captured.worktree_id.as_str(), captured.sequence, &captured.worktree_paths);
154    let candidates: Vec<&String> = paths.iter().filter(|path| { return own.paths.contains(path.as_str()); }).collect();
155    if candidates.is_empty() {
156        return Ok(BTreeMap::new());
157    }
158    let history: Vec<LandedChange> = list_landed_changes(context, shadow, cwd, base, current)?;
159    let landed: BTreeMap<String, CaptureView> = read_landed_captures(git_dir, &history)?;
160    return Ok(candidates
161        .into_iter()
162        .map(|path| {
163            let decision: CaptureOrderDecision = decide_capture_order(path.as_str(), &own, &history, &landed);
164            return (path.to_string(), decision);
165        })
166        .collect());
167}
168
169#[cfg(test)]
170#[path = "replay_capture_history_tests.rs"]
171mod tests;