mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
feat: add run.checkpoint.skip_git_hooks to bypass Git commit hooks (#355)
## Summary
Adds an opt-in `skip_git_hooks` boolean to `[run.checkpoint]` that
causes Fabro-managed run-branch checkpoint commits to pass `--no-verify`
to `git commit`, bypassing local hooks such as `pre-commit` and
`commit-msg`. Defaults to `false`. Metadata-branch snapshots and Fabro
`[[run.hooks]]` are unaffected.
```toml
[run.checkpoint]
skip_git_hooks = true
```
### Plan Summary
- `RunCheckpointSettings` (dense, in `fabro-types`) gains
`skip_git_hooks: bool` with `#[serde(default)]`.
- `RunCheckpointLayer` (sparse, in `fabro-config`) gains
`skip_git_hooks: Option<bool>` so layered config can distinguish unset
from explicit `false`.
- `RunCheckpointLayer::combine` is refactored from a wholesale-replace
to field-level merging: `exclude_globs` keeps its existing replace-wins
semantics; `skip_git_hooks` uses `.or()` (highest-priority layer that
sets it wins).
- `resolve_checkpoint` resolves `None → false`.
- `git_checkpoint` / `checked_git_checkpoint` in `sandbox_git.rs` accept
a new `skip_git_hooks: bool` and append `--no-verify` when true.
- `parallel_branch_commit_cmd` (new helper in `handler/parallel.rs`)
replaces the inline format string and accepts the same flag.
- `GitState` carries `checkpoint_skip_git_hooks`;
`RunOptions::checkpoint_skip_git_hooks()` exposes it; `execute.rs` and
`git.rs` thread it through.
- OpenAPI schema, TypeScript API client, and docs are updated.
### Key design decisions
**Field-level merging in `combine`**: the previous
`RunCheckpointLayer::combine` replaced the whole struct when
`self.exclude_globs` was non-empty. The refactor keeps that same replace
rule for `exclude_globs` while adding independent `Option::or` merging
for `skip_git_hooks`, so the two fields don't interfere.
**`--no-verify` only on run-branch commits**: the flag is injected only
in the two Git commit paths Fabro controls for run-branch checkpoints.
Metadata-branch snapshots use `git2` and never fire local hooks
regardless of this setting.
### Fabro Details
<details>
<summary>Ran 9 stages in 48m 3s for $13.91</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 3s | – | 0 |
| preflight_compile | 2m 4s | – | 0 |
| preflight_lint | 2m 15s | – | 0 |
| implement | 24m 13s | $10.38 | 0 |
| simplify_opus | 10m 31s | $1.59 | 0 |
| simplify_gpt | 5m 8s | $1.93 | 0 |
| verify | 3m 4s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **48m 3s** | **$13.91** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
203ed5d029
commit
94c657b92e
18 changed files with 350 additions and 29 deletions
|
|
@ -61,7 +61,7 @@ function sampleSettings({
|
|||
git: { author: null },
|
||||
prepare: { commands: prepareCommands, timeout_ms: 120_000 },
|
||||
execution: { mode: "normal", approval: "prompt" },
|
||||
checkpoint: { exclude_globs: [] },
|
||||
checkpoint: { exclude_globs: [], skip_git_hooks: false },
|
||||
clone: { enabled: true },
|
||||
run_branch: { enabled: true, push: true },
|
||||
meta_branch: { enabled: true, push: true },
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ team = "platform"
|
|||
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
||||
skip_git_hooks = false
|
||||
|
||||
[run.inputs]
|
||||
default_branch = "main"
|
||||
|
|
@ -293,8 +294,9 @@ Configure checkpoint behavior for all runs.
|
|||
| Key | Description |
|
||||
|---|---|
|
||||
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) |
|
||||
| `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. |
|
||||
|
||||
`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration.
|
||||
`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. `skip_git_hooks` uses normal override semantics. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration.
|
||||
|
||||
## Secrets and environment variables
|
||||
|
||||
|
|
|
|||
|
|
@ -10631,12 +10631,19 @@ components:
|
|||
|
||||
RunCheckpointSettings:
|
||||
type: object
|
||||
required: [exclude_globs]
|
||||
required: [exclude_globs, skip_git_hooks]
|
||||
properties:
|
||||
exclude_globs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_git_hooks:
|
||||
type: boolean
|
||||
default: false
|
||||
description: |
|
||||
When true, Fabro-managed run-branch checkpoint commits bypass
|
||||
local Git commit hooks. Does not affect Fabro `[[run.hooks]]`
|
||||
or metadata-branch snapshots. Defaults to false.
|
||||
|
||||
RunCloneSettings:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -345,13 +345,15 @@ Configure how git checkpoint commits behave.
|
|||
```toml title="run.toml"
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"]
|
||||
skip_git_hooks = false
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. |
|
||||
| `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks (e.g. `pre-commit`, `commit-msg`). Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. |
|
||||
|
||||
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale.
|
||||
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` uses normal override semantics: the highest layer that sets it wins.
|
||||
|
||||
### `[run.inputs]`
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,39 @@ approval = "auto"
|
|||
assert_eq!(round_trip, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_settings_json_includes_run_checkpoint_skip_git_hooks() {
|
||||
let settings = WorkflowSettingsBuilder::from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
skip_git_hooks = true
|
||||
"#,
|
||||
)
|
||||
.expect("settings with run.checkpoint.skip_git_hooks should resolve");
|
||||
|
||||
let json = serde_json::to_value(&settings).expect("workflow settings should serialize");
|
||||
assert_eq!(json["run"]["checkpoint"]["skip_git_hooks"], true);
|
||||
assert_eq!(
|
||||
json["run"]["checkpoint"]["exclude_globs"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
|
||||
let round_trip: ApiWorkflowSettings =
|
||||
serde_json::from_value(json).expect("workflow settings should deserialize");
|
||||
assert_eq!(round_trip, settings);
|
||||
assert!(round_trip.run.checkpoint.skip_git_hooks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_settings_default_run_checkpoint_skip_git_hooks_is_false() {
|
||||
let settings = WorkflowSettingsBuilder::from_toml("_version = 1\n")
|
||||
.expect("default settings should resolve");
|
||||
let json = serde_json::to_value(&settings).expect("workflow settings should serialize");
|
||||
assert_eq!(json["run"]["checkpoint"]["skip_git_hooks"], false);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
|
|
|
|||
|
|
@ -925,7 +925,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"include": []
|
||||
},
|
||||
"checkpoint": {
|
||||
"exclude_globs": []
|
||||
"exclude_globs": [],
|
||||
"skip_git_hooks": false
|
||||
},
|
||||
"clone": {
|
||||
"enabled": true
|
||||
|
|
|
|||
|
|
@ -147,7 +147,8 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
"approval": "prompt"
|
||||
},
|
||||
"checkpoint": {
|
||||
"exclude_globs": []
|
||||
"exclude_globs": [],
|
||||
"skip_git_hooks": false
|
||||
},
|
||||
"clone": {
|
||||
"enabled": true
|
||||
|
|
|
|||
|
|
@ -164,10 +164,15 @@ impl_combine_self!(
|
|||
|
||||
impl Combine for RunCheckpointLayer {
|
||||
fn combine(self, other: Self) -> Self {
|
||||
if self.exclude_globs.is_empty() {
|
||||
other
|
||||
let exclude_globs = if self.exclude_globs.is_empty() {
|
||||
other.exclude_globs
|
||||
} else {
|
||||
self
|
||||
self.exclude_globs
|
||||
};
|
||||
let skip_git_hooks = self.skip_git_hooks.or(other.skip_git_hooks);
|
||||
Self {
|
||||
exclude_globs,
|
||||
skip_git_hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,7 +281,9 @@ pub struct RunExecutionLayer {
|
|||
#[serde(deny_unknown_fields)]
|
||||
pub struct RunCheckpointLayer {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exclude_globs: Vec<String>,
|
||||
pub exclude_globs: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skip_git_hooks: Option<bool>,
|
||||
}
|
||||
|
||||
/// `[run.clone]` — source workspace clone policy.
|
||||
|
|
|
|||
|
|
@ -184,9 +184,12 @@ fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSetti
|
|||
|
||||
fn resolve_checkpoint(checkpoint: Option<&RunCheckpointLayer>) -> RunCheckpointSettings {
|
||||
RunCheckpointSettings {
|
||||
exclude_globs: checkpoint
|
||||
exclude_globs: checkpoint
|
||||
.map(|checkpoint| checkpoint.exclude_globs.clone())
|
||||
.unwrap_or_default(),
|
||||
skip_git_hooks: checkpoint
|
||||
.and_then(|checkpoint| checkpoint.skip_git_hooks)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -609,3 +609,99 @@ fabro_tools = true
|
|||
assert!(!settings.agent.fabro_tools);
|
||||
}
|
||||
}
|
||||
|
||||
mod run_checkpoint_skip_git_hooks {
|
||||
//! Layer + resolver tests for `[run.checkpoint] skip_git_hooks`.
|
||||
|
||||
use crate::layers::Combine;
|
||||
use crate::{SettingsLayer, WorkflowSettingsBuilder};
|
||||
|
||||
fn parse_settings(source: &str) -> SettingsLayer {
|
||||
source
|
||||
.parse::<SettingsLayer>()
|
||||
.expect("fixture should parse via SettingsLayer")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_skip_git_hooks_true_when_set() {
|
||||
let settings = WorkflowSettingsBuilder::from_toml(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
skip_git_hooks = true
|
||||
",
|
||||
)
|
||||
.expect("settings should resolve")
|
||||
.run;
|
||||
|
||||
assert!(settings.checkpoint.skip_git_hooks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_skip_git_hooks_false_when_omitted() {
|
||||
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
|
||||
.expect("empty settings should resolve")
|
||||
.run;
|
||||
|
||||
assert!(!settings.checkpoint.skip_git_hooks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_layer_false_overrides_lower_layer_true() {
|
||||
let workflow = parse_settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
skip_git_hooks = false
|
||||
",
|
||||
);
|
||||
let user = parse_settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
skip_git_hooks = true
|
||||
",
|
||||
);
|
||||
let merged = workflow.combine(user);
|
||||
|
||||
let settings = WorkflowSettingsBuilder::from_layer(&merged)
|
||||
.expect("merged settings should resolve")
|
||||
.run;
|
||||
|
||||
assert!(!settings.checkpoint.skip_git_hooks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_globs_replace_behavior_preserved_when_skip_git_hooks_added() {
|
||||
// Higher layer provides skip_git_hooks but no exclude_globs;
|
||||
// exclude_globs should still inherit from the lower layer because the
|
||||
// higher layer's list is empty.
|
||||
let workflow = parse_settings(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
skip_git_hooks = true
|
||||
",
|
||||
);
|
||||
let user = parse_settings(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.checkpoint]
|
||||
exclude_globs = ["**/lower/**"]
|
||||
"#,
|
||||
);
|
||||
let merged = workflow.combine(user);
|
||||
|
||||
let settings = WorkflowSettingsBuilder::from_layer(&merged)
|
||||
.expect("merged settings should resolve")
|
||||
.run;
|
||||
|
||||
assert_eq!(settings.checkpoint.exclude_globs, vec!["**/lower/**"]);
|
||||
assert!(settings.checkpoint.skip_git_hooks);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,13 @@ impl Default for RunExecutionSettings {
|
|||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunCheckpointSettings {
|
||||
pub exclude_globs: Vec<String>,
|
||||
pub exclude_globs: Vec<String>,
|
||||
/// When `true`, Fabro-managed run-branch checkpoint commits bypass
|
||||
/// local Git commit hooks (e.g. `pre-commit`, `commit-msg`). This does
|
||||
/// not affect Fabro workflow `[[run.hooks]]` or metadata-branch
|
||||
/// snapshots, which already bypass repository hooks.
|
||||
#[serde(default)]
|
||||
pub skip_git_hooks: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ impl Handler for ParallelHandler {
|
|||
None,
|
||||
&gs.checkpoint_exclude_globs,
|
||||
&gs.git_author,
|
||||
gs.checkpoint_skip_git_hooks,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
|
|
@ -318,6 +319,9 @@ impl Handler for ParallelHandler {
|
|||
.as_ref()
|
||||
.map(|gs| gs.git_author.clone())
|
||||
.unwrap_or_default();
|
||||
let skip_git_hooks = git_state
|
||||
.as_ref()
|
||||
.is_some_and(|gs| gs.checkpoint_skip_git_hooks);
|
||||
let group_id = parallel_group_id.clone();
|
||||
let branch_scope = StageScope::for_parallel_branch(
|
||||
setup.target_id.clone(),
|
||||
|
|
@ -408,10 +412,12 @@ impl Handler for ParallelHandler {
|
|||
.is_ok_and(fabro_sandbox::ExecResult::is_success)
|
||||
{
|
||||
let msg = format!("fabro({rid}): {nid} ({status_str})");
|
||||
let commit_cmd = format!(
|
||||
"{git_r} -c 'user.name={name}' -c 'user.email={email}' commit --allow-empty -m '{msg}'",
|
||||
name = git_author.name,
|
||||
email = git_author.email,
|
||||
let commit_cmd = parallel_branch_commit_cmd(
|
||||
git_r,
|
||||
&git_author.name,
|
||||
&git_author.email,
|
||||
&msg,
|
||||
skip_git_hooks,
|
||||
);
|
||||
let _ = setup
|
||||
.sandbox
|
||||
|
|
@ -660,6 +666,23 @@ fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option<String> {
|
|||
common_sorted.first().map(|id| (*id).clone())
|
||||
}
|
||||
|
||||
/// Build the parallel-branch checkpoint commit command. Appends
|
||||
/// `--no-verify` when `skip_git_hooks` is true so the commit bypasses the
|
||||
/// repository's local Git commit hooks (e.g. `pre-commit`, `commit-msg`).
|
||||
fn parallel_branch_commit_cmd(
|
||||
git_remote: &str,
|
||||
author_name: &str,
|
||||
author_email: &str,
|
||||
message: &str,
|
||||
skip_git_hooks: bool,
|
||||
) -> String {
|
||||
let no_verify = if skip_git_hooks { " --no-verify" } else { "" };
|
||||
let name = fabro_sandbox::shell_quote(&format!("user.name={author_name}"));
|
||||
let email = fabro_sandbox::shell_quote(&format!("user.email={author_email}"));
|
||||
let msg = fabro_sandbox::shell_quote(message);
|
||||
format!("{git_remote} -c {name} -c {email} commit --allow-empty{no_verify} -m {msg}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
|
@ -921,4 +944,36 @@ mod tests {
|
|||
let branch_count = context.get(keys::PARALLEL_BRANCH_COUNT);
|
||||
assert_eq!(branch_count, Some(serde_json::json!(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_commit_cmd_includes_no_verify_when_skip_hooks_enabled() {
|
||||
let cmd = super::parallel_branch_commit_cmd(
|
||||
super::GIT_REMOTE,
|
||||
"Fabro",
|
||||
"fabro@example.com",
|
||||
"fabro(r1): branch_a (succeeded)",
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("--no-verify"),
|
||||
"expected --no-verify when skip_git_hooks=true; got {cmd:?}"
|
||||
);
|
||||
assert!(cmd.contains("commit --allow-empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_branch_commit_cmd_omits_no_verify_when_skip_hooks_disabled() {
|
||||
let cmd = super::parallel_branch_commit_cmd(
|
||||
super::GIT_REMOTE,
|
||||
"Fabro",
|
||||
"fabro@example.com",
|
||||
"fabro(r1): branch_a (succeeded)",
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!cmd.contains("--no-verify"),
|
||||
"expected no --no-verify when skip_git_hooks=false; got {cmd:?}"
|
||||
);
|
||||
assert!(cmd.contains("commit --allow-empty"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
shadow_sha,
|
||||
&self.run_options.checkpoint_exclude_globs(),
|
||||
&git_author,
|
||||
self.run_options.checkpoint_skip_git_hooks(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ pub async fn execute(init: Initialized) -> Executed {
|
|||
run_branch: git.run_branch.clone(),
|
||||
meta_branch: git.meta_branch.clone(),
|
||||
checkpoint_exclude_globs: run_options.checkpoint_exclude_globs(),
|
||||
checkpoint_skip_git_hooks: run_options.checkpoint_skip_git_hooks(),
|
||||
git_author: run_options.git_author(),
|
||||
}))
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ impl RunOptions {
|
|||
self.settings.run.checkpoint.exclude_globs.clone()
|
||||
}
|
||||
|
||||
pub fn checkpoint_skip_git_hooks(&self) -> bool {
|
||||
self.settings.run.checkpoint.skip_git_hooks
|
||||
}
|
||||
|
||||
pub fn git_author(&self) -> GitAuthor {
|
||||
git_author_from_settings(&self.settings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ pub struct GitCommandError {
|
|||
/// Captured git state for a workflow run, shared with handlers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitState {
|
||||
pub run_id: RunId,
|
||||
pub base_sha: String,
|
||||
pub run_branch: Option<String>,
|
||||
pub meta_branch: Option<String>,
|
||||
pub checkpoint_exclude_globs: Vec<String>,
|
||||
pub git_author: GitAuthor,
|
||||
pub run_id: RunId,
|
||||
pub base_sha: String,
|
||||
pub run_branch: Option<String>,
|
||||
pub meta_branch: Option<String>,
|
||||
pub checkpoint_exclude_globs: Vec<String>,
|
||||
pub checkpoint_skip_git_hooks: bool,
|
||||
pub git_author: GitAuthor,
|
||||
}
|
||||
|
||||
pub const GIT_REMOTE: &str =
|
||||
|
|
@ -68,6 +69,7 @@ pub async fn git_checkpoint(
|
|||
shadow_sha: Option<String>,
|
||||
exclude_globs: &[String],
|
||||
author: &GitAuthor,
|
||||
skip_git_hooks: bool,
|
||||
) -> std::result::Result<String, GitCommandError> {
|
||||
let mut all_excludes: Vec<String> = artifact_snapshot::EXCLUDE_DIRS
|
||||
.iter()
|
||||
|
|
@ -125,8 +127,9 @@ pub async fn git_checkpoint(
|
|||
}
|
||||
|
||||
let msg_path_q = shell_quote(&msg_path);
|
||||
let no_verify = if skip_git_hooks { " --no-verify" } else { "" };
|
||||
let commit_cmd = format!(
|
||||
"{GIT_REMOTE} -c user.name={name} -c user.email={email} commit --allow-empty -F {msg_path_q}",
|
||||
"{GIT_REMOTE} -c user.name={name} -c user.email={email} commit --allow-empty{no_verify} -F {msg_path_q}",
|
||||
name = shell_quote(&author.name),
|
||||
email = shell_quote(&author.email),
|
||||
);
|
||||
|
|
@ -174,6 +177,7 @@ pub(crate) async fn checked_git_checkpoint(
|
|||
shadow_sha: Option<String>,
|
||||
exclude_globs: &[String],
|
||||
author: &GitAuthor,
|
||||
skip_git_hooks: bool,
|
||||
) -> std::result::Result<String, SharedError> {
|
||||
runtime.ensure_git_available(sandbox).await.map_err(|err| {
|
||||
SharedError::new(anyhow::Error::new(err).context("sandbox git unavailable"))
|
||||
|
|
@ -187,6 +191,7 @@ pub(crate) async fn checked_git_checkpoint(
|
|||
shadow_sha,
|
||||
exclude_globs,
|
||||
author,
|
||||
skip_git_hooks,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| SharedError::new(anyhow::Error::new(err)))
|
||||
|
|
@ -1068,6 +1073,7 @@ mod tests {
|
|||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -1094,6 +1100,7 @@ mod tests {
|
|||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -1124,6 +1131,7 @@ mod tests {
|
|||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -1143,6 +1151,7 @@ mod tests {
|
|||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -1162,10 +1171,30 @@ mod tests {
|
|||
]);
|
||||
let author = crate::git::GitAuthor::default();
|
||||
|
||||
let first =
|
||||
git_checkpoint(&sandbox, "run1", "work", "success", 1, None, &[], &author).await;
|
||||
let second =
|
||||
git_checkpoint(&sandbox, "run1", "work", "success", 1, None, &[], &author).await;
|
||||
let first = git_checkpoint(
|
||||
&sandbox,
|
||||
"run1",
|
||||
"work",
|
||||
"success",
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
&author,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let second = git_checkpoint(
|
||||
&sandbox,
|
||||
"run1",
|
||||
"work",
|
||||
"success",
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
&author,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(first.is_ok(), "first checkpoint failed: {:?}", first.err());
|
||||
assert!(
|
||||
|
|
@ -1225,6 +1254,63 @@ mod tests {
|
|||
assert_eq!(tail.stderr.as_deref(), Some("fatal: bad revision\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_checkpoint_appends_no_verify_when_skip_hooks_enabled() {
|
||||
// add, commit, rev-parse
|
||||
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
|
||||
git_checkpoint(
|
||||
&sandbox,
|
||||
"run1",
|
||||
"work",
|
||||
"success",
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("checkpoint should succeed");
|
||||
|
||||
let commands = sandbox.commands();
|
||||
let commit_cmd = commands
|
||||
.iter()
|
||||
.find(|c| c.contains(" commit "))
|
||||
.expect("commit command should be issued");
|
||||
assert!(
|
||||
commit_cmd.contains("--no-verify"),
|
||||
"commit command should include --no-verify when skip_git_hooks=true; got {commit_cmd:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_checkpoint_omits_no_verify_when_skip_hooks_disabled() {
|
||||
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
|
||||
git_checkpoint(
|
||||
&sandbox,
|
||||
"run1",
|
||||
"work",
|
||||
"success",
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
&crate::git::GitAuthor::default(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("checkpoint should succeed");
|
||||
|
||||
let commands = sandbox.commands();
|
||||
let commit_cmd = commands
|
||||
.iter()
|
||||
.find(|c| c.contains(" commit "))
|
||||
.expect("commit command should be issued");
|
||||
assert!(
|
||||
!commit_cmd.contains("--no-verify"),
|
||||
"commit command should omit --no-verify when skip_git_hooks=false; got {commit_cmd:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_checkpoint_includes_builtin_excludes() {
|
||||
// Set up a real git repo
|
||||
|
|
@ -1262,8 +1348,18 @@ mod tests {
|
|||
|
||||
// Call git_checkpoint with empty user excludes — built-in excludes should still
|
||||
// apply
|
||||
let result =
|
||||
git_checkpoint(&sandbox, "run1", "work", "success", 1, None, &[], &author).await;
|
||||
let result = git_checkpoint(
|
||||
&sandbox,
|
||||
"run1",
|
||||
"work",
|
||||
"success",
|
||||
1,
|
||||
None,
|
||||
&[],
|
||||
&author,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "git_checkpoint failed: {:?}", result.err());
|
||||
|
||||
// Verify that excluded directories were NOT staged
|
||||
|
|
|
|||
|
|
@ -16,4 +16,10 @@
|
|||
|
||||
export interface RunCheckpointSettings {
|
||||
'exclude_globs': Array<string>;
|
||||
/**
|
||||
* When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false.
|
||||
* @type {boolean}
|
||||
* @memberof RunCheckpointSettings
|
||||
*/
|
||||
'skip_git_hooks': boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue