mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-19 00:03:30 +00:00
Sandbox exec logs previously required command_len fingerprinting to tell a
push from a credential refresh or a checkpoint commit. The shared git
helpers now instrument their futures with a git_op span, so Daytona's and
Docker's `exec_command: entered` lines inherit the operation label and the
log renders as `git_op{op=push}: exec_command: entered timeout_ms=...`.
Ops: push (git_push_via_exec), refresh-credentials (both providers'
refresh_push_credentials), checkpoint-commit (checked_git_checkpoint),
fetch (fetch_source_run_ref), and metadata-push (the run-metadata snapshot
write). Spans are attached with #[tracing::instrument] — attached to the
future, never an entered() guard held across an await — so they follow the
task across worker threads. No trait or signature changes.
Plan: .ai/plans/git-push-token-resilience.md (PR 3: item 10).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1621 lines
54 KiB
Rust
1621 lines
54 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use fabro_agent::Sandbox;
|
|
use fabro_checkpoint::trailer as trailerlink;
|
|
use fabro_checkpoint::trailer::Trailer;
|
|
use fabro_sandbox::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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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: &dyn Sandbox,
|
|
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 std::collections::VecDeque;
|
|
use std::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
use fabro_agent::{DirEntry, ExecResult, GrepOptions};
|
|
use fabro_types::CommandTermination;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use super::*;
|
|
|
|
struct ScriptedSandbox {
|
|
exec_results: Mutex<VecDeque<ExecResult>>,
|
|
commands: Mutex<Vec<String>>,
|
|
timeouts: Mutex<Vec<u64>>,
|
|
write_paths: Mutex<Vec<String>>,
|
|
delete_paths: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl ScriptedSandbox {
|
|
fn new(exec_results: Vec<ExecResult>) -> Self {
|
|
Self {
|
|
exec_results: Mutex::new(exec_results.into()),
|
|
commands: Mutex::new(Vec::new()),
|
|
timeouts: Mutex::new(Vec::new()),
|
|
write_paths: Mutex::new(Vec::new()),
|
|
delete_paths: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn commands(&self) -> Vec<String> {
|
|
self.commands
|
|
.lock()
|
|
.expect("commands lock poisoned")
|
|
.clone()
|
|
}
|
|
|
|
fn timeouts(&self) -> Vec<u64> {
|
|
self.timeouts
|
|
.lock()
|
|
.expect("timeouts lock poisoned")
|
|
.clone()
|
|
}
|
|
|
|
fn write_paths(&self) -> Vec<String> {
|
|
self.write_paths
|
|
.lock()
|
|
.expect("write_paths lock poisoned")
|
|
.clone()
|
|
}
|
|
|
|
fn delete_paths(&self) -> Vec<String> {
|
|
self.delete_paths
|
|
.lock()
|
|
.expect("delete_paths lock poisoned")
|
|
.clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Sandbox for ScriptedSandbox {
|
|
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
|
|
Err("read_file not implemented for ScriptedSandbox".into())
|
|
}
|
|
|
|
async fn write_file(&self, path: &str, _content: &str) -> fabro_sandbox::Result<()> {
|
|
self.write_paths
|
|
.lock()
|
|
.expect("write_paths lock poisoned")
|
|
.push(path.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_file(&self, path: &str) -> fabro_sandbox::Result<()> {
|
|
self.delete_paths
|
|
.lock()
|
|
.expect("delete_paths lock poisoned")
|
|
.push(path.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
|
Ok(false)
|
|
}
|
|
|
|
async fn list_directory(
|
|
&self,
|
|
_path: &str,
|
|
_depth: Option<usize>,
|
|
) -> fabro_sandbox::Result<Vec<DirEntry>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn exec_command(
|
|
&self,
|
|
command: &str,
|
|
timeout_ms: u64,
|
|
_working_dir: Option<&str>,
|
|
_env_vars: Option<&std::collections::HashMap<String, String>>,
|
|
_cancel_token: Option<CancellationToken>,
|
|
) -> fabro_sandbox::Result<ExecResult> {
|
|
self.commands
|
|
.lock()
|
|
.expect("commands lock poisoned")
|
|
.push(command.to_string());
|
|
self.timeouts
|
|
.lock()
|
|
.expect("timeouts lock poisoned")
|
|
.push(timeout_ms);
|
|
self.exec_results
|
|
.lock()
|
|
.expect("exec_results lock poisoned")
|
|
.pop_front()
|
|
.ok_or_else(|| fabro_sandbox::Error::message("unexpected exec_command call"))
|
|
}
|
|
|
|
async fn grep(
|
|
&self,
|
|
_pattern: &str,
|
|
_path: &str,
|
|
_options: &GrepOptions,
|
|
) -> fabro_sandbox::Result<Vec<String>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn glob(
|
|
&self,
|
|
_pattern: &str,
|
|
_path: Option<&str>,
|
|
) -> fabro_sandbox::Result<Vec<String>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn download_file_to_local(
|
|
&self,
|
|
_remote_path: &str,
|
|
_local_path: &std::path::Path,
|
|
) -> fabro_sandbox::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn upload_file_from_local(
|
|
&self,
|
|
_local_path: &std::path::Path,
|
|
_remote_path: &str,
|
|
) -> fabro_sandbox::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn initialize(&self) -> fabro_sandbox::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn working_directory(&self) -> &str {
|
|
"/work"
|
|
}
|
|
|
|
fn platform(&self) -> &str {
|
|
"darwin"
|
|
}
|
|
|
|
fn os_version(&self) -> String {
|
|
"Darwin".to_string()
|
|
}
|
|
}
|
|
|
|
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 = ScriptedSandbox::new(vec![exec_timed_out(77)]);
|
|
let err = git_checkpoint(
|
|
&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 = ScriptedSandbox::new(vec![exec_failed(127, "", "git missing\n")]);
|
|
let runtime = crate::sandbox_git_runtime::SandboxGitRuntime::new();
|
|
|
|
let err = checked_git_checkpoint(
|
|
&runtime,
|
|
&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 = ScriptedSandbox::new(vec![exec_ok(), exec_timed_out(88)]);
|
|
let err = git_checkpoint(
|
|
&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 = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_failed(-1, "", "")]);
|
|
let err = git_checkpoint(
|
|
&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 = ScriptedSandbox::new(vec![
|
|
exec_ok(),
|
|
exec_ok(),
|
|
exec_ok(),
|
|
exec_ok(),
|
|
exec_ok(),
|
|
exec_ok(),
|
|
]);
|
|
let author = crate::git::GitAuthor::default();
|
|
|
|
let first = git_checkpoint(
|
|
&sandbox,
|
|
"run1",
|
|
"work",
|
|
"success",
|
|
1,
|
|
None,
|
|
&RunCheckpointSettings::default(),
|
|
&author,
|
|
)
|
|
.await;
|
|
let second = git_checkpoint(
|
|
&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 = sandbox.write_paths();
|
|
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.delete_paths();
|
|
assert_eq!(delete_paths, write_paths);
|
|
|
|
let commands = sandbox.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 = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
|
|
let checkpoint = RunCheckpointSettings {
|
|
commit_timeout_ms: 600_000,
|
|
..RunCheckpointSettings::default()
|
|
};
|
|
git_checkpoint(
|
|
&sandbox,
|
|
"run1",
|
|
"work",
|
|
"success",
|
|
1,
|
|
None,
|
|
&checkpoint,
|
|
&crate::git::GitAuthor::default(),
|
|
)
|
|
.await
|
|
.expect("checkpoint should succeed");
|
|
|
|
assert_eq!(sandbox.timeouts(), vec![600_000, 600_000, 10_000]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn git_diff_reports_timeout() {
|
|
let sandbox = ScriptedSandbox::new(vec![exec_timed_out(99)]);
|
|
let err = git_diff_with_timeout(&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 = ScriptedSandbox::new(vec![exec_failed(128, "", "fatal: bad revision\n")]);
|
|
let err = git_diff_with_timeout(&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 = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
|
|
let checkpoint = RunCheckpointSettings {
|
|
skip_git_hooks: true,
|
|
..RunCheckpointSettings::default()
|
|
};
|
|
git_checkpoint(
|
|
&sandbox,
|
|
"run1",
|
|
"work",
|
|
"success",
|
|
1,
|
|
None,
|
|
&checkpoint,
|
|
&crate::git::GitAuthor::default(),
|
|
)
|
|
.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,
|
|
&RunCheckpointSettings::default(),
|
|
&crate::git::GitAuthor::default(),
|
|
)
|
|
.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
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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_agent::LocalSandbox::new(repo.to_path_buf());
|
|
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"
|
|
);
|
|
}
|
|
}
|