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