fabro/lib/crates/fabro-sandbox/src/worktree.rs
Bryan Helmkamp 234bd5663e
Add ACP backend support (#237)
## Summary
Implemented ACP support as a first-class Fabro backend alongside `api`
and `cli`. This adds a new `fabro-acp` crate using the official ACP Rust
crates, routes `backend=\"acp\"` for agent and prompt nodes, adds
sandbox stdio support for local/Docker/test-support paths, emits ACP
workflow events/projections, updates server steerability handling,
validation, documentation, and black-box CLI coverage.

## Test Plan
Passed strict non-live verification:
- `ulimit -n 4096 && cargo nextest run -p fabro-workflow --run-ignored
all --no-fail-fast` — 1162 passed, 0 skipped.
- `ulimit -n 4096 && cargo nextest run -p fabro-acp -p fabro-sandbox -p
fabro-workflow -p fabro-validate -p fabro-store -p fabro-server -p
fabro-cli --run-ignored all --no-fail-fast -E 'not
test(daytona_streaming_live_smoke)'` — 3125 passed.
- `cargo build --workspace` — passed.
- `ulimit -n 4096 && cargo nextest run --workspace --run-ignored all
--no-fail-fast -E 'not test(daytona_streaming_live_smoke)'` — 5666
passed.
- `cargo +nightly-2026-04-14 fmt --check --all` — passed.
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings` — passed.

Live-environment tests skipped/excluded under explicit user override:
- `daytona_streaming_live_smoke` was excluded from final nextest runs
because it requires live Daytona infrastructure and `DAYTONA_API_KEY`.
- Confirmed with `env -u DAYTONA_API_KEY cargo test -p fabro-sandbox
--features daytona --test daytona_streaming_live
daytona_streaming_live::daytona_streaming_live_smoke -- --ignored
--exact --nocapture`: failed fast with `DAYTONA_API_KEY must be set to
run this live smoke test`.
2026-05-11 23:39:43 -04:00

913 lines
32 KiB
Rust

use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use tokio_util::sync::CancellationToken;
use crate::sandbox::fetch_source_run_ref;
use crate::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GitRunInfo, GitSetupIntent,
GrepOptions, Sandbox, StdioProcess, shell_quote,
};
/// Git command prefix that disables background maintenance.
const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// Events emitted during worktree lifecycle operations.
pub enum WorktreeEvent {
BranchCreated { branch: String, sha: String },
WorktreeAdded { path: String, branch: String },
WorktreeRemoved { path: String },
}
/// Callback type for worktree lifecycle events.
pub type WorktreeEventCallback = Arc<dyn Fn(WorktreeEvent) + Send + Sync>;
/// Configuration for a `WorktreeSandbox`.
pub struct WorktreeOptions {
pub branch_name: String,
pub base_sha: String,
pub worktree_path: String,
/// Skip branch creation and hard reset (for resume, where branch already
/// exists).
pub skip_branch_creation: bool,
pub setup_intent: Option<GitSetupIntent>,
}
/// Wraps any `Sandbox`, manages a git worktree lifecycle in
/// `initialize()`/`cleanup()`, and overrides `working_directory()` and
/// `exec_command()` to use the worktree path.
///
/// `initialize()` and `cleanup()` do NOT call the inner sandbox's lifecycle
/// methods. The inner sandbox's lifecycle is managed separately by the caller.
pub struct WorktreeSandbox {
inner: Arc<dyn Sandbox>,
config: WorktreeOptions,
event_callback: Option<WorktreeEventCallback>,
initialized: std::sync::atomic::AtomicBool,
}
impl WorktreeSandbox {
/// Create a new `WorktreeSandbox` wrapping `inner` with the given
/// configuration.
pub fn new(inner: Arc<dyn Sandbox>, config: WorktreeOptions) -> Self {
Self {
inner,
config,
event_callback: None,
initialized: std::sync::atomic::AtomicBool::new(false),
}
}
/// Set the callback to receive worktree lifecycle events.
pub fn set_event_callback(&mut self, cb: WorktreeEventCallback) {
self.event_callback = Some(cb);
}
/// The git branch name managed by this sandbox.
pub fn branch_name(&self) -> &str {
&self.config.branch_name
}
/// The base commit SHA used when initializing the worktree.
pub fn base_sha(&self) -> &str {
&self.config.base_sha
}
/// The filesystem path to the worktree directory.
pub fn worktree_path(&self) -> &str {
&self.config.worktree_path
}
fn emit(&self, event: WorktreeEvent) {
if let Some(ref cb) = self.event_callback {
cb(event);
}
}
fn resolve_path(&self, path: &str) -> String {
if std::path::Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{}/{path}", self.config.worktree_path)
}
}
async fn fetch_fork_source_if_needed(&self) -> crate::Result<()> {
if let Some(GitSetupIntent::ForkFromCheckpoint {
source_run_id,
checkpoint_sha,
..
}) = self.config.setup_intent.as_ref()
{
fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?;
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Sandbox implementation
// ---------------------------------------------------------------------------
#[async_trait]
impl Sandbox for WorktreeSandbox {
// --- Lifecycle ---
/// Set up the git worktree:
/// 1. Best-effort remove any stale worktree at `path` (so the branch is
/// free to be updated).
/// 2. Unless `skip_branch_creation`: force-create the branch at `base_sha`,
/// emit `BranchCreated`.
/// 3. Add the worktree, emit `WorktreeAdded`.
///
/// Does NOT call `inner.initialize()`.
async fn initialize(&self) -> crate::Result<()> {
if self
.initialized
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
return Ok(());
}
let path = shell_quote(&self.config.worktree_path);
let branch = shell_quote(&self.config.branch_name);
let sha = shell_quote(&self.config.base_sha);
self.fetch_fork_source_if_needed().await?;
// Best-effort remove any stale worktree registration + directory first,
// so that the branch is not "in use" when we try to force-update it.
let rm_cmd = format!("{GIT} worktree remove --force {path}");
let _ = self
.inner
.exec_command(&rm_cmd, 30_000, None, None, None)
.await;
// Prune all stale worktree references whose directories no longer exist.
// Without this, a branch may remain locked by a worktree in a deleted
// temp directory from a previous run.
let prune_cmd = format!("{GIT} worktree prune");
let _ = self
.inner
.exec_command(&prune_cmd, 30_000, None, None, None)
.await;
if !self.config.skip_branch_creation {
let cmd = format!("{GIT} branch --force {branch} {sha}");
let result = self
.inner
.exec_command(&cmd, 30_000, None, None, None)
.await?;
if !result.is_success() {
return Err(crate::Error::message(format!(
"git branch --force failed (exit {}): {}",
result.display_exit_code(),
result.stderr.trim()
)));
}
self.emit(WorktreeEvent::BranchCreated {
branch: self.config.branch_name.clone(),
sha: self.config.base_sha.clone(),
});
}
let add_cmd = format!("{GIT} worktree add {path} {branch}");
let result = self
.inner
.exec_command(&add_cmd, 30_000, None, None, None)
.await?;
if !result.is_success() {
// Roll back the branch created above so we don't leak partial state.
if !self.config.skip_branch_creation {
let rollback_cmd = format!("{GIT} branch -D {branch}");
let _ = self
.inner
.exec_command(&rollback_cmd, 30_000, None, None, None)
.await;
}
return Err(crate::Error::message(format!(
"git worktree add failed (exit {}): {}",
result.display_exit_code(),
result.stderr.trim()
)));
}
self.emit(WorktreeEvent::WorktreeAdded {
path: self.config.worktree_path.clone(),
branch: self.config.branch_name.clone(),
});
Ok(())
}
/// No-op — the worktree must survive cleanup for `fabro cp` access.
/// Worktrees are pruned separately by `system prune`.
async fn cleanup(&self) -> crate::Result<()> {
Ok(())
}
async fn start(&self) -> crate::Result<()> {
self.inner.start().await
}
async fn stop(&self) -> crate::Result<()> {
self.inner.stop().await
}
async fn delete(&self) -> crate::Result<()> {
self.inner.delete().await
}
fn working_directory(&self) -> &str {
&self.config.worktree_path
}
/// Execute a command, defaulting `working_dir` to the worktree path when
/// `None`.
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<ExecResult> {
let wd = working_dir.unwrap_or(&self.config.worktree_path);
self.inner
.exec_command(command, timeout_ms, Some(wd), env_vars, cancel_token)
.await
}
/// Stream a command's output, forwarding to the inner sandbox's streaming
/// implementation so live output and `streams_separated` / `live_streaming`
/// flags survive the worktree wrapping.
async fn exec_command_streaming(
&self,
command: &str,
timeout_ms: Option<u64>,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
output_callback: CommandOutputCallback,
) -> crate::Result<ExecStreamingResult> {
let wd = working_dir.unwrap_or(&self.config.worktree_path);
self.inner
.exec_command_streaming(
command,
timeout_ms,
Some(wd),
env_vars,
cancel_token,
output_callback,
)
.await
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
let wd = working_dir.unwrap_or(&self.config.worktree_path);
self.inner
.spawn_stdio_process(command, Some(wd), env_vars, cancel_token)
.await
}
// --- Delegated methods ---
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> crate::Result<String> {
let resolved = self.resolve_path(path);
self.inner.read_file(&resolved, offset, limit).await
}
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
let resolved = self.resolve_path(path);
self.inner.write_file(&resolved, content).await
}
async fn delete_file(&self, path: &str) -> crate::Result<()> {
let resolved = self.resolve_path(path);
self.inner.delete_file(&resolved).await
}
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
let resolved = self.resolve_path(path);
self.inner.file_exists(&resolved).await
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> crate::Result<Vec<DirEntry>> {
let resolved = self.resolve_path(path);
self.inner.list_directory(&resolved, depth).await
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> crate::Result<Vec<String>> {
let resolved = self.resolve_path(path);
self.inner.grep(pattern, &resolved, options).await
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let resolved = path.map(|p| self.resolve_path(p));
let glob_path = resolved.as_deref().unwrap_or(&self.config.worktree_path);
self.inner.glob(pattern, Some(glob_path)).await
}
async fn download_file_to_local(
&self,
remote_path: &str,
local_path: &Path,
) -> crate::Result<()> {
let resolved = self.resolve_path(remote_path);
self.inner
.download_file_to_local(&resolved, local_path)
.await
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> crate::Result<()> {
let resolved = self.resolve_path(remote_path);
self.inner
.upload_file_from_local(local_path, &resolved)
.await
}
fn platform(&self) -> &str {
self.inner.platform()
}
fn os_version(&self) -> String {
self.inner.os_version()
}
fn sandbox_info(&self) -> String {
self.inner.sandbox_info()
}
async fn refresh_push_credentials(&self) -> crate::Result<()> {
self.inner.refresh_push_credentials().await
}
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
self.inner.set_autostop_interval(minutes).await
}
async fn setup_git(
&self,
intent: &crate::GitSetupIntent,
) -> crate::Result<Option<crate::GitRunInfo>> {
if let GitSetupIntent::ForkFromCheckpoint {
source_run_id,
checkpoint_sha,
..
} = intent
{
fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?;
}
Ok(Some(GitRunInfo {
base_sha: self.config.base_sha.clone(),
run_branch: self.config.branch_name.clone(),
base_branch: None,
}))
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
self.inner.resume_setup_commands(run_branch)
}
async fn git_push_ref(&self, refspec: &str) -> crate::Result<()> {
let has_origin = match self
.exec_command("git remote get-url origin", 10_000, None, None, None)
.await
{
Ok(result) if result.is_success() => true,
Ok(_) => false,
Err(err) => return Err(crate::Error::context("git remote get-url origin", err)),
};
if !has_origin {
return Ok(());
}
crate::git_push_via_exec(self, refspec).await
}
fn parallel_worktree_path(
&self,
run_dir: &Path,
run_id: &str,
node_id: &str,
key: &str,
) -> String {
self.inner
.parallel_worktree_path(run_dir, run_id, node_id, key)
}
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
self.inner.ssh_access_command().await
}
fn origin_url(&self) -> Option<&str> {
self.inner.origin_url()
}
async fn get_preview_url(
&self,
port: u16,
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
self.inner.get_preview_url(port).await
}
fn mark_agent_read(&self, path: &str) {
let resolved = self.resolve_path(path);
self.inner.mark_agent_read(&resolved);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "worktree tests stage fixtures with sync std::fs writes in temp dirs"
)]
mod tests {
use std::sync::Mutex;
use fabro_types::CommandTermination;
use super::*;
use crate::local::LocalSandbox;
use crate::test_support::MockSandbox;
fn make_config(wt_path: &str) -> WorktreeOptions {
WorktreeOptions {
branch_name: "fabro/run/test-branch".to_string(),
base_sha: "abc123def456".to_string(),
worktree_path: wt_path.to_string(),
skip_branch_creation: false,
setup_intent: None,
}
}
fn make_config_skip(wt_path: &str) -> WorktreeOptions {
WorktreeOptions {
branch_name: "fabro/run/test-branch".to_string(),
base_sha: "abc123def456".to_string(),
worktree_path: wt_path.to_string(),
skip_branch_creation: true,
setup_intent: None,
}
}
/// Create a shared mock and return both the `Arc<dyn Sandbox>` (passed to
/// WorktreeSandbox) and the `Arc<MockSandbox>` (used to assert captured
/// state).
fn make_mock() -> (Arc<dyn Sandbox>, Arc<MockSandbox>) {
let mock = Arc::new(MockSandbox::linux());
let as_sandbox: Arc<dyn Sandbox> = mock.clone();
(as_sandbox, mock)
}
// -----------------------------------------------------------------------
// initialize() — full setup (skip_branch_creation = false)
// -----------------------------------------------------------------------
#[tokio::test]
async fn initialize_issues_correct_git_commands() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.initialize().await.unwrap();
let cmds = mock.captured_commands.lock().unwrap().clone();
// worktree remove (best-effort), worktree prune, branch --force, worktree add
assert_eq!(cmds.len(), 4, "expected 4 git commands, got: {cmds:?}");
assert!(
cmds[0].contains("worktree remove --force"),
"cmd[0]: {}",
cmds[0]
);
assert!(cmds[1].contains("worktree prune"), "cmd[1]: {}", cmds[1]);
assert!(cmds[2].contains("branch --force"), "cmd[2]: {}", cmds[2]);
assert!(cmds[3].contains("worktree add"), "cmd[3]: {}", cmds[3]);
}
#[tokio::test]
async fn initialize_emits_branch_and_worktree_events() {
let (inner, _mock) = make_mock();
let mut wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
wt.set_event_callback(Arc::new(move |event| {
let label = match &event {
WorktreeEvent::BranchCreated { .. } => "BranchCreated",
WorktreeEvent::WorktreeAdded { .. } => "WorktreeAdded",
WorktreeEvent::WorktreeRemoved { .. } => "WorktreeRemoved",
};
events_clone.lock().unwrap().push(label.to_string());
}));
wt.initialize().await.unwrap();
let captured = events.lock().unwrap();
assert_eq!(*captured, vec!["BranchCreated", "WorktreeAdded"]);
}
#[tokio::test]
async fn initialize_uses_shell_quoted_values_in_commands() {
let (inner, mock) = make_mock();
let config = WorktreeOptions {
branch_name: "fabro/run/my-branch".to_string(),
base_sha: "deadbeef".to_string(),
worktree_path: "/tmp/my worktree".to_string(), // path with space
skip_branch_creation: false,
setup_intent: None,
};
let wt = WorktreeSandbox::new(inner, config);
wt.initialize().await.unwrap();
let cmds = mock.captured_commands.lock().unwrap().clone();
// The path "/tmp/my worktree" should be quoted in the worktree remove command
// (cmd[0])
assert!(
cmds[0].contains("'/tmp/my worktree'") || cmds[0].contains("\"/tmp/my worktree\""),
"worktree path should be shell-quoted: {}",
cmds[0]
);
}
// -----------------------------------------------------------------------
// initialize() — skip_branch_creation = true
// -----------------------------------------------------------------------
#[tokio::test]
async fn initialize_skip_branch_creation_issues_only_worktree_commands() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config_skip("/tmp/wt"));
wt.initialize().await.unwrap();
let cmds = mock.captured_commands.lock().unwrap().clone();
// worktree remove (best-effort), worktree prune, worktree add
assert_eq!(cmds.len(), 3, "expected 3 git commands, got: {cmds:?}");
assert!(
cmds[0].contains("worktree remove --force"),
"cmd[0]: {}",
cmds[0]
);
assert!(cmds[1].contains("worktree prune"), "cmd[1]: {}", cmds[1]);
assert!(cmds[2].contains("worktree add"), "cmd[2]: {}", cmds[2]);
}
#[tokio::test]
async fn initialize_skip_branch_creation_emits_only_worktree_added() {
let (inner, _mock) = make_mock();
let mut wt = WorktreeSandbox::new(inner, make_config_skip("/tmp/wt"));
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
wt.set_event_callback(Arc::new(move |event| {
let label = match &event {
WorktreeEvent::BranchCreated { .. } => "BranchCreated",
WorktreeEvent::WorktreeAdded { .. } => "WorktreeAdded",
WorktreeEvent::WorktreeRemoved { .. } => "WorktreeRemoved",
};
events_clone.lock().unwrap().push(label.to_string());
}));
wt.initialize().await.unwrap();
let captured = events.lock().unwrap();
assert_eq!(*captured, vec!["WorktreeAdded"]);
}
// -----------------------------------------------------------------------
// initialize() — error propagation
// -----------------------------------------------------------------------
#[tokio::test]
async fn initialize_propagates_error_on_nonzero_exit() {
let inner: Arc<dyn Sandbox> = Arc::new(MockSandbox {
exec_result: ExecResult {
stdout: String::new(),
stderr: "fatal: not a git repo".to_string(),
exit_code: Some(128),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..MockSandbox::linux()
});
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
let result = wt.initialize().await;
assert!(result.is_err(), "should return Err on non-zero exit");
let err = result.unwrap_err().to_string();
assert!(
err.contains("branch --force failed") || err.contains("128"),
"error should mention the failure: {err}"
);
}
// -----------------------------------------------------------------------
// cleanup()
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// working_directory()
// -----------------------------------------------------------------------
#[test]
fn working_directory_returns_worktree_path() {
let (inner, _mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/my_worktree"));
assert_eq!(wt.working_directory(), "/tmp/my_worktree");
}
// -----------------------------------------------------------------------
// exec_command() working_dir defaulting
// -----------------------------------------------------------------------
#[tokio::test]
async fn exec_command_none_working_dir_defaults_to_worktree_path() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.exec_command("echo hello", 5000, None, None, None)
.await
.unwrap();
let wdirs = mock.captured_working_dirs.lock().unwrap().clone();
assert_eq!(
wdirs.last(),
Some(&Some("/tmp/wt".to_string())),
"None working_dir should be replaced with worktree path"
);
}
#[tokio::test]
async fn exec_command_explicit_working_dir_passes_through() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.exec_command("echo hello", 5000, Some("/explicit/path"), None, None)
.await
.unwrap();
let wdirs = mock.captured_working_dirs.lock().unwrap().clone();
assert_eq!(
wdirs.last(),
Some(&Some("/explicit/path".to_string())),
"explicit working_dir should be passed through unchanged"
);
}
#[tokio::test]
async fn stdio_process_none_working_dir_defaults_to_worktree_path() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.spawn_stdio_process("python fake_agent.py", None, None, None)
.await
.unwrap();
let wdirs = mock.captured_working_dirs.lock().unwrap().clone();
assert_eq!(
wdirs.last(),
Some(&Some("/tmp/wt".to_string())),
"None working_dir should be replaced with worktree path"
);
}
// -----------------------------------------------------------------------
// Accessors
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Bug: cleanup() destroys worktree, breaking `fabro cp`
// -----------------------------------------------------------------------
#[tokio::test]
async fn cleanup_should_preserve_worktree_for_post_run_access() {
// The worktree directory must survive cleanup() so that `fabro cp` can
// access run artifacts afterward. It is pruned separately by `system prune`.
// LocalSandbox.cleanup() was a no-op; WorktreeSandbox should match.
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.cleanup().await.unwrap();
let cmds = mock.captured_commands.lock().unwrap().clone();
assert!(
cmds.is_empty(),
"cleanup should not issue destructive git commands \
(worktree must be preserved for fabro cp), but got: {cmds:?}"
);
}
#[tokio::test]
async fn lifecycle_operations_forward_to_inner_sandbox() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.start().await.unwrap();
wt.stop().await.unwrap();
wt.delete().await.unwrap();
assert_eq!(mock.start_count(), 1);
assert_eq!(mock.stop_count(), 1);
assert_eq!(mock.delete_count(), 1);
}
// -----------------------------------------------------------------------
// Bug: initialize() is not idempotent — double call destroys worktree
// -----------------------------------------------------------------------
#[tokio::test]
async fn initialize_is_idempotent_on_second_call() {
// engine.run_with_lifecycle() calls sandbox.initialize() unconditionally,
// even when run.rs already called it during sandbox construction.
// The second call must be a no-op; it must NOT re-run
// `git worktree remove --force` which would destroy the worktree.
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.initialize().await.unwrap();
let first_count = mock.captured_commands.lock().unwrap().len();
wt.initialize().await.unwrap();
let second_count = mock.captured_commands.lock().unwrap().len();
assert_eq!(
first_count,
second_count,
"second initialize() should be a no-op, but it issued {} additional commands",
second_count - first_count
);
}
// -----------------------------------------------------------------------
// Bug: file operations resolve against inner working_directory, not worktree
// -----------------------------------------------------------------------
#[tokio::test]
async fn grep_should_search_worktree_not_inner_working_directory() {
// WorktreeSandbox delegates grep() to the inner sandbox without path
// adjustment. When the inner LocalSandbox was created with original_cwd,
// grep("pattern", ".") searches the original repo instead of the worktree.
let original =
std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4()));
let worktree =
std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&original).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
// Put a marker file ONLY in the worktree directory
std::fs::write(worktree.join("marker.txt"), "UNIQUE_WORKTREE_MARKER").unwrap();
let inner: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(original.clone()));
let config = WorktreeOptions {
branch_name: "test-branch".into(),
base_sha: "abc123".into(),
worktree_path: worktree.to_string_lossy().to_string(),
skip_branch_creation: false,
setup_intent: None,
};
let wt = WorktreeSandbox::new(inner, config);
// working_directory() correctly returns the worktree path
assert_eq!(wt.working_directory(), worktree.to_string_lossy().as_ref());
// grep with "." should search the worktree, not the original repo
let results = wt
.grep("UNIQUE_WORKTREE_MARKER", ".", &GrepOptions::default())
.await
.unwrap();
assert!(
!results.is_empty(),
"grep(\".\") should search the worktree directory, not the inner sandbox's working directory"
);
std::fs::remove_dir_all(&original).ok();
std::fs::remove_dir_all(&worktree).ok();
}
#[tokio::test]
async fn glob_should_search_worktree_when_path_is_none() {
// WorktreeSandbox delegates glob() to the inner sandbox without path
// adjustment. LocalSandbox::glob(pattern, None) defaults to
// self.working_directory, which is the original repo path.
let original =
std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4()));
let worktree =
std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&original).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
// Put a file ONLY in the worktree directory
std::fs::write(worktree.join("worktree_only.txt"), "content").unwrap();
let inner: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(original.clone()));
let config = WorktreeOptions {
branch_name: "test-branch".into(),
base_sha: "abc123".into(),
worktree_path: worktree.to_string_lossy().to_string(),
skip_branch_creation: false,
setup_intent: None,
};
let wt = WorktreeSandbox::new(inner, config);
let results = wt.glob("*.txt", None).await.unwrap();
assert!(
results.iter().any(|r| r.contains("worktree_only.txt")),
"glob(pattern, None) should search the worktree directory, not the inner sandbox's working directory. Got: {results:?}"
);
std::fs::remove_dir_all(&original).ok();
std::fs::remove_dir_all(&worktree).ok();
}
#[tokio::test]
async fn read_file_relative_should_resolve_against_worktree() {
// WorktreeSandbox delegates read_file() to the inner sandbox without
// path adjustment. Relative paths resolve against the inner
// LocalSandbox's working_directory (original repo), not the worktree.
let original =
std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4()));
let worktree =
std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&original).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
// Put the file ONLY in the worktree directory
std::fs::write(worktree.join("only_in_worktree.txt"), "worktree content").unwrap();
let inner: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(original.clone()));
let config = WorktreeOptions {
branch_name: "test-branch".into(),
base_sha: "abc123".into(),
worktree_path: worktree.to_string_lossy().to_string(),
skip_branch_creation: false,
setup_intent: None,
};
let wt = WorktreeSandbox::new(inner, config);
let result = wt.read_file("only_in_worktree.txt", None, None).await;
assert!(
result.is_ok(),
"read_file with relative path should resolve against worktree, not inner sandbox's working directory. Error: {}",
result.unwrap_err()
);
std::fs::remove_dir_all(&original).ok();
std::fs::remove_dir_all(&worktree).ok();
}
// -----------------------------------------------------------------------
// Accessors
// -----------------------------------------------------------------------
#[test]
fn accessors_return_config_values() {
let (inner, _mock) = make_mock();
let config = WorktreeOptions {
branch_name: "my-branch".to_string(),
base_sha: "sha123".to_string(),
worktree_path: "/path/to/wt".to_string(),
skip_branch_creation: false,
setup_intent: None,
};
let wt = WorktreeSandbox::new(inner, config);
assert_eq!(wt.branch_name(), "my-branch");
assert_eq!(wt.base_sha(), "sha123");
assert_eq!(wt.worktree_path(), "/path/to/wt");
}
}