fabro/lib/components/fabro-workflow/src/sandbox_git.rs
Bryan Helmkamp 18a3c4741e
Run agent stages, Ask Fabro, and fabro exec on pebble's CodingAgent
Replace fabro's hand-written agent loop with pebble's `CodingAgent` and
delete the `fabro-agent` crate.

Workflow: `PebbleBackend` builds one agent per stage over `RunSandbox`,
binds the stage's hooks as tool middleware, the interviewer as the
human-input provider, and a durable `EventSink` that writes every agent
event through the run event log before the agent goes on. Full-fidelity
threads continue across stages through `export`/`resume_from_export`.
Model failover takes the session record after the failed prompt and
continues it on the next route with `ResumeMode::UseModel`, so no tool
effect repeats. The steering hub targets pebble's control handle, with
a steering lease holding completion open while a human is paired.

Events: `EventBody::Agent` carries pebble's `CodingAgentEvent` envelope;
the per-variant bodies, the transcript projection, and the fabro-only
context-window, tool-summary, and skill types are gone in favor of
pebble's. The OpenAPI schemas, generated Rust and TypeScript clients,
and web readers follow.

Ask Fabro: the session runs a `CodingAgent` under a read-only permission
policy and a system prompt transform. Its conversation lives in a new
`run_session_records` table and resumes on the recorded model with the
event cursor advanced past the run log.

`fabro exec` builds the same agent over a local sandbox with pebble's
permission middleware and an interactive approval service.

The catalog fills in `metadata.agent.profile` for operator providers
that declare none, so pebble's lookup is the one resolution path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:19:15 -06:00

1484 lines
50 KiB
Rust

