Skip to main content

git_policy_cli/
commit_no_change.rs

1//! What:
2//!  The normalization-only transaction: a correction whose complete intended tree equals the recorded base lands as
3//!  a reconciliation of the index and the worktree copies, without an empty commit.
4//! Why:
5//!  The incumbent's `completeNoChangeTransaction` lands the normalization through the landing critical section
6//!  against the recorded base, so a base that moved since invocation fails without touching the index
7//!  (`src/policy-engine/commit-transaction-no-change.ts`).
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const result = await completeNoChangeTransaction({ workspace, capture, gitPath, cwd, repositoryRoot, mode, eligible, ... });
12//! ```
13
14use super::capture_invocation::InvocationCapture;
15use super::commit_conclusion::events_with_summary;
16use super::commit_landing_lock::acquire_landing_lock;
17use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, failed};
18use super::commit_workspace::CommitWorkspace;
19use super::landing_attempt::{LandingOutcome, LandingPayload, LandingRequest, land_transaction};
20use super::landing_findings::{LANDING_FINDING_EXIT_CODE, landing_finding};
21use super::owner_lock::OwnerLock;
22use super::path_bytes::path_message_text;
23use super::policy_events::PolicyEvent;
24use super::transaction_git::{GitFailure, GitRequest, run_git_checked};
25
26/// The core of the no-change finding.
27pub const NO_CHANGE_CORE_ID: &str = "commit-normalization";
28
29/// The code of the no-change finding.
30pub const NO_CHANGE_CODE: &str = "no-change";
31use super::transaction_journal::{AddedPath, Base, TransactionMode};
32use super::worktree_completion::{InstallResult, install_added_worktree_files};
33
34
35/// What:
36///  What the no-change decision reads.
37/// Why:
38///   The decision compares the intended tree with the recorded base's tree.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// { workspace, capture, gitPath, cwd, repositoryRoot, mode, eligible, intendedTreeOid, committedPaths, ... }
43/// ```
44pub struct NoChangeInput<'a> {
45    /// The invocation's fixed facts.
46    pub invocation: &'a CommitInvocation<'a>,
47    /// The published workspace.
48    pub workspace: &'a mut CommitWorkspace,
49    /// The invocation capture.
50    pub capture: &'a InvocationCapture,
51    /// The original selection mode.
52    pub mode: TransactionMode,
53    /// False for an explicit allow-empty, an amend and a sequencer conclusion.
54    pub eligible: bool,
55    /// The completely settled private tree.
56    pub intended_tree: &'a str,
57    /// The selection including policy additions.
58    pub committed_paths: &'a [Vec<u8>],
59    /// Selected corrected files that match their initial bytes.
60    pub selected_worktree_paths: &'a [AddedPath],
61    /// The settled pass's events.
62    pub events: &'a [PolicyEvent],
63    /// How many passes changed content.
64    pub changed_passes: u64,
65    /// The corrected paths, for the summary.
66    pub changed_paths: &'a [Vec<u8>],
67    /// The backoff budget for a foreign `index.lock`.
68    pub index_lock_timeout_ms: u64,
69}
70
71/// What:
72///  The tree of the recorded base commit, never a later moving `HEAD`.
73/// Why:
74///   The normalization is judged against what the invocation saw.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// git rev-parse --verify <base>^{tree}
79/// ```
80fn base_tree(input: &NoChangeInput<'_>, base: &str) -> Result<String, GitFailure> {
81    let revision: String = format!("{base}^{{tree}}");
82    let mut request: GitRequest = GitRequest::new(input.invocation.cwd, &["rev-parse", "--verify", revision.as_str()]);
83    request.without_prefix = true;
84    let output = run_git_checked(input.invocation.context, &request)?;
85    return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
86}
87
88/// What:
89///  The message of the no-change finding.
90/// Why:
91///   Worktree copies that changed since are kept and named.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// hasConflicts ? `No commit created: ... ${JSON.stringify(conflicted)}. Compare them with HEAD before retrying.` : '...'
96/// ```
97fn no_change_message(conflicted: &[Vec<u8>]) -> String {
98    if conflicted.is_empty() {
99        return String::from(
100            "No commit created: normalization removed every selected change; matching worktree and index copies were reconciled.",
101        );
102    }
103    // The message shows each name as text; a name that is not UTF-8 shows its replacement characters.
104    let names: Vec<String> = conflicted.iter().map(|path| { return path_message_text(path.as_slice()); }).collect();
105    let listed: String = serde_json::to_string(&names).unwrap_or_default();
106    return format!(
107        "No commit created: normalization removed every selected change; index copies were reconciled, but worktree copies changed and were kept: {listed}. Compare them with HEAD before retrying."
108    );
109}
110
111/// What:
112///  Complete an index and worktree correction without creating an empty commit.
113/// Why:
114///   Nothing is returned when Git still has a commit to make.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// await completeNoChangeTransaction({ ... });
119/// ```
120pub fn complete_no_change_transaction(input: NoChangeInput<'_>) -> Result<Option<TransactionResult>, TransactionStop> {
121    let Base::Commit(base) = input.capture.base.clone() else {
122        return Ok(None);
123    };
124    if !input.eligible {
125        return Ok(None);
126    }
127    if base_tree(&input, base.as_str())? != input.intended_tree {
128        return Ok(None);
129    }
130    let summary: Vec<PolicyEvent> = events_with_summary(input.events.to_vec(), input.changed_passes, input.changed_paths);
131    let payload: LandingPayload = LandingPayload::NormalizeOnly {
132        landed_tree_oid: String::from(input.intended_tree),
133    };
134    let request: LandingRequest<'_> = LandingRequest {
135        capture: input.capture,
136        mode: input.mode,
137        payload: &payload,
138        committed_paths: input.committed_paths,
139        added_paths: &[],
140        selected_worktree_paths: input.selected_worktree_paths,
141        index_lock_timeout_ms: input.index_lock_timeout_ms,
142        attempt: 1,
143    };
144    let outcome: LandingOutcome = {
145        let lock: OwnerLock = acquire_landing_lock(
146            input.invocation.context,
147            input.invocation.cwd,
148            &input.capture.layout,
149            input.invocation.environment,
150        )
151        .map_err(|error| { return failed(error.0); })?;
152        land_transaction(
153            input.invocation.context,
154            input.invocation.cwd,
155            &lock,
156            &mut input.workspace.landing,
157            &request,
158        )?
159    };
160    if let Some(event) = landing_finding(&outcome, input.capture, None, false) {
161        let mut events: Vec<PolicyEvent> = summary;
162        events.push(event);
163        return Ok(Some(TransactionResult {
164            events,
165            exit_code: LANDING_FINDING_EXIT_CODE,
166            landed_oid: None,
167        }));
168    }
169    let completion: InstallResult = install_added_worktree_files(
170        input.invocation.context,
171        input.invocation.cwd,
172        input.capture.layout.repository_root.as_path(),
173        input.selected_worktree_paths,
174        Some(input.workspace.object_directory.as_path()),
175    )
176    .map_err(|error| { return TransactionStop::Message(error.0); })?;
177    input.workspace.finish_transaction();
178    let mut events: Vec<PolicyEvent> = summary;
179    events.push(PolicyEvent::CoreFinding {
180        core_id: NO_CHANGE_CORE_ID,
181        code: NO_CHANGE_CODE,
182        message: no_change_message(completion.conflicted.as_slice()),
183    });
184    return Ok(Some(TransactionResult {
185        events,
186        exit_code: 1,
187        landed_oid: None,
188    }));
189}
190
191/// Controls for this module stay out of the release executable.
192#[cfg(test)]
193#[path = "commit_no_change_tests.rs"]
194mod tests;