git_policy_cli/policy_patch.rs
1//! What:
2//! One policy-proposed patch applied to the private commit index with `git apply --cached --3way`.
3//! Why:
4//! Corrections land in the private index, never the real one.
5//! A patch that no longer applies is a `patch-conflict` for the transaction, not a silent skip.
6//! The incumbent applies the same command in `applyPrivatePatch`
7//! (`src/policy-engine/commit-transaction-git.ts`).
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await applyPrivatePatch({ workspace, gitPath, cwd, patch, candidateRevision, ordinal });
12//! ```
13
14use super::policy_patch_validate::{PatchCheck, validate_policy_patch};
15use super::private_storage::write_exclusive_synced;
16use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
17use std::path::{Path, PathBuf};
18
19/// What:
20/// A policy patch proposal for one ordinary text path.
21/// Why:
22/// The path and bytes are exactly what the policy proposed, and are validated before Git runs.
23///
24/// In TS you'd write (pseudocode):
25/// ```ts
26/// type PolicyPatch = { kind: 'git-unified'; targetId: string; path: string; bytes: Uint8Array };
27/// ```
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct PolicyPatch {
30 /// Exact candidate target ID the patch corrects.
31 pub target_id: String,
32 /// Repository path the patch names.
33 pub path: String,
34 /// Unified patch bytes.
35 pub bytes: Vec<u8>,
36}
37
38/// What:
39/// Why a policy patch was not applied.
40/// Why:
41/// The convergence loop maps `Rejected` to a grammar failure and `Conflict` to `patch-conflict`.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// class PatchApplicationError extends Error {}
46/// ```
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub enum PatchError {
49 /// The patch broke the supported grammar; Git never saw it.
50 Rejected(String),
51 /// Git refused the patch against the private index.
52 Conflict(String),
53 /// The patch file or the Git process failed.
54 Failed(String),
55}
56
57/// What:
58/// The text of a patch failure.
59/// Why:
60/// Diagnostics print the reason unchanged.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// error.message;
65/// ```
66impl std::fmt::Display for PatchError {
67 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 let message: &String = match self {
69 Self::Rejected(message) | Self::Conflict(message) | Self::Failed(message) => message,
70 };
71 return write!(formatter, "{message}");
72 }
73}
74
75/// What:
76/// Inputs of one private patch application.
77/// Why:
78/// One object keeps the Git context, the private index and the patch separate at the call site.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type PrivatePatchInput = { context; cwd; index; objectDirectory; directory; patch; expectedRevision; ordinal };
83/// ```
84pub struct PrivatePatchInput<'a> {
85 /// Real Git executable and the invocation's overlay.
86 pub context: &'a GitContext,
87 /// Effective repository directory the patch is applied from.
88 pub cwd: &'a Path,
89 /// Private commit index receiving the correction.
90 pub index: &'a Path,
91 /// Object store receiving written blobs; the real store when absent.
92 pub object_directory: Option<&'a Path>,
93 /// Private transaction directory holding the patch file.
94 pub directory: &'a Path,
95 /// Patch to apply.
96 pub patch: &'a PolicyPatch,
97 /// Exact candidate blob revision the patch's index line names as its base.
98 pub expected_revision: &'a str,
99 /// Position of this patch in the pass, which names its patch file.
100 pub ordinal: u32,
101}
102
103/// What:
104/// Validate a policy patch, write it privately, and apply it to the private index.
105/// Why:
106/// The patch file is created exclusively with private permissions, so a re-run never overwrites an earlier one.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// validatePolicyPatch({ patch, expectedRevision });
111/// await runTransactionGit({ args: ['apply', '--cached', '--3way', patchPath], indexPath });
112/// ```
113pub fn apply_private_patch(input: PrivatePatchInput<'_>) -> Result<(), PatchError> {
114 validate_policy_patch(PatchCheck {
115 patch: input.patch,
116 expected_revision: input.expected_revision,
117 })?;
118 let patch_path: PathBuf = input.directory.join(format!("patch-{}.diff", input.ordinal));
119 if let Err(error) = write_exclusive_synced(patch_path.as_path(), input.patch.bytes.as_slice()) {
120 return Err(PatchError::Failed(format!("writing {}: {error}", patch_path.display())));
121 }
122 let mut request: GitRequest = GitRequest::new(input.cwd, &["apply", "--cached", "--3way"]);
123 request.arguments.push(patch_path.as_os_str().to_owned());
124 request.without_prefix = true;
125 request.index = Some(input.index.to_path_buf());
126 request.object_directory = input.object_directory.map(Path::to_path_buf);
127 let output: GitOutput = match run_git(input.context, &request) {
128 Ok(found) => found,
129 Err(error) => return Err(PatchError::Failed(format!("starting git apply: {error}"))),
130 };
131 if output.succeeded() {
132 return Ok(());
133 }
134 return Err(PatchError::Conflict(output.error_text()));
135}
136
137#[cfg(test)]
138#[path = "policy_patch_tests.rs"]
139mod tests;