fabro/lib/components/fabro-workflow/src/sandbox_git.rs
Bryan Helmkamp e5d5c534ab
Merge remote-tracking branch 'origin/main' into remove/run-metadata-branches
Resolve conflicts between the metadata-branch removal and the
sandbox-driver adoption on main:

- fabro-sandbox docker.rs, sandbox.rs, daytona/mod.rs: take main's driver
  rewrite. The Sandbox trait is gone, so the PR's push_token_source
  removal now applies to RunSandbox instead; drop that accessor and the
  RepoCredentials::source helper that only served it.
- run_metadata.rs: keep deleted. Main's edits there were adaptations to
  the driver API and the run git identity field.
- lifecycle/git.rs, finalize.rs: keep the PR's removal of metadata
  snapshots and write_finalize_commit; carry main's RunSandbox,
  GitRetryPolicy, git_identity, local_sandbox, and test catalog changes.
- sandbox_git.rs: take main's version and drop the shadow_sha parameter
  and Fabro-Checkpoint trailer.
- git_integration.rs: remove meta_branch from the new git identity test.
- Cargo.toml: main's dependency set with fabro-dump kept as a
  dev-dependency.
- checkpoints.mdx: keep both the git identity paragraph and the durable
  execution state section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 16:28:01 -06:00

1081 lines
38 KiB
Rust

