Run fabro's git plumbing through the driver's verbs

Fabro assembled its own hardened git command lines (maintenance, hooks,
fsmonitor, path quoting, signing, the file transport, external diff
drivers) in three crates and parsed raw diff, numstat, cat-file, and log
output itself. The driver's git facet now carries fetch, rev-parse,
ancestry, diff entries, numstat, patch, log, blob sizes and contents,
config, untracked files, and stage-all, hardened by default and typed, so
the checkpoint commit, the run diffs, the Run Files listing and blob
reads, the commit log, the fork fetch, the agent's changed-files
detection, and the git identity setup go through it. The parsers and the
command prefixes go; the per-run capability probe keeps its own plumbing
script. Checkpoint commits never run repository hooks now, so
skip_git_hooks and commit_timeout are accepted for compatibility only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-11 12:55:39 -06:00
parent 22cd5d3382
commit 7d9d2bf5f3
No known key found for this signature in database
12 changed files with 505 additions and 877 deletions

1
Cargo.lock generated
View file

@ -3043,6 +3043,7 @@ dependencies = [
"rand 0.9.4",
"regex",
"reqwest 0.12.28",
"sandbox-driver",
"semver",
"serde",
"serde_json",

View file

@ -14977,9 +14977,10 @@ components:
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.
Accepted for compatibility. Fabro-managed run-branch checkpoint
commits never run local Git commit hooks: the sandbox driver
disables repository hooks on every git command it runs. Does not
affect Fabro `[[run.hooks]]`. Defaults to false.
RunCloneSettings:
type: object

View file

@ -428,8 +428,8 @@ commit_timeout = "30s"
| 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. |
| `commit_timeout` | Max duration for the per-node run-branch checkpoint commit (e.g. `"30s"`, `"10m"`). This commit runs repository commit hooks unless `skip_git_hooks` is `true`. Defaults to `"30s"`. |
| `skip_git_hooks` | Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks (e.g. `pre-commit`, `commit-msg`); the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro workflow `[[run.hooks]]`. |
| `commit_timeout` | Accepted for compatibility. The per-node run-branch checkpoint commit runs under the sandbox driver's git command budget; no repository hook can prolong it. |
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics: the highest layer that sets the field wins.

View file

@ -35,6 +35,7 @@ fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
sandbox-driver.workspace = true
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
fabro-llm = { path = "../../components/fabro-llm" }

View file

@ -20,7 +20,7 @@ use std::future::Future;
use std::num::NonZeroU64;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use axum::Json;
use axum::extract::{Path, Query, State};
@ -43,6 +43,9 @@ use fabro_workflow::sandbox_git::{
list_diff_numstat, stream_blob_metadata, stream_blobs,
};
use futures_util::FutureExt;
use sandbox_driver::{
Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange,
};
use serde::Deserialize;
use tokio::sync::{Mutex, watch};
@ -60,6 +63,7 @@ pub(crate) const AGGREGATE_BYTES_CAP: u64 = 5 * 1024 * 1024;
pub(crate) const FILE_COUNT_CAP: usize = 200;
/// Sandbox git timeout. Matches Unit 3 helpers (10 s).
const SANDBOX_GIT_TIMEOUT_MS: u64 = 10_000;
const SANDBOX_GIT_TIMEOUT: Duration = Duration::from_millis(SANDBOX_GIT_TIMEOUT_MS);
/// Below this SHA count the phase-1 `cat-file --batch-check` pre-filter is
/// skipped — its ~100 ms round-trip dominates for small diffs, and phase-2
@ -333,8 +337,7 @@ async fn materialize_run_commits(
.ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no base SHA."))?;
let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?;
let (head_sha, _) = resolve_ref_sha_and_time(&sandbox, "HEAD").await?;
let output = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?;
let mut commits = parse_git_log_commits(&output)?;
let mut commits = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?;
let truncated = commits.len() > usize::try_from(limit).unwrap_or(usize::MAX);
commits.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
let total_returned = u64::try_from(commits.len()).unwrap_or(u64::MAX);
@ -357,57 +360,26 @@ async fn git_log_commits(
base_sha: &str,
head_sha: &str,
limit: u64,
) -> std::result::Result<String, ApiError> {
let base_q = shell_quote(base_sha);
let head_q = shell_quote(head_sha);
let format_q =
shell_quote("%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B%x1e");
sandbox_git_stdout(
sandbox,
&format!(
"git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false log --first-parent --reverse --max-count={limit} --format={format_q} {base_q}..{head_q}"
),
"git log",
)
.await
) -> std::result::Result<Vec<RunCommit>, ApiError> {
let git = sandbox_git(sandbox)?;
let options = GitLogOptions::new(GitRevisionRange::new(base_sha).to(head_sha))
.first_parent()
.reverse()
.max_count(limit)
.timeout(SANDBOX_GIT_TIMEOUT);
let commits = git
.log(sandbox.working_directory(), &options)
.await
.map_err(|error| sandbox_git_error("git log", &error))?;
commits.iter().map(run_commit).collect()
}
fn parse_git_log_commits(stdout: &str) -> std::result::Result<Vec<RunCommit>, ApiError> {
stdout
.split('\x1e')
.filter_map(|record| {
let record = record.trim_matches('\n');
(!record.is_empty()).then_some(record)
})
.map(parse_git_log_commit)
.collect()
}
fn parse_git_log_commit(record: &str) -> std::result::Result<RunCommit, ApiError> {
let mut fields = record.splitn(10, '\x1f');
let sha = fields.next().unwrap_or_default();
let tree_sha = fields.next().unwrap_or_default();
let parents = fields.next().unwrap_or_default();
let author_name = fields.next().unwrap_or_default();
let author_email = fields.next().unwrap_or_default();
let author_date = fields.next().unwrap_or_default();
let committer_name = fields.next().unwrap_or_default();
let committer_email = fields.next().unwrap_or_default();
let committer_date = fields.next().unwrap_or_default();
let message = fields
.next()
.unwrap_or_default()
.trim_end_matches('\n')
.to_string();
if sha.is_empty() {
return Err(ApiError::bad_request(
"Malformed git log output: missing commit SHA.",
));
}
fn run_commit(commit: &GitCommit) -> std::result::Result<RunCommit, ApiError> {
let message = commit.message.trim_end_matches('\n').to_string();
let (subject, body) = split_commit_message(&message);
let parents = parents
.split_whitespace()
let parents = commit
.parents
.iter()
.map(|parent| {
Ok(RunCommitParent {
sha: sha_newtype::<RunCommitParentSha>(parent)?,
@ -417,27 +389,27 @@ fn parse_git_log_commit(record: &str) -> std::result::Result<RunCommit, ApiError
.collect::<std::result::Result<Vec<_>, ApiError>>()?;
Ok(RunCommit {
sha: sha_newtype::<RunCommitSha>(sha)?,
short_sha: short_sha_newtype::<RunCommitShortSha>(sha)?,
sha: sha_newtype::<RunCommitSha>(&commit.sha)?,
short_sha: short_sha_newtype::<RunCommitShortSha>(&commit.sha)?,
parents,
author: RunCommitPerson {
name: author_name.to_string(),
email: author_email.to_string(),
date: parse_git_date(author_date),
name: commit.author.name.clone(),
email: commit.author.email.clone(),
date: parse_git_date(&commit.author.date),
},
committer: RunCommitPerson {
name: committer_name.to_string(),
email: committer_email.to_string(),
date: parse_git_date(committer_date),
name: commit.committer.name.clone(),
email: commit.committer.email.clone(),
date: parse_git_date(&commit.committer.date),
},
subject,
body,
message: message.clone(),
trailers: parse_commit_trailers(&message),
tree_sha: if tree_sha.is_empty() {
tree_sha: if commit.tree.is_empty() {
None
} else {
Some(sha_newtype::<RunCommitTreeSha>(tree_sha)?)
Some(sha_newtype::<RunCommitTreeSha>(&commit.tree)?)
},
})
}
@ -733,15 +705,15 @@ async fn materialize_working_tree_sandbox_path(
start: Instant,
) -> ListRunFilesResult {
let (to_sha, to_sha_committed_at) = resolve_head_sha_and_time(sandbox).await?;
let base_q = shell_quote(base_ref);
let patch = sandbox_git_stdout(
sandbox,
&format!(
"git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false diff --patch --find-renames=50% {base_q}"
),
"git diff --patch",
)
.await?;
let git = sandbox_git(sandbox)?;
// No head: the driver diffs `base_ref` against the working tree.
let options = GitDiffOptions::new(GitRevisionRange::new(base_ref))
.find_renames(50)
.timeout(SANDBOX_GIT_TIMEOUT);
let patch = git
.diff_patch(sandbox.working_directory(), &options)
.await
.map_err(|error| sandbox_git_error("git diff --patch", &error))?;
let entries: Vec<String> = split_patch_sections(&patch)
.into_iter()
@ -763,22 +735,25 @@ async fn materialize_working_tree_sandbox_path(
))
}
async fn sandbox_git_stdout(
sandbox: &RunSandbox,
command: &str,
op: &str,
) -> std::result::Result<String, ApiError> {
let res = sandbox
.exec_command(command, SANDBOX_GIT_TIMEOUT_MS, None, None, None)
.await
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?;
if res.termination == Termination::TimedOut {
return Err(transient_503(op, "command timed out"));
/// The sandbox's git facet; a provider without git cannot serve files.
fn sandbox_git(sandbox: &RunSandbox) -> std::result::Result<GitFacet<'_>, ApiError> {
sandbox
.git()
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))
}
/// A driver git failure as the endpoint's transient 503, so the client
/// retries; a command that timed out says so.
fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError {
let timed_out = matches!(
error,
sandbox_driver::Error::Git(failure)
if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut)
);
if timed_out {
return transient_503(op, "command timed out");
}
if !res.success() {
return Err(transient_503(op, res.stderr_lossy().trim()));
}
Ok(res.stdout_lossy())
transient_503(op, &fabro_sandbox::display_for_log(error))
}
/// Build the degraded response from the stored terminal diff patch.
@ -1752,7 +1727,7 @@ mod tests {
sandbox.respond_with(|command| {
let stdout = if command.contains(" show -s --format=") {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2026-05-09T17:12:40Z\n".to_string()
} else if command.contains(" diff --patch --find-renames=50% ") {
} else if command.contains("'diff'") && command.contains("'--find-renames=50%'") {
"\
diff --git a/src/live.rs b/src/live.rs
--- a/src/live.rs
@ -1784,12 +1759,18 @@ diff --git a/src/live.rs b/src/live.rs
let commands = sandbox.driver().scripted_exec().commands();
assert_eq!(commands.len(), 2);
assert!(commands[0].contains(" show -s --format="));
assert!(commands[1].contains(" diff --patch --find-renames=50% HEAD"));
assert!(
commands[1].contains("'diff'")
&& commands[1].contains("'--find-renames=50%'")
&& commands[1].contains("'HEAD'"),
"{}",
commands[1]
);
assert!(!commands.iter().any(|command| command.contains("ls-files")));
}
#[test]
fn parse_git_log_commits_keeps_external_and_fabro_metadata() {
#[tokio::test]
async fn git_log_commits_keeps_external_and_fabro_metadata() {
let stdout = concat!(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1f",
"cccccccccccccccccccccccccccccccccccccccc\x1f",
@ -1804,8 +1785,26 @@ diff --git a/src/live.rs b/src/live.rs
"Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f",
"external tool update\n\nLonger body.\n\x1e",
);
let sandbox = fabro_sandbox::test_support::MockSandbox::default();
sandbox
.driver()
.scripted_exec()
.push_result(fabro_sandbox::test_support::exec_result(
stdout,
"",
Some(0),
Termination::Exited,
1,
));
let commits = parse_git_log_commits(stdout).expect("git log should parse");
let commits = git_log_commits(
&sandbox.sandbox(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"dddddddddddddddddddddddddddddddddddddddd",
50,
)
.await
.expect("git log should parse");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].subject, "fabro(run_1): implement (succeeded)");
@ -1818,6 +1817,11 @@ diff --git a/src/live.rs b/src/live.rs
assert_eq!(commits[1].subject, "external tool update");
assert_eq!(commits[1].body.as_deref(), Some("Longer body."));
assert!(commits[1].trailers.is_empty());
let command = &sandbox.driver().scripted_exec().commands()[0];
assert!(
command.contains("'--first-parent'") && command.contains("'--max-count=50'"),
"{command}"
);
}
#[tokio::test]

View file

@ -472,9 +472,11 @@ impl RunSandbox {
}
}
/// The driver's git facet for this sandbox's checkout. Absent until a
/// pending sandbox is initialized, or when the provider has no git.
pub(crate) fn git(&self) -> crate::Result<sandbox_driver::GitFacet<'_>> {
/// The driver's git facet for this sandbox's checkout, for fabro's own
/// git operations (checkpoints, diffs, the Run Files listing). Absent
/// until a pending sandbox is initialized, or when the provider has no
/// git. Pass [`Self::working_directory`] as the repository path.
pub fn git(&self) -> crate::Result<sandbox_driver::GitFacet<'_>> {
self.handle()?.git().ok_or_else(|| {
crate::Error::message(format!(
"sandbox provider `{}` does not support git",

View file

@ -5,20 +5,17 @@ use chrono::{DateTime, Utc};
use fabro_github::token_source::TokenSnapshot;
use fabro_util::shell;
use sandbox_driver::{
Git as _, GitAttempt, GitCheckoutOptions, GitPushOptions, GitRetryError, GitRetryPolicy,
retry_git,
Git as _, GitAttempt, GitCheckoutOptions, GitFetchOptions, GitPushOptions, GitRetryError,
GitRetryPolicy, retry_git,
};
use serde::{Deserialize, Serialize};
use tokio::time;
use crate::credentials::{self, RepoCredentials};
use crate::driver_sandbox::RunSandbox;
use crate::exec::ExecResultExt;
use crate::git_policy::{self, GitRetryReason};
/// Git command prefix that disables background maintenance.
pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
/// Where a clone-based sandbox put its files, as persisted on the run.
@ -245,38 +242,27 @@ pub(crate) async fn fetch_source_run_ref(
) -> crate::Result<()> {
let remote_ref = format!("refs/heads/fabro/run/{source_run_id}");
let tracking_ref = format!("refs/remotes/origin/fabro/run/{source_run_id}");
let fetch_cmd = format!(
"{GIT} fetch origin {}:{}",
shell_quote(&remote_ref),
shell_quote(&tracking_ref)
);
let check_cmd = format!(
"{GIT} merge-base --is-ancestor {} {}",
shell_quote(checkpoint_sha),
shell_quote(&tracking_ref)
);
let git = sandbox.git()?;
let repo = sandbox.working_directory();
let mut fetch = GitFetchOptions::default();
fetch.remote = Some("origin".to_owned());
fetch.refspecs = vec![format!("{remote_ref}:{tracking_ref}")];
fetch.timeout = Some(Duration::from_secs(30));
// The source run's checkpoint may still be landing on the remote; a
// few short retries cover the replication.
let mut last_error = String::new();
for _ in 0..5 {
let fetch = sandbox
.exec_command(&fetch_cmd, 30_000, None, None, None)
.await?;
if fetch.success() {
let check = sandbox
.exec_command(&check_cmd, 10_000, None, None, None)
.await?;
if check.success() {
return Ok(());
}
last_error = check
.into_exec_error(format!(
"checkpoint {checkpoint_sha} is not reachable from {remote_ref}"
))
.to_string();
} else {
last_error = fetch
.into_exec_error("git fetch source run ref")
.to_string();
match git.fetch(repo, &fetch).await {
Ok(()) => match git.is_ancestor(repo, checkpoint_sha, &tracking_ref).await {
Ok(true) => return Ok(()),
Ok(false) => {
last_error =
format!("checkpoint {checkpoint_sha} is not reachable from {remote_ref}");
}
Err(error) => last_error = format!("git merge-base --is-ancestor: {error}"),
},
Err(error) => last_error = format!("git fetch source run ref: {error}"),
}
time::sleep(Duration::from_millis(500)).await;
}

View file

@ -2,25 +2,26 @@ use std::collections::HashSet;
use std::sync::Arc;
use fabro_agent::{RunSandbox, shell_quote};
use sandbox_driver::{Git as _, GitDiffOptions, GitRevisionRange};
const DIFF_MARKER: &str = "__FABRO_CHANGED_FILES_DIFF__";
const UNTRACKED_MARKER: &str = "__FABRO_CHANGED_FILES_UNTRACKED__";
/// The paths the working tree changed against `HEAD`, plus the untracked
/// files git does not ignore, sorted and deduplicated. A sandbox without
/// git, or a working directory that is not a repository, has no changed
/// files.
pub async fn detect_changed_files(sandbox: &Arc<RunSandbox>) -> Vec<String> {
let Ok(git) = sandbox.git() else {
return Vec::new();
};
let repo = sandbox.working_directory();
let mut files: Vec<String> = Vec::new();
let command = format!(
"printf '%s\\n' {diff}; git diff --name-only || true; \
printf '%s\\n' {untracked}; git ls-files --others --exclude-standard || true",
diff = shell_quote(DIFF_MARKER),
untracked = shell_quote(UNTRACKED_MARKER),
);
if let Ok(result) = sandbox
.exec_command(&command, 30_000, None, None, None)
if let Ok(entries) = git
.diff_entries(repo, &GitDiffOptions::new(GitRevisionRange::new("HEAD")))
.await
{
if result.success() {
files.extend(parse_changed_files(&result.stdout_lossy()));
}
files.extend(entries.into_iter().map(|entry| entry.path));
}
if let Ok(untracked) = git.untracked_files(repo).await {
files.extend(untracked);
}
files.sort();
@ -57,27 +58,3 @@ pub async fn files_touched_since(
(files_touched, last_file_touched)
}
fn parse_changed_files(stdout: &str) -> impl Iterator<Item = String> + '_ {
stdout.lines().filter_map(|line| {
let trimmed = line.trim();
(!trimmed.is_empty() && trimmed != DIFF_MARKER && trimmed != UNTRACKED_MARKER)
.then(|| trimmed.to_string())
})
}
#[cfg(test)]
mod tests {
use super::parse_changed_files;
#[test]
fn parse_changed_files_ignores_section_markers() {
let files = parse_changed_files(
"__FABRO_CHANGED_FILES_DIFF__\nsrc/main.rs\n\
__FABRO_CHANGED_FILES_UNTRACKED__\nREADME.md\n",
)
.collect::<Vec<_>>();
assert_eq!(files, vec!["src/main.rs", "README.md"]);
}
}

View file

@ -12,13 +12,13 @@ use fabro_llm::credentials::{CredentialProvider, readiness};
use fabro_llm::lithos_catalog::Catalog;
use fabro_sandbox::{
DaytonaCredentials, ExecResultExt, GitSetupIntent, ProviderAccess, SandboxSpec,
reconnect_for_run_with_events, shell_quote,
reconnect_for_run_with_events,
};
use fabro_static::EnvVars;
use fabro_types::RunSandboxKind;
use fabro_util::time::elapsed_ms;
use fabro_vault::Vault;
use sandbox_driver::{CorrelationId, EventContext};
use sandbox_driver::{CorrelationId, EventContext, Git as _};
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
@ -79,18 +79,15 @@ async fn configure_sandbox_git_identity(
sandbox: &RunSandbox,
author: &GitAuthor,
) -> Result<(), Error> {
let command = format!(
"git config --local user.name {} && git config --local user.email {}",
shell_quote(&author.name),
shell_quote(&author.email)
);
sandbox
.exec_command(&command, 10_000, None, None, None)
.await
.map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))?
.into_result("git config user identity")
let git = sandbox
.git()
.map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))?;
let repo = sandbox.working_directory();
for (key, value) in [("user.name", &author.name), ("user.email", &author.email)] {
git.config_set(repo, key, value)
.await
.map_err(|err| Error::engine_with_source("Sandbox git identity setup failed", err))?;
}
Ok(())
}
@ -1071,10 +1068,17 @@ mod tests {
.expect("git identity should configure");
let commands = sandbox.driver().scripted_exec().commands();
assert_eq!(commands, vec![
"git config --local user.name 'Fabro Bot' && git config --local user.email \
fabro-bot@example.com"
]);
assert_eq!(commands.len(), 2, "{commands:#?}");
assert!(
commands[0].contains("'config' '--local' '--' 'user.name' 'Fabro Bot'"),
"{}",
commands[0]
);
assert!(
commands[1].contains("'config' '--local' '--' 'user.email' 'fabro-bot@example.com'"),
"{}",
commands[1]
);
}
#[tokio::test]

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
use fabro_agent::RunSandbox;
use fabro_sandbox::shell_quote;
use fabro_sandbox::{ExecResult, ExecResultExt, Termination, shell_quote};
use fabro_util::error::SharedError;
use tokio::sync::OnceCell;
use crate::sandbox_git::{GIT_REMOTE, exec_err};
use crate::sandbox_git::GitCommandError;
pub(crate) struct SandboxGitRuntime {
probe: OnceCell<Result<(), SharedError>>,
@ -69,7 +69,7 @@ async fn probe_sandbox_git(sandbox: &RunSandbox) -> Result<(), SharedError> {
temp_q = shell_quote(&temp),
probe_file_q = shell_quote(&probe_file),
index_q = shell_quote(&index),
git = GIT_REMOTE,
git = "git -c maintenance.auto=0 -c gc.auto=0",
);
exec_ok(sandbox, &command).await
}
@ -95,3 +95,23 @@ async fn exec_ok(sandbox: &RunSandbox, command: &str) -> Result<(), SharedError>
))))
}
}
/// The probe's failure, named by how the command ended; the output tail
/// travels in the source.
fn exec_err(label: &str, result: ExecResult) -> GitCommandError {
let duration_ms = result.duration_ms();
let message = match result.termination {
Termination::TimedOut => format!("{label} timed out after {duration_ms}ms"),
Termination::Cancelled | Termination::Killed => {
format!("{label} cancelled after {duration_ms}ms")
}
_ => format!(
"{label} failed (exit {})",
result.program_exit_code().unwrap_or(-1)
),
};
GitCommandError {
message,
source: result.into_exec_error(label),
}
}

View file

@ -1039,14 +1039,16 @@ impl Default for RunExecutionSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCheckpointSettings {
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.
/// Accepted for compatibility. Fabro-managed run-branch checkpoint
/// commits never run local Git commit hooks (e.g. `pre-commit`,
/// `commit-msg`): the sandbox driver disables repository hooks on every
/// git command it runs, whatever this field says. Fabro workflow
/// `[[run.hooks]]` are unaffected.
#[serde(default)]
pub skip_git_hooks: bool,
/// Timeout (ms) for the per-node run-branch checkpoint commit, which runs
/// repository commit hooks unless `skip_git_hooks` is set. Default 30_000.
/// Accepted for compatibility. The per-node run-branch checkpoint commit
/// runs under the sandbox driver's own git command budget now that no
/// repository hook can prolong it. Default 30_000.
#[serde(default = "default_checkpoint_commit_timeout_ms")]
pub commit_timeout_ms: u64,
}