Skip to main content

git_policy_cli/
commit_revalidation.rs

1//! What:
2//!  Revalidation of a replayed tree, outside both landing locks:
3//!  a private index for the replayed tree, the shadow `HEAD` at the replay parent, the policies converged again over
4//!  that index, and `pre-commit` re-run whenever the tree differs from the one it last approved.
5//! Why:
6//!  A replay merges changes the policies and the hook never saw together, so the merged tree must pass the same gates
7//!  as the prepared one (`src/policy-engine/commit-revalidation.ts`).
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const revalidation = await revalidateReplay({ context, replay, mergedTree, onto, approvedTree });
12//! ```
13
14use super::candidate_object::{ObjectId, parse_object_id};
15use super::capture_invocation::InvocationCapture;
16use super::commit_convergence::{CommitConvergence, CommitConvergenceInput, converge_commit_policies};
17use super::commit_facts::PrivateIndexFacts;
18use super::commit_index::{list_changed_index_paths, write_private_tree};
19use super::commit_preparation::{NATIVE_UNSET_VARIABLES, install_guard_when_free};
20use super::commit_transaction::{CommitInvocation, TransactionStop, failed, settled_passes, staged_at_base};
21use super::commit_workspace::CommitWorkspace;
22use super::commit_worktree_records::{HookChanges, describe_hook_changes};
23use super::diagnostic_log::debug;
24use super::forwarding::{exit_code, git_command};
25use super::signal_relay::SignalGuard;
26use super::hook_dispatch_plan::SILENCED_HOOK_EVENTS;
27use super::policy_checks::ShippedChecks;
28use super::policy_content::{ContentState, LifecycleContent};
29use super::policy_engine::{StageRequest, pass_exit_code};
30use super::policy_events::PolicyEvent;
31use super::policy_pass::{PassResult, run_policy_pass};
32use super::private_storage::create_private_directory;
33use super::replay_revalidation::{move_shadow_head, pre_commit_rerun_required, read_replayed_index, replayed_index_path};
34use super::repository_facts::RepositoryFacts;
35use super::transaction_git::GitContext;
36use super::transaction_journal::SymbolicHead;
37use std::ffi::OsString;
38use std::path::{Path, PathBuf};
39
40/// Module tag for diagnostics.
41const TAG: &str = "revalidateReplay";
42
43/// The exit status of a commit whose `pre-commit` hook rejected it, as native `git commit` exits.
44const HOOK_REJECTION_EXIT: i32 = 1;
45
46/// What:
47///  The result of revalidating a replayed tree.
48/// Why:
49///   A blocked tree stops the commit with the policies' events; a revalidated one is rebuilt and landed.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type Revalidation = { kind: 'revalidated'; ... } | { kind: 'blocked'; result };
54/// ```
55pub enum Revalidation {
56    /// The replayed commit may land.
57    Revalidated {
58        /// The tree to commit, with policy and hook changes.
59        tree: String,
60        /// The private index holding that tree.
61        index: PathBuf,
62        /// The settled pass's events, with the fix summary the corrections earned.
63        events: Vec<PolicyEvent>,
64        /// The paths the replayed commit changes against its new parent.
65        committed_paths: Vec<String>,
66        /// What the re-run `pre-commit` staged.
67        hook_changes: HookChanges,
68    },
69    /// A policy blocked the replayed commit.
70    Blocked {
71        /// The blocking pass's events.
72        events: Vec<PolicyEvent>,
73        /// The exit status of the block.
74        exit_code: i32,
75    },
76}
77
78/// What:
79///  What revalidation reads besides the policies.
80/// Why:
81///   The hook re-run needs the kept global options and the shim; the policies need the capture.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// type RevalidationContext = { gitPath; cwd; capture; workspace; args; policyOptions; readOnlySelection; repositoryRoot; globalArgs; noVerify };
86/// ```
87pub struct RevalidationContext<'a> {
88    /// The invocation's fixed facts.
89    pub invocation: &'a CommitInvocation<'a>,
90    /// The invocation capture.
91    pub capture: &'a InvocationCapture,
92    /// Whether interactive or include selection forbids automatic patches.
93    pub read_only_selection: bool,
94    /// The caller's kept global options.
95    pub global_args: &'a [String],
96    /// Whether the invocation bypassed `pre-commit` with `--no-verify`.
97    pub no_verify: bool,
98}
99
100/// What:
101///  Re-run `pre-commit` against a replayed private index through the dispatcher shim.
102/// Why:
103///   It runs exactly as native preparation ran it, so the shim takes the hook lock and restores the caller's
104///  configuration; `prepare-commit-msg` and `commit-msg` do not re-run because the message is fixed.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// await rerunPreCommit({ gitPath, worktreeRoot, globalArgs, shadowPath, hooksDirectory, indexPath });
109/// ```
110fn rerun_pre_commit(
111    context: &GitContext,
112    worktree_root: &Path,
113    global_args: &[String],
114    shadow: &Path,
115    hooks_directory: &Path,
116    index: &Path,
117) -> Result<(), TransactionStop> {
118    let mut arguments: Vec<OsString> = global_args.iter().map(OsString::from).collect();
119    arguments.push(OsString::from(format!("--git-dir={}", shadow.display())));
120    arguments.push(OsString::from(format!("--work-tree={}", worktree_root.display())));
121    arguments.push(OsString::from("-c"));
122    arguments.push(OsString::from(format!("core.hooksPath={}", hooks_directory.display())));
123    for event in SILENCED_HOOK_EVENTS {
124        arguments.push(OsString::from("-c"));
125        arguments.push(OsString::from(format!("hook.{event}.enabled=false")));
126    }
127    for word in ["hook", "run", "--ignore-missing", "pre-commit"] {
128        arguments.push(OsString::from(word));
129    }
130    let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
131    command
132        .current_dir(worktree_root)
133        .env("GIT_INDEX_FILE", index)
134        // Native Git gives pre-commit `GIT_EDITOR=:` when no editor runs, and a replay never opens one.
135        .env("GIT_EDITOR", ":");
136    for name in NATIVE_UNSET_VARIABLES {
137        command.env_remove(name);
138    }
139    let guard: SignalGuard = install_guard_when_free()
140        .map_err(|error| { return failed(format!("installing the signal guard failed: {error}")); })?;
141    let outcome = guard
142        .run_child(&mut command)
143        .map_err(|error| { return TransactionStop::Message(format!("git hook run could not start: {error}")); })?;
144    debug(TAG, format!("pre-commit re-run ended {outcome:?}").as_str());
145    if exit_code(outcome) != 0 {
146        return Err(TransactionStop::NativeExit(HOOK_REJECTION_EXIT));
147    }
148    return Ok(());
149}
150
151/// What:
152///  Revalidate a replayed tree.
153/// Why:
154///   The policies converge over the replayed index with the replay parent as their baseline, and `pre-commit` sees
155///   the tree when the policies changed what it last approved.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// await revalidateReplay({ context, replay, mergedTree, onto, approvedTree });
160/// ```
161pub fn revalidate_replay<F: RepositoryFacts>(
162    context: &RevalidationContext<'_>,
163    workspace: &CommitWorkspace,
164    request: &StageRequest,
165    checks: &mut ShippedChecks<PrivateIndexFacts<F>>,
166    replay: u64,
167    merged_tree: &str,
168    onto: &str,
169    approved_tree: &str,
170) -> Result<Revalidation, TransactionStop> {
171    let git: &GitContext = context.invocation.context;
172    let cwd: &Path = context.invocation.cwd;
173    let root: &Path = context.capture.layout.repository_root.as_path();
174    let index: PathBuf = replayed_index_path(workspace.landing.directory.as_path(), replay);
175    let directory: PathBuf = workspace.landing.directory.join(format!("replay-{replay}"));
176    ensure_replay_directory(directory.as_path())
177        .map_err(|error| { return failed(format!("creating {} failed: {error}", directory.display())); })?;
178    read_replayed_index(git, cwd, workspace.object_directory.as_path(), index.as_path(), merged_tree)?;
179    move_shadow_head(
180        git,
181        workspace.landing.shadow.as_path(),
182        cwd,
183        context.capture.layout.target_ref.as_str(),
184        onto,
185        is_detached(&context.capture.layout.symbolic_head),
186    )?;
187    let candidate_paths: Vec<String> =
188        list_changed_index_paths(git, cwd, index.as_path(), onto, workspace.object_directory.as_path())?;
189    let base: Option<ObjectId> = parse_object_id(onto.as_bytes());
190    checks.facts.use_index(index.as_path(), workspace.object_directory.as_path());
191    checks.content = ContentState::new();
192    checks.candidates = LifecycleContent::Requested(staged_at_base(base, candidate_paths.as_slice()));
193    let (pass, changed_passes, changed_paths): (PassResult, u64, Vec<String>) = if context.read_only_selection {
194        checks.can_apply_patches = false;
195        (run_policy_pass(request, checks), 0, Vec::new())
196    } else {
197        let outcome: CommitConvergence = converge_commit_policies(CommitConvergenceInput {
198            request,
199            checks,
200            stage: super::private_index_stage::PrivateStage {
201                context: git,
202                cwd,
203                index: index.as_path(),
204                object_directory: Some(workspace.object_directory.as_path()),
205            },
206        });
207        let passes: u64 = settled_passes(&outcome.convergence);
208        (outcome.pass, passes, outcome.changed_paths)
209    };
210    let code: i32 = pass_exit_code(pass.events.as_slice(), pass.end);
211    if code != 0 {
212        return Ok(Revalidation::Blocked {
213            events: pass.events,
214            exit_code: code,
215        });
216    }
217    let policy_tree: String =
218        write_private_tree(git, cwd, index.as_path(), workspace.object_directory.as_path())?;
219    let rerun: bool = pre_commit_rerun_required(context.no_verify, policy_tree.as_str(), approved_tree);
220    if rerun {
221        rerun_pre_commit(
222            git,
223            root,
224            context.global_args,
225            workspace.landing.shadow.as_path(),
226            workspace.hooks_directory.as_path(),
227            index.as_path(),
228        )?;
229    }
230    let tree: String = if rerun {
231        write_private_tree(git, cwd, index.as_path(), workspace.object_directory.as_path())?
232    } else {
233        policy_tree.clone()
234    };
235    let hook_changes: HookChanges = if rerun {
236        describe_hook_changes(git, cwd, workspace.object_directory.as_path(), policy_tree.as_str(), tree.as_str())?
237    } else {
238        HookChanges::default()
239    };
240    debug(
241        TAG,
242        format!("revalidated replay {replay} onto {onto}: tree {tree}{}", if rerun { " after pre-commit" } else { "" }).as_str(),
243    );
244    return Ok(Revalidation::Revalidated {
245        tree,
246        index,
247        events: super::commit_conclusion::events_with_changed_summary(pass.events, changed_passes, changed_paths.as_slice()),
248        committed_paths: candidate_paths,
249        hook_changes,
250    });
251}
252
253/// What:
254///  Make the directory of one replay, accepting one that subsumption already made for its base index.
255/// Why:
256///   Every other failure leaves the replay without a place for its index.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// await mkdir(directory, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
261/// ```
262pub fn ensure_replay_directory(directory: &Path) -> std::io::Result<()> {
263    match create_private_directory(directory) {
264        Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => return Err(error),
265        Ok(()) | Err(_) => return Ok(()),
266    }
267}
268
269/// What:
270///  Whether the invocation started on a detached `HEAD`.
271/// Why:
272///   A detached replay moves the shadow `HEAD` itself; a branch replay moves the branch.
273///
274/// In TS you'd write (pseudocode):
275/// ```ts
276/// symbolicHead.kind === 'detached'
277/// ```
278pub fn is_detached(head: &SymbolicHead) -> bool {
279    return *head == SymbolicHead::Detached;
280}
281
282/// Controls for this module stay out of the release executable.
283#[cfg(test)]
284#[path = "commit_revalidation_tests.rs"]
285mod tests;