//! Fabro's git operations on a run's sandbox, over the driver's git facet.
//!
//! The driver runs every command hardened (no auto maintenance or gc, no
//! repository hooks, no fsmonitor, unquoted paths, no signing; read verbs
//! refuse the file transport and external diff drivers) and returns typed
//! results. Fabro decides what to stage, what to say in a checkpoint
//! commit, and which ranges the Run Files endpoint reads.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use fabro_checkpoint::trailer as trailerlink;
use fabro_checkpoint::trailer::Trailer;
use fabro_sandbox::RunSandbox;
use fabro_types::settings::run::RunCheckpointSettings;
use fabro_util::error::SharedError;
use sandbox_driver::{
Git as _, GitChange, GitCommitOptions, GitDiffEntry, GitDiffOptions, GitFacet, GitFailureKind,
GitRevisionRange,
};
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,
}
/// Rename detection threshold for the diffs the Run Files endpoint and the
/// checkpoint summaries read.
const FIND_RENAMES_PERCENT: u8 = 50;
/// Budget for the machine-readable diffs behind the Run Files endpoint.
const RUN_FILES_TIMEOUT: Duration = Duration::from_secs(10);
/// The sandbox's git facet, or the error a git operation reports when the
/// provider has none.
fn facet<'a>(sandbox: &'a RunSandbox, label: &str) -> Result<GitFacet<'a>, GitCommandError> {
sandbox.git().map_err(|source| GitCommandError {
message: format!("{label} failed"),
source,
})
}
fn git_error(label: &str, error: sandbox_driver::Error) -> GitCommandError {
GitCommandError {
message: format!("{label} failed"),
source: fabro_sandbox::Error::from(error),
}
}
/// Commit the run's checkpoint: everything under the working directory
/// except the built-in and configured excludes, as an allow-empty commit
/// carrying fabro's trailers. Repository hooks never run: the driver
/// disables them on every command it issues.
pub async fn git_checkpoint(
sandbox: &RunSandbox,
run_id: &str,
node_id: &str,
status: &str,
completed_count: usize,
checkpoint: &RunCheckpointSettings,
author: &GitAuthor,
) -> std::result::Result<String, GitCommandError> {
let git = facet(sandbox, "git add")?;
let repo = sandbox.working_directory();
let mut pathspecs = vec![".".to_owned()];
pathspecs.extend(
artifact_snapshot::EXCLUDE_DIRS
.iter()
.map(|dir| format!(":(glob,exclude)**/{dir}/**")),
);
pathspecs.extend(
checkpoint
.exclude_globs
.iter()
.map(|glob| format!(":(glob,exclude){glob}")),
);
git.add_all(repo, &pathspecs)
.await
.map_err(|error| git_error("git add", error))?;
let subject = format!("fabro({run_id}): {node_id} ({status})");
let completed_str = completed_count.to_string();
let trailers = vec![
Trailer {
key: "Fabro-Run",
value: run_id,
},
Trailer {
key: "Fabro-Completed",
value: &completed_str,
},
];
let mut message = trailerlink::format_message(&subject, "", &trailers);
author.append_footer(&mut message);
let mut options = GitCommitOptions::new(message, &author.name, &author.email);
options.allow_empty = true;
git.commit(repo, &options)
.await
.map_err(|error| git_error("git commit", error))
}
/// 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,
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,
checkpoint,
author,
)
.await
.map_err(|err| SharedError::new(anyhow::Error::new(err)))
}
/// The unified diff from `base` to `HEAD` (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
}
/// The unified diff from `base` to `HEAD` under 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). Paths come
/// back unquoted, which the Run Files denylist parser relies on.
pub(crate) async fn git_diff_with_timeout(
sandbox: &RunSandbox,
base: &str,
timeout_ms: u64,
) -> std::result::Result<String, GitCommandError> {
let git = facet(sandbox, "git diff")?;
let options = GitDiffOptions::new(GitRevisionRange::new(base).to("HEAD"))
.timeout(Duration::from_millis(timeout_ms));
git.diff_patch(sandbox.working_directory(), &options)
.await
.map_err(|error| git_error("git diff", error))
}
// ── Machine-readable diff enumeration (Run Files endpoint) ─────────────────
/// A single changed-file entry of a range, as the Run Files endpoint reads
/// it.
///
/// Paths are repo-relative, UTF-8. Blob SHAs are lowercase hex. Modes are
/// octal strings (`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 creation, deletion, or target change. No blob contents are
/// fetched for these: the "content" is the link target, which is
/// not meaningful to diff as file text.
Symlink {
path: String,
change_kind: SymlinkChange,
old_blob: Option<String>,
new_blob: Option<String>,
},
/// Submodule (gitlink) pointer change. No blob contents exist for
/// these in the parent repo.
Submodule {
path: String,
change_kind: SubmoduleChange,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SymlinkChange {
Added,
Deleted,
Modified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmoduleChange {
Added,
Deleted,
Modified,
}
/// Errors from the machine-readable diff paths, classified so the server
/// can fall back or retry.
#[derive(Debug, thiserror::Error)]
pub enum DiffError {
/// Unknown revision, missing object, or a repository the driver could
/// not read: retrying will not help.
#[error("permanent git error: {message}")]
Permanent { message: String },
/// A timeout, a transport failure, or any other failure worth retrying.
#[error("transient git error: {message}")]
Transient { message: String },
}
/// Blob metadata from a batch lookup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobMeta {
pub sha: String,
/// `None` when git reports the blob as missing.
pub size: Option<u64>,
}
/// Enumerate files changed between `base_sha` and `to_sha` via the sandbox.
///
/// Paths from this listing are treated as metadata only; blob reads use
/// the SHAs, not the paths. The `--numstat` companion classifies text vs
/// binary so callers can skip binary contents without ever fetching them.
pub async fn list_changed_files_raw(
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<Vec<RawDiffEntry>, DiffError> {
let git = diff_facet(sandbox)?;
let options = GitDiffOptions::new(GitRevisionRange::new(base_sha).to(to_sha))
.find_renames(FIND_RENAMES_PERCENT)
.timeout(RUN_FILES_TIMEOUT);
let entries = git
.diff_entries(sandbox.working_directory(), &options)
.await
.map_err(|error| diff_error(&error))?;
entries
.into_iter()
.map(raw_diff_entry)
.collect::<std::result::Result<Vec<_>, String>>()
.map_err(|message| DiffError::Permanent { message })
}
fn diff_facet(sandbox: &RunSandbox) -> std::result::Result<GitFacet<'_>, DiffError> {
sandbox.git().map_err(|error| DiffError::Permanent {
message: fabro_sandbox::display_for_log(&error),
})
}
/// What a driver failure means for the Run Files endpoint: an unknown
/// revision or missing object is permanent (the handler falls through to
/// the stored patch), and so is output the driver could not read, since a
/// retry reads the same object; a timeout, a transport failure, or anything
/// else is transient and surfaces as a 503 for the client to retry.
fn diff_error(error: &sandbox_driver::Error) -> DiffError {
let message = fabro_sandbox::display_for_log(error);
match error {
sandbox_driver::Error::Io { .. } => DiffError::Permanent { message },
sandbox_driver::Error::Git(failure) => {
let stderr = failure
.output()
.map(|output| String::from_utf8_lossy(output.stderr()).into_owned())
.unwrap_or_default();
if failure.kind() == GitFailureKind::RefNotFound || is_permanent_git_error(&stderr) {
DiffError::Permanent { message }
} else {
DiffError::Transient { message }
}
}
_ => DiffError::Transient { 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("bad object")
|| lower.contains("invalid revision")
|| lower.contains("no such path")
|| lower.contains("not a valid object name")
}
/// The Run Files entry for one path of the driver's diff. Mode 120000 is a
/// symlink, 160000 a submodule.
fn raw_diff_entry(entry: GitDiffEntry) -> std::result::Result<RawDiffEntry, String> {
let is_mode = |mode: &Option<String>, expected: &str| mode.as_deref() == Some(expected);
let is_symlink = is_mode(&entry.old_mode, "120000") || is_mode(&entry.new_mode, "120000");
let is_submodule = is_mode(&entry.old_mode, "160000") || is_mode(&entry.new_mode, "160000");
let path = entry.path;
let old_blob = entry.old_blob.unwrap_or_default();
let new_blob = entry.new_blob.unwrap_or_default();
let old_mode = entry.old_mode.unwrap_or_default();
let new_mode = entry.new_mode.unwrap_or_default();
Ok(match (entry.change, is_symlink, is_submodule) {
(GitChange::Renamed | GitChange::Copied, _, _) => RawDiffEntry::Renamed {
old_path: entry.old_path.unwrap_or_default(),
new_path: path,
old_blob,
new_blob,
new_mode,
similarity: entry.similarity.unwrap_or(0),
},
(GitChange::Added, true, _) => RawDiffEntry::Symlink {
path,
change_kind: SymlinkChange::Added,
old_blob: None,
new_blob: Some(new_blob),
},
(GitChange::Added, _, true) => RawDiffEntry::Submodule {
path,
change_kind: SubmoduleChange::Added,
},
(GitChange::Added, _, _) => RawDiffEntry::Added {
path,
new_blob,
new_mode,
},
(GitChange::Deleted, true, _) => RawDiffEntry::Symlink {
path,
change_kind: SymlinkChange::Deleted,
old_blob: Some(old_blob),
new_blob: None,
},
(GitChange::Deleted, _, true) => RawDiffEntry::Submodule {
path,
change_kind: SubmoduleChange::Deleted,
},
(GitChange::Deleted, _, _) => RawDiffEntry::Deleted {
path,
old_blob,
old_mode,
},
(GitChange::Modified | GitChange::TypeChanged, true, _) => RawDiffEntry::Symlink {
path,
change_kind: SymlinkChange::Modified,
old_blob: Some(old_blob),
new_blob: Some(new_blob),
},
(GitChange::Modified | GitChange::TypeChanged, _, true) => RawDiffEntry::Submodule {
path,
change_kind: SubmoduleChange::Modified,
},
(GitChange::Modified | GitChange::TypeChanged, _, _) => RawDiffEntry::Modified {
path,
old_blob,
new_blob,
new_mode,
},
(other, _, _) => {
return Err(format!("unknown diff status {other:?} for {path:?}"));
}
})
}
pub use fabro_types::{DiffStats, DiffSummary};
/// What `git diff --numstat` says about a range: which paths are binary,
/// plus per-path `+/-` line totals for text files.
#[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,
}
}
/// The numstat of `base_sha..to_sha`: the set of binary paths and the
/// text-file `+/-` totals, from one driver call.
pub async fn list_diff_numstat(
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<DiffNumstat, DiffError> {
let git = diff_facet(sandbox)?;
let options = GitDiffOptions::new(GitRevisionRange::new(base_sha).to(to_sha))
.find_renames(FIND_RENAMES_PERCENT)
.timeout(RUN_FILES_TIMEOUT);
let rows = git
.diff_numstat(sandbox.working_directory(), &options)
.await
.map_err(|error| diff_error(&error))?;
let mut out = DiffNumstat::default();
for row in rows {
match (row.additions, row.deletions) {
(Some(additions), Some(deletions)) => {
out.line_stats_by_path.insert(row.path, DiffStats {
additions: i64::try_from(additions).unwrap_or(i64::MAX),
deletions: i64::try_from(deletions).unwrap_or(i64::MAX),
});
}
_ => {
out.binary_paths.insert(row.path);
}
}
}
Ok(out)
}
/// Blob sizes for many SHAs in one driver call, in the order of `shas`.
/// A blob git does not have yields `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 git = diff_facet(sandbox)?;
let sizes = git
.blob_sizes(sandbox.working_directory(), shas)
.await
.map_err(|error| diff_error(&error))?;
Ok(shas
.iter()
.zip(sizes)
.map(|(sha, size)| BlobMeta {
sha: sha.clone(),
size,
})
.collect())
}
/// Blob contents for many SHAs in one driver call, in the order of `shas`.
///
/// Contents are size-capped per blob: any blob exceeding `size_cap_bytes`
/// returns `None` in its slot (the caller flags that entry as truncated),
/// as does a blob git does not have or one that is not UTF-8. Callers are
/// expected to have pre-filtered binary blobs via [`list_diff_numstat`].
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 git = diff_facet(sandbox)?;
let blobs = git
.blobs(sandbox.working_directory(), shas, size_cap_bytes)
.await
.map_err(|error| diff_error(&error))?;
Ok(blobs
.into_iter()
.map(|blob| blob.and_then(|bytes| String::from_utf8(bytes).ok()))
.collect())
}
#[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::test_support::{MockSandbox, exec_result};
use fabro_sandbox::{ExecResult, Termination};
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.driver().scripted_exec().push_result(result.clone());
}
sandbox
}
fn exec_ok() -> ExecResult {
exec_result("", "", Some(0), Termination::Exited, 1)
}
fn exec_timed_out(duration_ms: u64) -> ExecResult {
exec_result("", "", None, Termination::TimedOut, duration_ms)
}
fn exec_failed(exit_code: i32, stdout: &str, stderr: &str) -> ExecResult {
exec_result(stdout, stderr, Some(exit_code), Termination::Exited, 1)
}
#[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,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git add failed");
let timed_out = matches!(
err.source.driver(),
Some(sandbox_driver::Error::Git(failure))
if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut)
);
assert!(timed_out, "{}", fabro_sandbox::display_for_log(&err));
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,
&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,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git commit failed");
}
#[tokio::test]
async fn git_checkpoint_reports_a_failed_sha_read_as_the_commit_failing() {
// add, commit, then the driver's own rev-parse of the new HEAD.
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_failed(-1, "", "")]);
let err = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
&RunCheckpointSettings::default(),
&crate::git::GitAuthor::default(),
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "git commit failed");
}
/// The commit message and author travel in the driver's own commit
/// command, and repository hooks never run: the driver disables them
/// whatever the checkpoint settings say.
#[tokio::test]
async fn git_checkpoint_commits_through_the_hardened_driver_command() {
let mut sha = exec_ok();
sha.stdout = b"abc123\n".to_vec();
let sandbox = scripted(&[exec_ok(), exec_ok(), sha]);
let checkpoint = RunCheckpointSettings {
skip_git_hooks: false,
..RunCheckpointSettings::default()
};
let author = crate::git::GitAuthor::default();
let sha = git_checkpoint(
&sandbox.sandbox(),
"run1",
"work",
"success",
1,
&checkpoint,
&author,
)
.await
.expect("checkpoint succeeds");
assert_eq!(sha, "abc123");
let commands = sandbox.driver().scripted_exec().commands();
let add = commands
.iter()
.find(|command| command.contains("'add' '-A'"))
.expect("the add ran");
assert!(
add.contains(":(glob,exclude)**/node_modules/**"),
"built-in excludes are pathspecs: {add}"
);
let commit = commands
.iter()
.find(|command| command.contains("'commit'"))
.expect("the commit ran");
assert!(commit.contains("core.hooksPath=/dev/null"), "{commit}");
assert!(commit.contains("commit.gpgsign=false"), "{commit}");
assert!(commit.contains("'--allow-empty'"), "{commit}");
assert!(
commit.contains("fabro(run1): work (success)")
&& commit.contains("Fabro-Run: run1")
&& !commit.contains("Fabro-Checkpoint"),
"{commit}"
);
assert!(
commit.contains(&format!("user.name={}", author.name)),
"{commit}"
);
assert!(
sandbox.written_files().is_empty(),
"no message file is written"
);
}
#[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 failed");
let timed_out = matches!(
err.source.driver(),
Some(sandbox_driver::Error::Git(failure))
if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut)
);
assert!(timed_out, "{}", fabro_sandbox::display_for_log(&err));
}
#[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");
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_diff_passes_the_range_and_timeout_to_the_driver() {
let mut patch = exec_ok();
patch.stdout = b"diff --git a/x b/x\n".to_vec();
let sandbox = scripted(&[patch]);
let diff = git_diff_with_timeout(&sandbox.sandbox(), "base-sha", 5_000)
.await
.expect("diff succeeds");
assert_eq!(diff, "diff --git a/x b/x\n");
let commands = sandbox.driver().scripted_exec().commands();
assert!(
commands[0].contains("'diff'") && commands[0].contains("'base-sha..HEAD'"),
"{}",
commands[0]
);
assert_eq!(sandbox.captured_timeouts(), vec![5_000]);
}
#[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,
&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 status_stdout = status.stdout_lossy();
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:?}");
}
}