use std::collections::{HashMap, HashSet};
use fabro_checkpoint::trailer as trailerlink;
use fabro_checkpoint::trailer::Trailer;
use fabro_sandbox::{RunSandbox, shell_quote};
use fabro_types::settings::run::RunCheckpointSettings;
use fabro_util::error::SharedError;
use crate::artifact_snapshot;
use crate::git::GitAuthor;
use crate::sandbox_git_runtime::SandboxGitRuntime;
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct GitCommandError {
pub message: String,
#[source]
pub source: fabro_sandbox::Error,
}
pub const GIT_REMOTE: &str =
"git -c maintenance.auto=0 -c gc.auto=0 -c commit.gpgsign=false -c tag.gpgsign=false";
pub(crate) fn exec_err(label: &str, r: fabro_sandbox::ExecResult) -> GitCommandError {
if r.is_timed_out() {
return GitCommandError {
message: format!("{label} timed out after {}ms", r.duration_ms),
source: fabro_sandbox::Error::exec(label, r),
};
}
if r.is_cancelled() {
return GitCommandError {
message: format!("{label} cancelled after {}ms", r.duration_ms),
source: fabro_sandbox::Error::exec(label, r),
};
}
let exit = r.display_exit_code();
GitCommandError {
message: format!("{label} failed (exit {exit})"),
source: fabro_sandbox::Error::exec(label, r),
}
}
/// Run a git checkpoint commit via the sandbox.
#[allow(
clippy::too_many_arguments,
reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs."
)]
pub async fn git_checkpoint(
sandbox: &RunSandbox,
run_id: &str,
node_id: &str,
status: &str,
completed_count: usize,
shadow_sha: Option<String>,
checkpoint: &RunCheckpointSettings,
author: &GitAuthor,
) -> std::result::Result<String, GitCommandError> {
let mut all_excludes: Vec<String> = artifact_snapshot::EXCLUDE_DIRS
.iter()
.map(|d| format!("**/{d}/**"))
.collect();
all_excludes.extend(checkpoint.exclude_globs.iter().cloned());
let pathspecs: Vec<String> = all_excludes
.iter()
.map(|g| format!("':(glob,exclude){g}'"))
.collect();
let add_cmd = format!("{GIT_REMOTE} add -A -- . {}", pathspecs.join(" "));
let add_result = sandbox
.exec_command(&add_cmd, checkpoint.commit_timeout_ms, None, None, None)
.await;
match add_result {
Ok(r) if r.is_success() => {}
Ok(r) => return Err(exec_err("git add", r)),
Err(e) => {
return Err(GitCommandError {
message: "git add failed".to_string(),
source: e,
});
}
}
let subject = format!("fabro({run_id}): {node_id} ({status})");
let completed_str = completed_count.to_string();
let mut trailers = vec![
Trailer {
key: "Fabro-Run",
value: run_id,
},
Trailer {
key: "Fabro-Completed",
value: &completed_str,
},
];
let shadow_sha_ref = shadow_sha.as_deref().unwrap_or("");
if shadow_sha.is_some() {
trailers.push(Trailer {
key: "Fabro-Checkpoint",
value: shadow_sha_ref,
});
}
let mut message = trailerlink::format_message(&subject, "", &trailers);
author.append_footer(&mut message);
let msg_path = format!("/tmp/fabro-commit-msg-{}", uuid::Uuid::new_v4());
if let Err(e) = sandbox.write_file(&msg_path, &message).await {
return Err(GitCommandError {
message: "failed to write commit message file".to_string(),
source: e,
});
}
let msg_path_q = shell_quote(&msg_path);
let no_verify = if checkpoint.skip_git_hooks {
" --no-verify"
} else {
""
};
let commit_cmd = format!(
"{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),
);
let commit_result = sandbox
.exec_command(&commit_cmd, checkpoint.commit_timeout_ms, None, None, None)
.await;
let _ = sandbox.delete_file(&msg_path).await;
match commit_result {
Ok(r) if r.is_success() => {}
Ok(r) => return Err(exec_err("git commit", r)),
Err(e) => {
return Err(GitCommandError {
message: "git commit failed".to_string(),
source: e,
});
}
}
let sha_cmd = format!("{GIT_REMOTE} rev-parse HEAD");
let sha_result = sandbox
.exec_command(&sha_cmd, 10_000, None, None, None)
.await;
match sha_result {
Ok(r) if r.is_success() => Ok(r.stdout.trim().to_string()),
Ok(r) => Err(exec_err("git rev-parse HEAD", r)),
Err(e) => Err(GitCommandError {
message: "git rev-parse HEAD failed".to_string(),
source: e,
}),
}
}
/// Run a git checkpoint after the per-run sandbox git capability probe.
#[allow(
clippy::too_many_arguments,
reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs."
)]
#[tracing::instrument(name = "git_op", skip_all, fields(op = "checkpoint-commit"))]
pub(crate) async fn checked_git_checkpoint(
runtime: &SandboxGitRuntime,
sandbox: &RunSandbox,
run_id: &str,
node_id: &str,
status: &str,
completed_count: usize,
shadow_sha: Option<String>,
checkpoint: &RunCheckpointSettings,
author: &GitAuthor,
) -> std::result::Result<String, SharedError> {
runtime.ensure_git_available(sandbox).await.map_err(|err| {
SharedError::new(anyhow::Error::new(err).context("sandbox git unavailable"))
})?;
git_checkpoint(
sandbox,
run_id,
node_id,
status,
completed_count,
shadow_sha,
checkpoint,
author,
)
.await
.map_err(|err| SharedError::new(anyhow::Error::new(err)))
}
/// Run a git diff via the sandbox (30 s default timeout).
pub(crate) async fn git_diff(
sandbox: &RunSandbox,
base: &str,
) -> std::result::Result<String, GitCommandError> {
git_diff_with_timeout(sandbox, base, 30_000).await
}
/// Run a git diff via the sandbox with a caller-supplied timeout in
/// milliseconds.
///
/// Failure-path capture uses a shorter timeout than the checkpoint path so a
/// pathological workspace (FS locks, corrupted index) doesn't stall terminal
/// event emission downstream (Slack notifier, SSE, CI hooks).
pub(crate) async fn git_diff_with_timeout(
sandbox: &RunSandbox,
base: &str,
timeout_ms: u64,
) -> std::result::Result<String, GitCommandError> {
// `-c core.quotePath=false` forces paths with non-ASCII, tabs, quotes,
// or backslashes to emit unquoted. The Run Files Changed endpoint's
// `strip_denylisted_sections` parser only recognizes unquoted
// `diff --git a/<old> b/<new>` headers; without this flag git would
// wrap such paths in `"a/…"` / `"b/…"` and evade the denylist (see
// docs/agent/reviews/2026-04-19-run-files-security-review.md).
let cmd = format!("{GIT_REMOTE} -c core.quotePath=false diff {base} HEAD");
match sandbox
.exec_command(&cmd, timeout_ms, None, None, None)
.await
{
Ok(r) if r.is_success() => Ok(r.stdout),
Ok(r) => Err(exec_err("git diff", r)),
Err(e) => Err(GitCommandError {
message: "git diff failed".to_string(),
source: e,
}),
}
}
// ── Machine-readable diff enumeration (Run Files endpoint) ─────────────────
/// Hardened git-command prefix for the Run Files endpoint.
///
/// Layers on top of [`GIT_REMOTE`]:
/// - `core.hooksPath=/dev/null`: repo-supplied hooks do not run.
/// - `core.fsmonitor=false`: no fsmonitor daemon interactions.
/// - `protocol.file.allow=never`: blocks local-protocol fetches.
///
/// These invocations use [`sandbox_git_hardening_env`] via `exec_command` to
/// disable terminal prompts and external diff drivers.
const GIT_HARDENED: &str = "git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.file.allow=never -c core.quotePath=false";
/// Environment additions applied to every hardened sandbox-side git invocation.
///
/// `GIT_TERMINAL_PROMPT=0` prevents git from stalling on credential prompts
/// when a remote or subprocess triggers one. Clearing `GIT_EXTERNAL_DIFF`
/// neutralizes any inherited custom diff driver.
fn sandbox_git_hardening_env() -> std::collections::HashMap<String, String> {
std::collections::HashMap::from([
("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
("GIT_EXTERNAL_DIFF".to_string(), String::new()),
])
}
/// A single changed-file entry from `git diff --raw -z --find-renames=50%`.
///
/// Paths are repo-relative, UTF-8; non-UTF-8 filenames are rejected by the
/// parser. Blob SHAs are lowercase hex. Modes are octal integers (`100644`,
/// `100755`, `120000`, `160000`, …).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RawDiffEntry {
Added {
path: String,
new_blob: String,
new_mode: String,
},
Modified {
path: String,
old_blob: String,
new_blob: String,
new_mode: String,
},
Deleted {
path: String,
old_blob: String,
old_mode: String,
},
Renamed {
old_path: String,
new_path: String,
old_blob: String,
new_blob: String,
new_mode: String,
similarity: u8,
},
Symlink {
path: String,
change_kind: SymlinkChange,
old_blob: Option<String>,
new_blob: Option<String>,
},
Submodule {
path: String,
change_kind: SubmoduleChange,
},
}
/// Lifecycle of a symlink entry (mode `120000`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SymlinkChange {
Added,
Modified,
Deleted,
}
/// Lifecycle of a submodule entry (mode `160000`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubmoduleChange {
Added,
Modified,
Deleted,
}
/// Error produced by the sandbox-git helpers.
///
/// Callers discriminate between transient (retry-safe) and permanent
/// conditions: a 503 can be returned to the client on `Transient`, while
/// `Permanent` errors should fall through to the patch-only fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffError {
/// Retry-safe failure: timeout, process kill, transient I/O.
Transient { message: String },
/// Non-retryable failure: unknown revision, malformed output, etc.
Permanent { message: String },
}
impl std::fmt::Display for DiffError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transient { message } => write!(f, "transient: {message}"),
Self::Permanent { message } => write!(f, "permanent: {message}"),
}
}
}
impl std::error::Error for DiffError {}
/// Size metadata for a single blob, as reported by `git cat-file
/// --batch-check`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMeta {
pub sha: String,
/// `None` if the blob is missing (git reports `missing`).
pub size: Option<u64>,
}
/// Enumerate files changed between `base_sha` and `to_sha` via the sandbox.
///
/// Uses `git diff --raw -z --find-renames=50%` to get a machine-readable,
/// null-separated, SHA-addressed listing. Paths from this output are treated
/// as metadata only — blob reads use the SHAs, not the paths.
///
/// The `--numstat` side-call classifies text vs binary so callers can skip
/// binary contents without ever invoking `git cat-file --batch` on them.
pub async fn list_changed_files_raw(
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<Vec<RawDiffEntry>, DiffError> {
let base_q = shell_quote(base_sha);
let to_q = shell_quote(to_sha);
let env = sandbox_git_hardening_env();
let cmd = format!("{GIT_HARDENED} diff --raw -z --find-renames=50% {base_q}..{to_q}");
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.is_timed_out() {
return Err(DiffError::Transient {
message: "git diff --raw timed out".to_string(),
});
}
if !res.is_success() {
// An unknown-object / bad-revision error is permanent; everything
// else we treat as transient so the server can retry safely.
let stderr = res.stderr.trim().to_string();
if is_permanent_git_error(&stderr) {
return Err(DiffError::Permanent { message: stderr });
}
return Err(DiffError::Transient { message: stderr });
}
parse_raw_z(&res.stdout).map_err(|message| DiffError::Permanent { message })
}
fn is_permanent_git_error(stderr: &str) -> bool {
// git emits these to stderr for unknown revisions / missing objects;
// treat them as Permanent so the handler falls through to final_patch.
let lower = stderr.to_lowercase();
lower.contains("unknown revision")
|| lower.contains("bad revision")
|| lower.contains("invalid revision")
|| lower.contains("no such path")
|| lower.contains("not a valid object name")
}
fn parse_raw_z(stdout: &str) -> std::result::Result<Vec<RawDiffEntry>, String> {
// git diff --raw -z format:
// ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0"
// For renames/copies:
// ":<srcmode> <dstmode> <srcsha> <dstsha> R<score>\0<oldpath>\0<newpath>\0"
//
// Multiple entries are concatenated with no separator between them.
let mut entries = Vec::new();
let mut tokens = stdout.split('\0').peekable();
while let Some(header) = tokens.next() {
if header.is_empty() {
continue;
}
if !header.starts_with(':') {
return Err(format!("unexpected token in diff --raw: {header:?}"));
}
let fields: Vec<&str> = header[1..].split(' ').collect();
if fields.len() < 5 {
return Err(format!("short raw-diff header: {header:?}"));
}
let src_mode = fields[0];
let dst_mode = fields[1];
let src_sha = fields[2];
let dst_sha = fields[3];
let status = fields[4];
let entry = if status.starts_with('R') || status.starts_with('C') {
let score: u8 = status[1..].parse().unwrap_or(0);
let old_path = tokens
.next()
.ok_or_else(|| "missing old_path for rename".to_string())?
.to_string();
let new_path = tokens
.next()
.ok_or_else(|| "missing new_path for rename".to_string())?
.to_string();
RawDiffEntry::Renamed {
old_path,
new_path,
old_blob: src_sha.to_string(),
new_blob: dst_sha.to_string(),
new_mode: dst_mode.to_string(),
similarity: score,
}
} else {
let path = tokens
.next()
.ok_or_else(|| "missing path for diff entry".to_string())?
.to_string();
classify_entry(status, src_mode, dst_mode, src_sha, dst_sha, &path)?
};
entries.push(entry);
}
Ok(entries)
}
fn classify_entry(
status: &str,
src_mode: &str,
dst_mode: &str,
src_sha: &str,
dst_sha: &str,
path: &str,
) -> std::result::Result<RawDiffEntry, String> {
// Mode 120000 = symlink, 160000 = submodule (gitlink).
let is_symlink_change = src_mode == "120000" || dst_mode == "120000";
let is_submodule_change = src_mode == "160000" || dst_mode == "160000";
Ok(match (status, is_symlink_change, is_submodule_change) {
("A", true, _) => RawDiffEntry::Symlink {
path: path.to_string(),
change_kind: SymlinkChange::Added,
old_blob: None,
new_blob: Some(dst_sha.to_string()),
},
("A", _, true) => RawDiffEntry::Submodule {
path: path.to_string(),
change_kind: SubmoduleChange::Added,
},
("A", _, _) => RawDiffEntry::Added {
path: path.to_string(),
new_blob: dst_sha.to_string(),
new_mode: dst_mode.to_string(),
},
("D", true, _) => RawDiffEntry::Symlink {
path: path.to_string(),
change_kind: SymlinkChange::Deleted,
old_blob: Some(src_sha.to_string()),
new_blob: None,
},
("D", _, true) => RawDiffEntry::Submodule {
path: path.to_string(),
change_kind: SubmoduleChange::Deleted,
},
("D", _, _) => RawDiffEntry::Deleted {
path: path.to_string(),
old_blob: src_sha.to_string(),
old_mode: src_mode.to_string(),
},
("M" | "T", true, _) => RawDiffEntry::Symlink {
path: path.to_string(),
change_kind: SymlinkChange::Modified,
old_blob: Some(src_sha.to_string()),
new_blob: Some(dst_sha.to_string()),
},
("M" | "T", _, true) => RawDiffEntry::Submodule {
path: path.to_string(),
change_kind: SubmoduleChange::Modified,
},
("M" | "T", _, _) => RawDiffEntry::Modified {
path: path.to_string(),
old_blob: src_sha.to_string(),
new_blob: dst_sha.to_string(),
new_mode: dst_mode.to_string(),
},
(other, _, _) => {
return Err(format!("unknown raw-diff status {other:?} for {path:?}"));
}
})
}
pub use fabro_types::{DiffStats, DiffSummary};
/// Output of `git diff --numstat`: which paths are binary, plus per-path
/// `+/-` line totals for text files in the range. Both pieces come from a
/// single git invocation so callers don't need to run two diffs.
#[derive(Debug, Default)]
pub struct DiffNumstat {
/// Repo-relative paths (post-rename) that git classifies as binary.
pub binary_paths: HashSet<String>,
/// Repo-relative paths (post-rename) to line stats for text files.
pub line_stats_by_path: HashMap<String, DiffStats>,
}
pub fn summarize_diff_numstat(numstat: &DiffNumstat) -> DiffSummary {
let text_files = i64::try_from(numstat.line_stats_by_path.len()).unwrap_or(i64::MAX);
let binary_files = i64::try_from(numstat.binary_paths.len()).unwrap_or(i64::MAX);
let (additions, deletions) =
numstat
.line_stats_by_path
.values()
.fold((0_i64, 0_i64), |(adds, dels), stats| {
(
adds.saturating_add(stats.additions),
dels.saturating_add(stats.deletions),
)
});
DiffSummary {
files_changed: text_files.saturating_add(binary_files),
additions,
deletions,
}
}
/// Run `git diff --numstat` once and return both the set of binary paths and
/// text-file `+/-` totals. The single call replaces the previous binary-only
/// helper.
pub async fn list_diff_numstat(
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<DiffNumstat, DiffError> {
let base_q = shell_quote(base_sha);
let to_q = shell_quote(to_sha);
let env = sandbox_git_hardening_env();
let cmd = format!("{GIT_HARDENED} diff --numstat --find-renames=50% {base_q}..{to_q}");
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.is_timed_out() {
return Err(DiffError::Transient {
message: "git diff --numstat timed out".to_string(),
});
}
if !res.is_success() {
let stderr = res.stderr.trim().to_string();
if is_permanent_git_error(&stderr) {
return Err(DiffError::Permanent { message: stderr });
}
return Err(DiffError::Transient { message: stderr });
}
let mut out = DiffNumstat::default();
for line in res.stdout.lines() {
// `-\t-\t<path>` marks binary. Rename lines read `<+>\t<->\t<path> =>
// <path>` or `<+>\t<->\t{<old> => <new>}`.
if let Some(rest) = line.strip_prefix("-\t-\t") {
out.binary_paths.insert(extract_new_path_from_numstat(rest));
continue;
}
// Text rows: `<adds>\t<dels>\t<path>`. Tolerate malformed lines
// (e.g. trailing whitespace) by skipping rather than failing the
// whole diff — the rest of the response stays usable.
let mut parts = line.splitn(3, '\t');
let adds_s = parts.next().unwrap_or("");
let dels_s = parts.next().unwrap_or("");
let Some(path_s) = parts.next() else {
continue;
};
let Ok(adds) = adds_s.parse::<i64>() else {
continue;
};
let Ok(dels) = dels_s.parse::<i64>() else {
continue;
};
let path = extract_new_path_from_numstat(path_s);
out.line_stats_by_path.insert(path, DiffStats {
additions: adds,
deletions: dels,
});
}
Ok(out)
}
fn extract_new_path_from_numstat(rest: &str) -> String {
// Forms seen:
// "simple/path"
// "old => new"
// "prefix/{old => new}/suffix"
if let Some(open_idx) = rest.find('{') {
if let Some(close_idx) = rest[open_idx..].find('}') {
let before = &rest[..open_idx];
let after = &rest[open_idx + close_idx + 1..];
let inside = &rest[open_idx + 1..open_idx + close_idx];
if let Some((_, new)) = inside.split_once(" => ") {
return format!("{before}{new}{after}");
}
}
}
if let Some((_, new)) = rest.split_once(" => ") {
return new.to_string();
}
rest.to_string()
}
/// Fetch blob metadata (size) for many SHAs in one sandbox invocation via
/// `git cat-file --batch-check`.
///
/// The order of returned `BlobMeta` entries matches the input `shas` order.
/// SHAs reported as `missing` by git yield `BlobMeta { size: None, .. }`.
pub async fn stream_blob_metadata(
sandbox: &RunSandbox,
shas: &[String],
) -> std::result::Result<Vec<BlobMeta>, DiffError> {
if shas.is_empty() {
return Ok(Vec::new());
}
let env = sandbox_git_hardening_env();
let quoted_shas: Vec<String> = shas.iter().map(|s| shell_quote(s)).collect();
let cmd = format!(
"printf '%s\\n' {} | {GIT_HARDENED} cat-file --batch-check",
quoted_shas.join(" ")
);
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.is_timed_out() {
return Err(DiffError::Transient {
message: "git cat-file --batch-check timed out".to_string(),
});
}
if !res.is_success() {
return Err(DiffError::Transient {
message: format!("git cat-file --batch-check failed: {}", res.stderr.trim()),
});
}
let mut metas = Vec::with_capacity(shas.len());
for line in res.stdout.lines() {
// Lines: "<sha> <type> <size>" OR "<sha> missing"
let mut parts = line.split(' ');
let sha = parts
.next()
.ok_or_else(|| DiffError::Permanent {
message: format!("empty cat-file line: {line:?}"),
})?
.to_string();
let second = parts.next().unwrap_or("");
if second == "missing" {
metas.push(BlobMeta { sha, size: None });
continue;
}
let size_str = parts.next().unwrap_or("");
let size = size_str.parse::<u64>().map_err(|e| DiffError::Permanent {
message: format!("unparseable size {size_str:?} for {sha}: {e}"),
})?;
metas.push(BlobMeta {
sha,
size: Some(size),
});
}
Ok(metas)
}
/// Fetch blob contents for many SHAs in one sandbox invocation via
/// `git cat-file --batch`.
///
/// Contents are size-capped per blob: any blob exceeding `size_cap_bytes`
/// returns `None` in its slot (the caller should flag that entry as
/// truncated). Callers are expected to have pre-filtered binary blobs via
/// [`list_diff_numstat`] — `--batch` output stream is text-oriented and
/// non-UTF-8 bytes are lossy through the sandbox `String` channel.
pub async fn stream_blobs(
sandbox: &RunSandbox,
shas: &[String],
size_cap_bytes: u64,
) -> std::result::Result<Vec<Option<String>>, DiffError> {
if shas.is_empty() {
return Ok(Vec::new());
}
let env = sandbox_git_hardening_env();
let quoted_shas: Vec<String> = shas.iter().map(|s| shell_quote(s)).collect();
let cmd = format!(
"printf '%s\\n' {} | {GIT_HARDENED} cat-file --batch",
quoted_shas.join(" ")
);
let res = sandbox
.exec_command(&cmd, 10_000, None, Some(&env), None)
.await
.map_err(|e| DiffError::Transient {
message: e.display_with_causes(),
})?;
if res.is_timed_out() {
return Err(DiffError::Transient {
message: "git cat-file --batch timed out".to_string(),
});
}
if !res.is_success() {
return Err(DiffError::Transient {
message: format!("git cat-file --batch failed: {}", res.stderr.trim()),
});
}
parse_batch_output(&res.stdout, shas, size_cap_bytes)
.map_err(|message| DiffError::Permanent { message })
}
fn parse_batch_output(
stdout: &str,
shas: &[String],
size_cap_bytes: u64,
) -> std::result::Result<Vec<Option<String>>, String> {
// `git cat-file --batch` output per blob:
// "<sha> <type> <size>\n<content-bytes>\n"
// `missing` blob: "<sha> missing\n" (no content).
let mut results: Vec<Option<String>> = Vec::with_capacity(shas.len());
let bytes = stdout.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
// Find end of header line.
let Some(nl_rel) = bytes[pos..].iter().position(|&b| b == b'\n') else {
break;
};
let header = std::str::from_utf8(&bytes[pos..pos + nl_rel])
.map_err(|e| format!("non-utf8 header in cat-file output: {e}"))?;
pos += nl_rel + 1;
let mut parts = header.split(' ');
let _sha = parts.next().unwrap_or("");
let second = parts.next().unwrap_or("");
if second == "missing" {
results.push(None);
continue;
}
let size_str = parts.next().unwrap_or("");
let size: usize = size_str
.parse()
.map_err(|e| format!("unparseable size {size_str:?}: {e}"))?;
let end = pos + size;
if end > bytes.len() {
return Err(format!(
"cat-file stream truncated: expected {size} bytes, have {}",
bytes.len() - pos
));
}
if (size as u64) > size_cap_bytes {
results.push(None);
} else {
let content = std::str::from_utf8(&bytes[pos..end])
.map_err(|e| format!("non-utf8 blob contents: {e}"))?;
results.push(Some(content.to_string()));
}
pos = end;
// Trailing newline that delimits the next entry.
if pos < bytes.len() && bytes[pos] == b'\n' {
pos += 1;
}
}
// Pad with None if the stream didn't cover every requested SHA (e.g.
// duplicate-sha deduping by git).
while results.len() < shas.len() {
results.push(None);
}
Ok(results)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::disallowed_methods,
reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk."
)]
use fabro_sandbox::sandbox::ExecResult;
use fabro_sandbox::test_support::MockSandbox;
use fabro_types::CommandTermination;
use super::*;
/// A sandbox answering commands from `exec_results`, in order.
fn scripted(exec_results: &[ExecResult]) -> MockSandbox {
let sandbox = MockSandbox::default();
for result in exec_results {
sandbox.push_exec_result(result);
}
sandbox
}
fn exec_ok() -> ExecResult {
ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 1,
}
}
fn exec_timed_out(duration_ms: u64) -> ExecResult {
ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms,
}
}
fn exec_failed(exit_code: i32, stdout: &str, stderr: &str) -> ExecResult {
ExecResult {
stdout: stdout.to_string(),
stderr: stderr.to_string(),
exit_code: Some(exit_code),
termination: CommandTermination::Exited,
duration_ms: 1,
}
}
#[test]
fn git_remote_disables_commit_and_tag_signing() {
assert!(GIT_REMOTE.contains("-c commit.gpgsign=false"));
assert!(GIT_REMOTE.contains("-c tag.gpgsign=false"));
}
#[tokio::test]
async fn git_checkpoint_reports_add_timeout() {
let sandbox = scripted(&[exec_timed_out(77)]);
let err = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git add timed out after 77ms");
assert!(
fabro_sandbox::default_redacted_output_tail(&err).is_none(),
"empty exec streams should not produce a tail"
);
}
#[tokio::test]
async fn checked_git_checkpoint_fails_before_checkpoint_when_probe_fails() {
let sandbox = scripted(&[exec_failed(127, "", "git missing\n")]);
let runtime = crate::sandbox_git_runtime::SandboxGitRuntime::new();
let err = checked_git_checkpoint(
&runtime,
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
let chain = anyhow::Error::new(err.clone())
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>();
assert!(
chain.iter().any(|cause| cause == "sandbox git unavailable"),
"expected sandbox git context, got {chain:#?}"
);
assert!(
fabro_sandbox::default_redacted_output_tail(&err).is_some(),
"expected probe exec output tail to survive SharedError wrapping"
);
}
#[tokio::test]
async fn git_checkpoint_reports_commit_timeout() {
let sandbox = scripted(&[exec_ok(), exec_timed_out(88)]);
let err = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git commit timed out after 88ms");
}
#[tokio::test]
async fn git_checkpoint_reports_rev_parse_killed_without_output() {
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_failed(-1, "", "")]);
let err = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git rev-parse HEAD failed (exit -1)");
}
#[tokio::test]
async fn git_checkpoint_uses_unique_commit_message_paths_for_same_run_and_node() {
let sandbox = scripted(&[
exec_ok(),
exec_ok(),
exec_ok(),
exec_ok(),
exec_ok(),
exec_ok(),
]);
let author = crate::git::GitAuthor::default();
let first = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&author,
)
.await;
let second = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&author,
)
.await;
assert!(first.is_ok(), "first checkpoint failed: {:?}", first.err());
assert!(
second.is_ok(),
"second checkpoint failed: {:?}",
second.err()
);
let write_paths: Vec<String> = sandbox
.written_files()
.into_iter()
.map(|(path, _)| path)
.collect();
assert_eq!(write_paths.len(), 2);
assert!(
write_paths
.iter()
.all(|path| path.starts_with("/tmp/fabro-commit-msg-")),
"unexpected commit message paths: {write_paths:?}"
);
assert_ne!(write_paths[0], write_paths[1]);
let delete_paths = sandbox.deleted_files();
assert_eq!(delete_paths, write_paths);
let commands = sandbox.captured_commands();
let commit_commands = commands
.iter()
.filter(|command| command.contains(" commit "))
.collect::<Vec<_>>();
assert_eq!(commit_commands.len(), 2);
for (command, path) in commit_commands.iter().zip(write_paths.iter()) {
assert!(
command.contains(&format!("-F {}", shell_quote(path))),
"expected commit command to use {path:?}, got {command:?}"
);
}
}
#[tokio::test]
async fn git_checkpoint_uses_configured_timeout_for_add_and_commit() {
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
let checkpoint = RunCheckpointSettings {
commit_timeout_ms: 600_000,
..RunCheckpointSettings::default()
};
git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&checkpoint,
&crate::git::GitAuthor::default(),
)
.await
.expect("checkpoint should succeed");
assert_eq!(sandbox.captured_timeouts(), vec![600_000, 600_000, 10_000]);
}
#[tokio::test]
async fn git_diff_reports_timeout() {
let sandbox = scripted(&[exec_timed_out(99)]);
let err = git_diff_with_timeout(&sandbox.sandbox(), "HEAD~1", 99)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git diff timed out after 99ms");
}
#[tokio::test]
async fn git_diff_reports_failure_detail() {
let sandbox = scripted(&[exec_failed(128, "", "fatal: bad revision\n")]);
let err = git_diff_with_timeout(&sandbox.sandbox(), "bad-base", 100)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git diff failed (exit 128)");
assert!(!err.to_string().contains("fatal: bad revision"));
let tail = fabro_sandbox::default_redacted_output_tail(&err).expect("tail present");
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 = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
let checkpoint = RunCheckpointSettings {
skip_git_hooks: true,
..RunCheckpointSettings::default()
};
git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&checkpoint,
&crate::git::GitAuthor::default(),
)
.await
.expect("checkpoint should succeed");
let commands = sandbox.captured_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 = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.expect("checkpoint should succeed");
let commands = sandbox.captured_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
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"--allow-empty",
"-m",
"initial",
])
.current_dir(repo)
.output()
.unwrap();
// Create files in both tracked and excluded directories
std::fs::write(repo.join("hello.txt"), "hello").unwrap();
std::fs::create_dir_all(repo.join("node_modules/pkg")).unwrap();
std::fs::write(repo.join("node_modules/pkg/index.js"), "module").unwrap();
std::fs::create_dir_all(repo.join(".venv/lib")).unwrap();
std::fs::write(repo.join(".venv/lib/site.py"), "venv").unwrap();
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let author = crate::git::GitAuthor::default();
// Call git_checkpoint with empty user excludes — built-in excludes should still
// apply
let result = git_checkpoint(
&sandbox,
"run1",
"work",
"success",
1,
None,
&RunCheckpointSettings::default(),
&author,
)
.await;
assert!(result.is_ok(), "git_checkpoint failed: {:?}", result.err());
// Verify that excluded directories were NOT staged
let status = sandbox
.exec_command(
"git diff --cached --name-only HEAD~1",
10_000,
None,
None,
None,
)
.await
.unwrap();
let staged_files: Vec<&str> = status.stdout.lines().collect();
assert!(
staged_files.contains(&"hello.txt"),
"expected hello.txt to be staged, got: {staged_files:?}"
);
assert!(
!staged_files.iter().any(|f| f.contains("node_modules")),
"node_modules should be excluded from checkpoint, got: {staged_files:?}"
);
assert!(
!staged_files.iter().any(|f| f.contains(".venv")),
".venv should be excluded from checkpoint, got: {staged_files:?}"
);
}
// Test helpers for machine-readable diff enumeration. The repo is seeded
// with a single commit at `base_sha`, then callers mutate and re-commit
// to produce a synthetic `base_sha..HEAD` diff.
fn init_git_repo(repo: &std::path::Path) {
let _ = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(repo)
.output()
.unwrap();
let _ = std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
let _ = std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
}
fn git_commit_all(repo: &std::path::Path, msg: &str) -> String {
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(repo)
.output()
.unwrap();
let _ = std::process::Command::new("git")
.args(["commit", "-m", msg])
.current_dir(repo)
.output()
.unwrap();
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[tokio::test]
async fn list_changed_files_raw_classifies_add_modify_delete() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("keep.txt"), "v1\n").unwrap();
std::fs::write(repo.join("drop.txt"), "goodbye\n").unwrap();
let base = git_commit_all(repo, "initial");
std::fs::write(repo.join("keep.txt"), "v2\n").unwrap();
std::fs::write(repo.join("add.txt"), "new\n").unwrap();
std::fs::remove_file(repo.join("drop.txt")).unwrap();
let head = git_commit_all(repo, "change");
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let entries = list_changed_files_raw(&sandbox, &base, &head)
.await
.unwrap();
assert_eq!(entries.len(), 3, "entries: {entries:?}");
assert!(
entries
.iter()
.any(|e| matches!(e, RawDiffEntry::Added { path, .. } if path == "add.txt"))
);
assert!(
entries
.iter()
.any(|e| matches!(e, RawDiffEntry::Modified { path, .. } if path == "keep.txt"))
);
assert!(
entries
.iter()
.any(|e| matches!(e, RawDiffEntry::Deleted { path, .. } if path == "drop.txt"))
);
}
#[tokio::test]
async fn list_changed_files_raw_detects_rename_above_threshold() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
// Write a file with enough content that a rename remains >= 50%
// similar even after the rename (identical content should be 100%).
let content = "line of shared content\n".repeat(50);
std::fs::write(repo.join("old.txt"), &content).unwrap();
let base = git_commit_all(repo, "initial");
std::fs::remove_file(repo.join("old.txt")).unwrap();
std::fs::write(repo.join("new.txt"), &content).unwrap();
let head = git_commit_all(repo, "rename");
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let entries = list_changed_files_raw(&sandbox, &base, &head)
.await
.unwrap();
let renames: Vec<_> = entries
.iter()
.filter_map(|e| match e {
RawDiffEntry::Renamed {
old_path,
new_path,
similarity,
..
} => Some((old_path.clone(), new_path.clone(), *similarity)),
_ => None,
})
.collect();
assert_eq!(renames.len(), 1, "expected one rename, got: {entries:?}");
let (old_path, new_path, similarity) = &renames[0];
assert_eq!(old_path, "old.txt");
assert_eq!(new_path, "new.txt");
assert!(*similarity >= 50, "similarity = {similarity}");
}
#[tokio::test]
async fn list_diff_numstat_flags_png_and_aggregates_text_lines() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("doc.md"), "hi\nthere\n").unwrap();
let base = git_commit_all(repo, "initial");
// doc.md: replace 2 lines with 3 lines → adds=3, dels=2
std::fs::write(repo.join("doc.md"), "alpha\nbeta\ngamma\n").unwrap();
// Minimal PNG header (8-byte signature) + a chunk — git classifies
// this as binary via NUL-byte detection.
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, b'I', b'H',
b'D', b'R',
];
std::fs::write(repo.join("logo.png"), png).unwrap();
let head = git_commit_all(repo, "change");
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let stats = list_diff_numstat(&sandbox, &base, &head).await.unwrap();
assert!(
stats.binary_paths.contains("logo.png"),
"binary_paths: {:?}",
stats.binary_paths
);
assert!(
!stats.binary_paths.contains("doc.md"),
"binary_paths: {:?}",
stats.binary_paths
);
let doc_stats = stats.line_stats_by_path.get("doc.md").unwrap();
assert_eq!(doc_stats.additions, 3, "additions: {stats:?}");
assert_eq!(doc_stats.deletions, 2, "deletions: {stats:?}");
}
#[tokio::test]
async fn stream_blob_metadata_returns_sizes_in_order() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("a.txt"), "aaa\n").unwrap();
std::fs::write(repo.join("b.txt"), "bb\n").unwrap();
git_commit_all(repo, "seed");
let ls = std::process::Command::new("git")
.args(["ls-files", "-s"])
.current_dir(repo)
.output()
.unwrap();
// `ls-files -s` format: "<mode> <sha> <stage>\t<path>"
let mut sha_by_name = std::collections::HashMap::new();
for line in String::from_utf8_lossy(&ls.stdout).lines() {
let mut cols = line.splitn(2, '\t');
let (meta, path) = (cols.next().unwrap(), cols.next().unwrap());
let mut parts = meta.split_whitespace();
let _mode = parts.next();
let sha = parts.next().unwrap();
sha_by_name.insert(path.to_string(), sha.to_string());
}
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["b.txt"].clone()];
let metas = stream_blob_metadata(&sandbox, &shas).await.unwrap();
assert_eq!(metas.len(), 2);
assert_eq!(metas[0].sha, shas[0]);
assert_eq!(metas[0].size, Some(4));
assert_eq!(metas[1].sha, shas[1]);
assert_eq!(metas[1].size, Some(3));
}
#[tokio::test]
async fn stream_blobs_returns_contents_and_respects_size_cap() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("a.txt"), "hello\n").unwrap();
let big = "b".repeat(200);
std::fs::write(repo.join("big.txt"), &big).unwrap();
git_commit_all(repo, "seed");
let ls = std::process::Command::new("git")
.args(["ls-files", "-s"])
.current_dir(repo)
.output()
.unwrap();
let mut sha_by_name = std::collections::HashMap::new();
for line in String::from_utf8_lossy(&ls.stdout).lines() {
let mut cols = line.splitn(2, '\t');
let (meta, path) = (cols.next().unwrap(), cols.next().unwrap());
let mut parts = meta.split_whitespace();
let _mode = parts.next();
let sha = parts.next().unwrap();
sha_by_name.insert(path.to_string(), sha.to_string());
}
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["big.txt"].clone()];
// size_cap = 100 bytes — "hello\n" (6) stays, 200-byte blob truncates.
let contents = stream_blobs(&sandbox, &shas, 100).await.unwrap();
assert_eq!(contents.len(), 2);
assert_eq!(contents[0].as_deref(), Some("hello\n"));
assert!(contents[1].is_none(), "oversize blob should be None");
}
#[tokio::test]
async fn list_changed_files_raw_bad_revision_is_permanent() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("x"), "x").unwrap();
git_commit_all(repo, "seed");
let sandbox = fabro_sandbox::local_sandbox(repo.to_path_buf())
.await
.unwrap();
let err =
list_changed_files_raw(&sandbox, "0000000000000000000000000000000000000000", "HEAD")
.await
.expect_err("expected error for unknown base sha");
assert!(matches!(err, DiffError::Permanent { .. }), "err: {err:?}");
}
#[test]
fn extract_new_path_from_numstat_handles_brace_renames() {
assert_eq!(extract_new_path_from_numstat("simple/path"), "simple/path");
assert_eq!(
extract_new_path_from_numstat("old.txt => new.txt"),
"new.txt"
);
assert_eq!(
extract_new_path_from_numstat("src/{old => new}/file.rs"),
"src/new/file.rs"
);
}
}