1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
//! RAII worktree guard for automatic cleanup on agent crash or panic.
//!
//! Ensures that agent worktrees are cleaned up even when the agent process
//! exits abnormally (SIGKILL, OOM, panic).
//!
//! # Example
//!
//! ```rust,ignore
//! use terraphim_orchestrator::worktree_guard::WorktreeGuard;
//!
//! {
//! let guard = WorktreeGuard::new("/tmp/agent-worktree-123");
//! // ... run agent ...
//! } // worktree automatically cleaned up here
//! ```
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
/// RAII guard that removes a worktree directory when dropped.
///
/// Call `keep()` to prevent cleanup (e.g., when the agent succeeds and
/// you want to preserve the worktree for inspection).
#[derive(Debug)]
pub struct WorktreeGuard {
path: PathBuf,
should_cleanup: bool,
/// When `Some`, `Drop` runs `git -C <repo_path> worktree remove
/// --force <path>` first and falls back to a filesystem-only
/// removal on non-zero exit or when the git CLI is not
/// invokable. When `None`, only the filesystem path runs (the
/// existing per-agent caller in `lib.rs`, unchanged).
repo_path: Option<PathBuf>,
}
impl WorktreeGuard {
/// Create a new worktree guard for the given path.
///
/// The path will be removed when the guard is dropped unless
/// `keep()` is called. This constructor performs filesystem-only
/// cleanup; use `for_managed` for git-aware cleanup of worktrees
/// created via `WorktreeManager::create_worktree`.
pub fn new<P: AsRef<Path>>(path: P) -> Self {
let path = path.as_ref().to_path_buf();
debug!(path = %path.display(), "worktree guard created");
Self {
path,
should_cleanup: true,
repo_path: None,
}
}
/// Create a managed guard whose `Drop` invokes `git worktree
/// remove --force` against `repo_path` before falling back to
/// filesystem removal.
///
/// Use this when the worktree was created via
/// `WorktreeManager::create_worktree` so the git admin registry
/// at `<repo>/.git/worktrees/<name>` is reconciled along with the
/// directory itself.
pub fn for_managed<R: AsRef<Path>, P: AsRef<Path>>(repo_path: R, worktree_path: P) -> Self {
let path = worktree_path.as_ref().to_path_buf();
let repo = repo_path.as_ref().to_path_buf();
debug!(
repo_path = %repo.display(),
worktree_path = %path.display(),
"managed worktree guard created"
);
Self {
path,
should_cleanup: true,
repo_path: Some(repo),
}
}
/// Prevent cleanup when the guard is dropped.
///
/// Call this when the agent succeeds and you want to keep the worktree.
pub fn keep(mut self) {
self.should_cleanup = false;
debug!(path = %self.path.display(), "worktree guard disarmed");
}
/// Get the worktree path.
pub fn path(&self) -> &Path {
&self.path
}
/// Perform the cleanup.
fn cleanup(&self) {
if !self.should_cleanup {
return;
}
if !self.path.exists() {
debug!(path = %self.path.display(), "worktree already removed");
return;
}
// Managed path: try `git worktree remove --force` first so the
// git admin entry at `<repo>/.git/worktrees/<name>` is
// reconciled. The synchronous std Command is intentional --
// Drop cannot be async, and git worktree remove is sub-second.
if let Some(ref repo) = self.repo_path {
let start = std::time::Instant::now();
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.arg("worktree")
.arg("remove")
.arg("--force")
.arg(&self.path)
.env_remove("GIT_INDEX_FILE")
.status();
match status {
Ok(s) if s.success() => {
info!(
path = %self.path.display(),
duration_ms = start.elapsed().as_millis() as u64,
"worktree cleaned up via git"
);
return;
}
Ok(s) => {
warn!(
path = %self.path.display(),
exit_code = ?s.code(),
"git worktree remove failed, falling back to fs"
);
}
Err(e) => {
warn!(
path = %self.path.display(),
error = %e,
"git CLI not invokable, falling back to fs"
);
}
}
}
// Fallback / unmanaged path: filesystem-only removal.
match std::fs::remove_dir_all(&self.path) {
Ok(_) => {
info!(path = %self.path.display(), "worktree cleaned up");
}
Err(e) => {
warn!(path = %self.path.display(), error = %e, "failed to remove worktree");
// Try to at least remove the directory entry
if let Err(e2) = std::fs::remove_dir(&self.path) {
debug!(path = %self.path.display(), error = %e2, "failed to remove worktree dir");
}
}
}
}
}
impl Drop for WorktreeGuard {
fn drop(&mut self) {
self.cleanup();
}
}
/// Scoped worktree guard that wraps a closure and ensures cleanup.
///
/// This is useful when you want to run an agent in a closure and guarantee
/// cleanup regardless of how the closure exits.
pub fn with_worktree_guard<F, T, P: AsRef<Path>>(path: P, f: F) -> T
where
F: FnOnce(&WorktreeGuard) -> T,
{
let guard = WorktreeGuard::new(path);
f(&guard)
}
/// Async version of `with_worktree_guard`.
pub async fn with_worktree_guard_async<F, T, P: AsRef<Path>>(path: P, f: F) -> T
where
F: std::future::Future<Output = T>,
{
let _guard = WorktreeGuard::new(path);
let result = f.await;
// _guard dropped here, cleanup happens
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn test_worktree_guard_cleanup() {
let temp_dir = TempDir::new().unwrap();
let worktree = temp_dir.path().join("worktree-123");
std::fs::create_dir(&worktree).unwrap();
File::create(worktree.join("file.txt")).unwrap();
assert!(worktree.exists());
{
let _guard = WorktreeGuard::new(&worktree);
// Guard should not cleanup yet
assert!(worktree.exists());
}
// After drop, should be cleaned up
assert!(!worktree.exists());
}
#[test]
fn test_worktree_guard_keep() {
let temp_dir = TempDir::new().unwrap();
let worktree = temp_dir.path().join("worktree-456");
std::fs::create_dir(&worktree).unwrap();
{
let guard = WorktreeGuard::new(&worktree);
guard.keep();
}
// Should still exist after keep()
assert!(worktree.exists());
}
#[test]
fn test_worktree_guard_already_removed() {
let temp_dir = TempDir::new().unwrap();
let worktree = temp_dir.path().join("worktree-789");
std::fs::create_dir(&worktree).unwrap();
{
let _guard = WorktreeGuard::new(&worktree);
// Remove manually before guard drops
std::fs::remove_dir_all(&worktree).unwrap();
}
// Should not panic even though already removed
assert!(!worktree.exists());
}
#[test]
fn test_with_worktree_guard() {
let temp_dir = TempDir::new().unwrap();
let worktree = temp_dir.path().join("worktree-scoped");
std::fs::create_dir(&worktree).unwrap();
let result = with_worktree_guard(&worktree, |guard| {
assert!(guard.path().exists());
42
});
assert_eq!(result, 42);
assert!(!worktree.exists());
}
/// Minimal real git repo bootstrap for guard tests. Mirrors the
/// helper in `scope::tests::setup_git_repo` but kept inline here
/// so the unit tests are self-contained.
fn init_git_repo() -> TempDir {
std::env::remove_var("GIT_INDEX_FILE");
let temp_dir = TempDir::new().expect("temp dir");
let repo = temp_dir.path();
let run = |args: &[&str]| {
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env_remove("GIT_INDEX_FILE")
.status()
.expect("git invocation");
assert!(status.success(), "git {:?} failed", args);
};
std::process::Command::new("git")
.arg("init")
.arg(repo)
.env_remove("GIT_INDEX_FILE")
.status()
.expect("git init");
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test User"]);
std::fs::write(repo.join("README.md"), "# Test").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "init"]);
temp_dir
}
#[test]
fn test_managed_guard_invokes_git_remove() {
let repo = init_git_repo();
let worktree = repo.path().join(".worktrees/managed-remove");
// Use real git worktree add so the admin entry exists.
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.arg("worktree")
.arg("add")
.arg(&worktree)
.arg("HEAD")
.env_remove("GIT_INDEX_FILE")
.status()
.expect("git worktree add");
assert!(status.success(), "git worktree add failed");
assert!(worktree.exists());
// git admin registry entry exists
let admin = repo.path().join(".git/worktrees/managed-remove");
assert!(admin.exists(), "git admin entry should exist");
{
let _guard = WorktreeGuard::for_managed(repo.path(), &worktree);
}
assert!(
!worktree.exists(),
"managed guard should remove worktree dir"
);
assert!(
!admin.exists(),
"managed guard should reconcile git admin entry"
);
}
#[test]
fn test_managed_guard_fallback_on_git_failure() {
// Point repo_path at a non-git directory so `git worktree
// remove` exits non-zero, exercising the fs fallback.
let temp_dir = TempDir::new().unwrap();
let not_a_repo = temp_dir.path().join("not-a-repo");
std::fs::create_dir(¬_a_repo).unwrap();
let worktree = temp_dir.path().join("orphan-worktree");
std::fs::create_dir(&worktree).unwrap();
File::create(worktree.join("payload.txt")).unwrap();
{
let _guard = WorktreeGuard::for_managed(¬_a_repo, &worktree);
}
assert!(
!worktree.exists(),
"fallback fs removal should remove worktree dir"
);
}
#[test]
fn test_managed_guard_keep_disarms() {
let temp_dir = TempDir::new().unwrap();
let fake_repo = temp_dir.path().join("repo");
std::fs::create_dir(&fake_repo).unwrap();
let worktree = temp_dir.path().join("kept-worktree");
std::fs::create_dir(&worktree).unwrap();
let guard = WorktreeGuard::for_managed(&fake_repo, &worktree);
guard.keep();
assert!(
worktree.exists(),
"managed guard with keep() must not remove"
);
}
}