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<Vec<u8>>,
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    /// Whether the dispatch plan wrote hook entries; without them no hook can run, so the re-run starts nothing.
99    pub named_hooks_possible: bool,
100}
101
102/// What:
103///  Re-run `pre-commit` against a replayed private index through the dispatcher shim.
104/// Why:
105///   It runs exactly as native preparation ran it, so the shim takes the hook lock and restores the caller's
106///  configuration; `prepare-commit-msg` and `commit-msg` do not re-run because the message is fixed.
107///  Without hook entries nothing runs, and nothing is started.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// await rerunPreCommit({ gitPath, worktreeRoot, globalArgs, shadowPath, hooksDirectory, indexPath });
112/// ```
113fn rerun_pre_commit(
114    context: &GitContext,
115    worktree_root: &Path,
116    global_args: &[String],
117    shadow: &Path,
118    hooks_directory: &Path,
119    named_hooks_possible: bool,
120    index: &Path,
121) -> Result<(), TransactionStop> {
122    // A configuration with no hook defined by name has no dispatcher entry, so `git hook run` would find nothing.
123    if !named_hooks_possible {
124        debug(TAG, "the configuration defines no hook by name; the re-run is skipped");
125        return Ok(());
126    }
127    let mut arguments: Vec<OsString> = global_args.iter().map(OsString::from).collect();
128    arguments.push(OsString::from(format!("--git-dir={}", shadow.display())));
129    arguments.push(OsString::from(format!("--work-tree={}", worktree_root.display())));
130    arguments.push(OsString::from("-c"));
131    arguments.push(OsString::from(format!("core.hooksPath={}", hooks_directory.display())));
132    for event in SILENCED_HOOK_EVENTS {
133        arguments.push(OsString::from("-c"));
134        arguments.push(OsString::from(format!("hook.{event}.enabled=false")));
135    }
136    for word in ["hook", "run", "--ignore-missing", "pre-commit"] {
137        arguments.push(OsString::from(word));
138    }
139    let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
140    command
141        .current_dir(worktree_root)
142        .env("GIT_INDEX_FILE", index)
143        // Native Git gives pre-commit `GIT_EDITOR=:` when no editor runs, and a replay never opens one.
144        .env("GIT_EDITOR", ":");
145    for name in NATIVE_UNSET_VARIABLES {
146        command.env_remove(name);
147    }
148    let guard: SignalGuard = install_guard_when_free()
149        .map_err(|error| { return failed(format!("installing the signal guard failed: {error}")); })?;
150    let outcome = guard
151        .run_child(&mut command)
152        .map_err(|error| { return TransactionStop::Message(format!("git hook run could not start: {error}")); })?;
153    debug(TAG, format!("pre-commit re-run ended {outcome:?}").as_str());
154    if exit_code(outcome) != 0 {
155        return Err(TransactionStop::NativeExit(HOOK_REJECTION_EXIT));
156    }
157    return Ok(());
158}
159
160/// What:
161///  Revalidate a replayed tree.
162/// Why:
163///   The policies converge over the replayed index with the replay parent as their baseline, and `pre-commit` sees
164///   the tree when the policies changed what it last approved.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// await revalidateReplay({ context, replay, mergedTree, onto, approvedTree });
169/// ```
170pub fn revalidate_replay<F: RepositoryFacts>(
171    context: &RevalidationContext<'_>,
172    workspace: &CommitWorkspace,
173    request: &StageRequest,
174    checks: &mut ShippedChecks<PrivateIndexFacts<F>>,
175    replay: u64,
176    merged_tree: &str,
177    onto: &str,
178    approved_tree: &str,
179) -> Result<Revalidation, TransactionStop> {
180    let git: &GitContext = context.invocation.context;
181    let cwd: &Path = context.invocation.cwd;
182    let root: &Path = context.capture.layout.repository_root.as_path();
183    let index: PathBuf = replayed_index_path(workspace.landing.directory.as_path(), replay);
184    let directory: PathBuf = workspace.landing.directory.join(format!("replay-{replay}"));
185    ensure_replay_directory(directory.as_path())
186        .map_err(|error| { return failed(format!("creating {} failed: {error}", directory.display())); })?;
187    read_replayed_index(git, cwd, workspace.object_directory.as_path(), index.as_path(), merged_tree)?;
188    move_shadow_head(
189        git,
190        workspace.landing.shadow.as_path(),
191        cwd,
192        context.capture.layout.target_ref.as_str(),
193        onto,
194        is_detached(&context.capture.layout.symbolic_head),
195    )?;
196    let candidate_paths: Vec<Vec<u8>> =
197        list_changed_index_paths(git, cwd, index.as_path(), onto, workspace.object_directory.as_path())?;
198    let base: Option<ObjectId> = parse_object_id(onto.as_bytes());
199    checks.facts.use_index(index.as_path(), workspace.object_directory.as_path());
200    checks.content = ContentState::new();
201    checks.candidates = LifecycleContent::Requested(staged_at_base(base, candidate_paths.as_slice()));
202    let (pass, changed_passes, changed_paths): (PassResult, u64, Vec<Vec<u8>>) = if context.read_only_selection {
203        checks.can_apply_patches = false;
204        (run_policy_pass(request, checks), 0, Vec::new())
205    } else {
206        let outcome: CommitConvergence = converge_commit_policies(CommitConvergenceInput {
207            request,
208            checks,
209            stage: super::private_index_stage::PrivateStage {
210                context: git,
211                cwd,
212                index: index.as_path(),
213                object_directory: Some(workspace.object_directory.as_path()),
214            },
215        });
216        let passes: u64 = settled_passes(&outcome.convergence);
217        (outcome.pass, passes, outcome.changed_paths)
218    };
219    let code: i32 = pass_exit_code(pass.events.as_slice(), pass.end);
220    if code != 0 {
221        return Ok(Revalidation::Blocked {
222            events: pass.events,
223            exit_code: code,
224        });
225    }
226    let policy_tree: String =
227        write_private_tree(git, cwd, index.as_path(), workspace.object_directory.as_path())?;
228    let rerun: bool = pre_commit_rerun_required(context.no_verify, policy_tree.as_str(), approved_tree);
229    if rerun {
230        rerun_pre_commit(
231            git,
232            root,
233            context.global_args,
234            workspace.landing.shadow.as_path(),
235            workspace.hooks_directory.as_path(),
236            context.named_hooks_possible,
237            index.as_path(),
238        )?;
239    }
240    let tree: String = if rerun {
241        write_private_tree(git, cwd, index.as_path(), workspace.object_directory.as_path())?
242    } else {
243        policy_tree.clone()
244    };
245    let hook_changes: HookChanges = if rerun {
246        describe_hook_changes(git, cwd, workspace.object_directory.as_path(), policy_tree.as_str(), tree.as_str())?
247    } else {
248        HookChanges::default()
249    };
250    debug(
251        TAG,
252        format!("revalidated replay {replay} onto {onto}: tree {tree}{}", if rerun { " after pre-commit" } else { "" }).as_str(),
253    );
254    return Ok(Revalidation::Revalidated {
255        tree,
256        index,
257        events: super::commit_conclusion::events_with_changed_summary(pass.events, changed_passes, changed_paths.as_slice()),
258        committed_paths: candidate_paths,
259        hook_changes,
260    });
261}
262
263/// What:
264///  Make the directory of one replay, accepting one that subsumption already made for its base index.
265/// Why:
266///   Every other failure leaves the replay without a place for its index.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// await mkdir(directory, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
271/// ```
272pub fn ensure_replay_directory(directory: &Path) -> std::io::Result<()> {
273    match create_private_directory(directory) {
274        Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => return Err(error),
275        Ok(()) | Err(_) => return Ok(()),
276    }
277}
278
279/// What:
280///  Whether the invocation started on a detached `HEAD`.
281/// Why:
282///   A detached replay moves the shadow `HEAD` itself; a branch replay moves the branch.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// symbolicHead.kind === 'detached'
287/// ```
288pub fn is_detached(head: &SymbolicHead) -> bool {
289    return *head == SymbolicHead::Detached;
290}
291
292/// Controls for this module stay out of the release executable.
293#[cfg(test)]
294#[path = "commit_revalidation_tests.rs"]
295mod tests;