Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions app/src/ai/agent_sdk/driver/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,14 +393,14 @@ clone_repo() {
printf '%s\n' "Repository directory $target already exists, skipping clone..."
else
printf '%s\n' "Cloning repository $repo_name..."
git clone --filter=tree:0 "$repo_url" "$target" || return 1
git clone --filter=blob:none "$repo_url" "$target" || return 1
fi
# Pin after clone or reuse: a reused directory may still be on an old ref.
if [ -n "$checkout_ref" ]; then
printf '%s\n' "Checking out $checkout_ref in $repo_name..."
# Fetch leaves the object in FETCH_HEAD; check that out detached so we
# never prefer a stale local branch with the same name.
git -C "$target" fetch --filter=tree:0 origin "$checkout_ref" && git -C "$target" checkout --detach FETCH_HEAD
git -C "$target" fetch --filter=blob:none origin "$checkout_ref" && git -C "$target" checkout --detach FETCH_HEAD
fi
}
"#,
Expand Down Expand Up @@ -519,8 +519,10 @@ pub(super) async fn clone_repo(
.await
.unwrap_or(ShellType::Bash);
let escaped_url = shell_escape_single_quotes(&repo_url, shell_type);
// We do a partial clone here to speed up environment setup time.
let command = format!("git clone --filter=tree:0 '{escaped_url}'");
// We do a blobless partial clone here to speed up environment setup time
// while still keeping trees local, so path-limited history and blame stay
// fully local instead of lazily refetching from the promisor remote.
let command = format!("git clone --filter=blob:none '{escaped_url}'");

let repo_dir = working_dir.join(&repo.repo);
// Always ask the session whether the repo dir already exists, rather
Expand Down Expand Up @@ -557,8 +559,9 @@ pub(super) async fn clone_repo(
}

// Pin after clone or reuse when a ref was requested. A reused directory may
// still be on an old default-branch tip, and a fresh partial clone only
// fetched the default branch — fetch the ref, then detach to FETCH_HEAD.
// still be on an old default-branch tip, and a checkout_ref (SHA, branch,
// or tag) may not have existed yet, or may have moved, by the time the
// clone ran — fetch the ref, then detach to FETCH_HEAD.
// When checkout_ref is unset, leave an existing directory untouched.
if let Some(command) = checkout_command_for(repo, working_dir, shell_type) {
let checkout_ref = repo.checkout_ref.as_deref().unwrap_or_default();
Expand All @@ -581,12 +584,11 @@ pub(super) async fn clone_repo(
/// Build the `git fetch` + `git checkout` command that pins `repo`'s clone at
/// its `checkout_ref`, or `None` when the repo has no ref to pin.
///
/// A partial clone (`--filter=tree:0`) only fetches the default branch, so an
/// arbitrary ref (commit SHA, branch, or tag) may not be present yet: fetch it
/// first, then check out the resulting `FETCH_HEAD` detached. Checking out the
/// original ref name can prefer a stale local branch or fail when the object
/// only landed in `FETCH_HEAD`. Detached HEAD is expected and fine — trials
/// never merge.
/// The requested ref (commit SHA, branch, or tag) may not have existed yet,
/// or may have moved, by the time the clone ran: fetch it first, then check
/// out the resulting `FETCH_HEAD` detached. Checking out the original ref
/// name can prefer a stale local branch or fail when the object only landed
/// in `FETCH_HEAD`. Detached HEAD is expected and fine — trials never merge.
fn checkout_command_for(
repo: &SourceRepo,
working_dir: &Path,
Expand All @@ -597,7 +599,7 @@ fn checkout_command_for(
let escaped_dir = shell_escape_single_quotes(&repo_dir.to_string_lossy(), shell_type);
let escaped_ref = shell_escape_single_quotes(checkout_ref, shell_type);
Some(format!(
"git -C '{escaped_dir}' fetch --filter=tree:0 origin '{escaped_ref}' && \
"git -C '{escaped_dir}' fetch --filter=blob:none origin '{escaped_ref}' && \
git -C '{escaped_dir}' checkout --detach FETCH_HEAD"
))
}
Expand Down
82 changes: 75 additions & 7 deletions app/src/ai/agent_sdk/driver/environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,16 @@ fn parallel_clone_command_threads_checkout_ref_and_pins_after_clone() {
// The shell function fetches then checks out FETCH_HEAD only when a ref is set.
assert!(command.contains("checkout_ref=\"$4\""));
assert!(command.contains("if [ -n \"$checkout_ref\" ]; then"));
assert!(command.contains("git -C \"$target\" fetch --filter=tree:0 origin \"$checkout_ref\""));
assert!(
command.contains("git -C \"$target\" fetch --filter=blob:none origin \"$checkout_ref\"")
);
assert!(command.contains("git -C \"$target\" checkout --detach FETCH_HEAD"));
// Pinning must not be nested under the "directory missing" branch — reused
// target directories still need fetch/checkout when a ref is set.
assert!(command.contains("already exists, skipping clone..."));
assert!(!command.contains("already exists, skipping clone...\n return 0"));
// A clone failure must short-circuit before any checkout attempt.
assert!(command.contains("git clone --filter=tree:0 \"$repo_url\" \"$target\" || return 1"));
assert!(command.contains("git clone --filter=blob:none \"$repo_url\" \"$target\" || return 1"));
// The pinned repo's ref is threaded into the command (as the 4th positional
// arg to clone_repo). The whole script is single-quote-escaped for `sh -c`,
// so assert on the ref content rather than the surrounding quoting.
Expand All @@ -197,7 +199,7 @@ fn checkout_command_checks_out_fetch_head_not_ref_name() {
let command =
checkout_command_for(&repo, Path::new("/tmp/work"), ShellType::Bash).expect("ref set");

assert!(command.contains("fetch --filter=tree:0 origin 'abc123'"));
assert!(command.contains("fetch --filter=blob:none origin 'abc123'"));
assert!(command.contains("checkout --detach FETCH_HEAD"));
// Must not check out the original ref name after fetch (stale local branch
// risk / FETCH_HEAD-only objects).
Expand Down Expand Up @@ -336,7 +338,7 @@ fn partial_clone(fixture: &Fixture) -> PathBuf {
git(
&[
"clone",
"--filter=tree:0",
"--filter=blob:none",
&fixture.origin_url,
repo_dir.to_str().unwrap(),
],
Expand Down Expand Up @@ -416,9 +418,9 @@ fn checkout_command_pins_head_to_commit_absent_from_default_branch() {
let fixture = build_fixture();
let repo_dir = partial_clone(&fixture);

// The partial clone (`--filter=tree:0`) only fetched `main`, so the pinned
// commit — which lives off the default branch — requires the fetch step
// baked into the command before it can be checked out.
// The partial clone (`--filter=blob:none`) only fetched `main`, so the
// pinned commit — which lives off the default branch — requires the fetch
// step baked into the command before it can be checked out.
let repo = SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
Expand Down Expand Up @@ -615,6 +617,72 @@ fn no_checkout_ref_leaves_clone_on_default_branch() {
);
}

/// Regression test for APP-5509: a `--filter=blob:none` clone must carry
/// enough tree data that a path-limited `git log` never reaches the network.
/// A regression to `--filter=tree:0` would instead try to lazily fetch a
/// tree per commit walked, so repointing `origin` at an unreachable URL
/// after the clone turns that regression into a fast failure here instead of
/// the hang seen in production.
#[test]
fn blobless_clone_walks_path_limited_history_without_network() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();

let origin = root.join("origin.git");
fs::create_dir_all(&origin).unwrap();
git(&["init", "-b", "main", "--bare", "."], &origin);
git(&["config", "uploadpack.allowFilter", "true"], &origin);
let origin_url = format!("file://{}", origin.display());

let seed = root.join("seed");
fs::create_dir_all(&seed).unwrap();
git(&["init", "-b", "main", "."], &seed);
git(&["remote", "add", "origin", &origin_url], &seed);
for (contents, message) in [("one\n", "first"), ("one\ntwo\n", "second")] {
fs::write(seed.join("notes.md"), contents).unwrap();
git(&["add", "."], &seed);
git(&["commit", "-m", message], &seed);
}
git(&["push", "origin", "main"], &seed);

let repo_dir = root.join("clone");
git(
&[
"clone",
"--filter=blob:none",
&origin_url,
repo_dir.to_str().unwrap(),
],
root,
);

// Simulate the origin becoming unreachable after the clone (e.g. the
// sandbox network being torn down), so a lazy tree fetch fails fast
// instead of hanging.
git(
&[
"remote",
"set-url",
"origin",
"https://127.0.0.1:1/unreachable.git",
],
&repo_dir,
);

let output = Command::new("git")
.args(["--no-pager", "log", "--oneline", "--", "notes.md"])
.current_dir(&repo_dir)
.output()
.expect("git should be runnable");
assert!(
output.status.success(),
"path-limited git log must stay local on a blobless clone: {}",
String::from_utf8_lossy(&output.stderr)
);
let commit_count = String::from_utf8(output.stdout).unwrap().lines().count();
assert_eq!(commit_count, 2, "expected both commits touching notes.md");
}

#[test]
fn factory_clone_is_prepended_when_clone_values_are_present() {
let mut setup_commands = vec!["make setup".to_string()];
Expand Down
Loading