返回 CodeWhale
worktree.rs
根目录 / crates / lane / src / worktree.rs
1 //! Worktree provisioning owned by Runtime (not Fleet) — #4176 / #4016.
2
3 use std::fs;
4 use std::path::{Path, PathBuf};
5 use std::process::Command;
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 use anyhow::{Context, Result, bail};
9 use chrono::DateTime;
10
11 /// Spec for an isolated worktree + branch for a lane.
12 #[derive(Debug, Clone)]
13 pub struct WorktreeProvision {
14 /// Git repository root (must contain `.git`).
15 pub repo_root: PathBuf,
16 /// Branch to create (from `base_ref`).
17 pub branch: String,
18 /// Directory for the new worktree (created by `git worktree add`).
19 pub path: PathBuf,
20 /// Base ref to branch from (default `HEAD`).
21 pub base_ref: Option<String>,
22 }
23
24 #[derive(Debug, Clone)]
25 pub struct ProvisionedWorktree {
26 pub path: PathBuf,
27 pub branch: String,
28 }
29
30 /// Create a git worktree + branch for a lane.
31 pub fn provision_worktree(spec: &WorktreeProvision) -> Result<ProvisionedWorktree> {
32 if spec.branch.trim().is_empty() {
33 bail!("worktree branch must not be empty");
34 }
35 if !spec.repo_root.exists() {
36 bail!("repo root does not exist: {}", spec.repo_root.display());
37 }
38 if let Some(parent) = spec.path.parent() {
39 fs::create_dir_all(parent)
40 .with_context(|| format!("create worktree parent {}", parent.display()))?;
41 }
42 let base = spec.base_ref.as_deref().unwrap_or("HEAD");
43 // Capture git output instead of inheriting the caller's terminal. Runtime
44 // callers include the raw-mode TUI launch screen, where even one inherited
45 // progress/error line corrupts the alternate-screen buffer.
46 let output = Command::new("git")
47 .current_dir(&spec.repo_root)
48 .args([
49 "worktree",
50 "add",
51 "-b",
52 &spec.branch,
53 &spec.path.to_string_lossy(),
54 base,
55 ])
56 .output()
57 .context("git worktree add")?;
58 if !output.status.success() {
59 let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
60 bail!(
61 "git worktree add failed for branch {} at {}{}{}",
62 spec.branch,
63 spec.path.display(),
64 if detail.is_empty() { "" } else { ": " },
65 detail
66 );
67 }
68 Ok(ProvisionedWorktree {
69 path: spec.path.clone(),
70 branch: spec.branch.clone(),
71 })
72 }
73
74 /// Remove a worktree when TTL has expired (or immediately when TTL is 0).
75 ///
76 /// `stopped_at` is RFC3339. When `ttl_secs` is `None`, no cleanup is performed.
77 ///
78 /// Removal only ever touches a path that git identifies as a managed worktree
79 /// of its own repository (#5824); anything else — a stale or malformed record
80 /// pointing at an unrelated directory — is left untouched.
81 pub fn remove_worktree_if_expired(
82 worktree_path: &Path,
83 ttl_secs: Option<u64>,
84 stopped_at: Option<&str>,
85 ) -> Result<()> {
86 let Some(ttl) = ttl_secs else {
87 return Ok(());
88 };
89 if !worktree_path.exists() {
90 return Ok(());
91 }
92 if ttl > 0 {
93 let Some(stopped) = stopped_at else {
94 return Ok(());
95 };
96 let stopped_ts = DateTime::parse_from_rfc3339(stopped)
97 .with_context(|| format!("parse stopped_at {stopped}"))?
98 .timestamp() as u64;
99 let now = SystemTime::now()
100 .duration_since(UNIX_EPOCH)
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103 if now.saturating_sub(stopped_ts) < ttl {
104 return Ok(());
105 }
106 }
107
108 // Ask the worktree what it is before deleting it: once the directory is
109 // gone, neither its branch nor its repository is recoverable from the path.
110 let Some(details) = worktree_details(worktree_path) else {
111 return Ok(());
112 };
113 // #5824: a stale or malformed record must not turn TTL cleanup into an
114 // unbounded recursive delete. Removal proceeds only for a path that git
115 // itself identifies as a managed worktree of `details.repo_root`.
116 if !is_managed_worktree(worktree_path, &details) {
117 tracing::debug!(
118 "skipped TTL cleanup of {}: git does not identify it as a managed worktree",
119 worktree_path.display()
120 );
121 return Ok(());
122 }
123
124 // Best-effort: git worktree remove --force, then rm -rf.
125 let removed = Command::new("git")
126 .current_dir(&details.repo_root)
127 .args([
128 "worktree",
129 "remove",
130 "--force",
131 &worktree_path.to_string_lossy(),
132 ])
133 .status()
134 .is_ok_and(|status| status.success());
135 if !removed && worktree_path.exists() && is_managed_worktree(worktree_path, &details) {
136 // Re-verified immediately before the fallback: the directory may have
137 // been swapped for an unrelated one between identification and removal.
138 fs::remove_dir_all(worktree_path)
139 .with_context(|| format!("remove worktree {}", worktree_path.display()))?;
140 }
141 if !removed {
142 // The directory is gone but git still has it registered, and
143 // `git worktree add` refuses a path it already knows about.
144 let _ = Command::new("git")
145 .current_dir(&details.repo_root)
146 .args(["worktree", "prune"])
147 .status();
148 }
149 if let Some(branch) = details.branch.as_deref() {
150 delete_lane_branch(&details.repo_root, branch);
151 }
152 Ok(())
153 }
154
155 /// What a lane worktree is: which repository owns it, which branch it has
156 /// checked out (`None` when detached), and every worktree that repository
157 /// lists.
158 struct WorktreeDetails {
159 repo_root: PathBuf,
160 branch: Option<String>,
161 /// The worktrees the owning repository lists; a candidate path must
162 /// resolve to one of these before cleanup may delete anything (#5824).
163 worktrees: Vec<PathBuf>,
164 }
165
166 fn worktree_details(worktree_path: &Path) -> Option<WorktreeDetails> {
167 let listing = Command::new("git")
168 .current_dir(worktree_path)
169 .args(["worktree", "list", "--porcelain"])
170 .output()
171 .ok()
172 .filter(|output| output.status.success())?;
173 let listing = String::from_utf8_lossy(&listing.stdout);
174 let worktrees: Vec<PathBuf> = listing
175 .lines()
176 .filter_map(|line| line.strip_prefix("worktree "))
177 .map(PathBuf::from)
178 .collect();
179 // The main worktree is listed first, so its path is the repository root.
180 let repo_root = worktrees.first().cloned()?;
181
182 let branch = Command::new("git")
183 .current_dir(worktree_path)
184 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
185 .output()
186 .ok()
187 .filter(|output| output.status.success())
188 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
189 .filter(|branch| !branch.is_empty());
190
191 Some(WorktreeDetails {
192 repo_root,
193 branch,
194 worktrees,
195 })
196 }
197
198 /// Whether git identifies `worktree_path` itself as a managed worktree of the
199 /// repository in `details` (#5824). Two checks, both required:
200 ///
201 /// - the candidate resolves to a worktree the owning repository lists, and
202 /// - the worktree is a *linked* one: its `.git` file names the registration
203 /// the owning repository keeps beneath `<repo>/.git/worktrees/`.
204 ///
205 /// Repository roots (whose `.git` is a directory with no registration) and
206 /// plain subdirectories of a repo (which are not listed as worktrees) fail
207 /// here and are never candidates for recursive deletion. Paths are
208 /// canonicalized on both sides so symlinks (macOS `/tmp` -> `/private/tmp`)
209 /// and relative records cannot smuggle a different directory past the check.
210 fn is_managed_worktree(worktree_path: &Path, details: &WorktreeDetails) -> bool {
211 let Ok(candidate) = fs::canonicalize(worktree_path) else {
212 return false;
213 };
214 let listed = details
215 .worktrees
216 .iter()
217 .any(|path| fs::canonicalize(path).is_ok_and(|resolved| resolved == candidate));
218 if !listed {
219 return false;
220 }
221 let Ok(repo_root) = fs::canonicalize(&details.repo_root) else {
222 return false;
223 };
224 let registrations = repo_root.join(".git").join("worktrees");
225 let registration = fs::read_to_string(worktree_path.join(".git"))
226 .ok()
227 .and_then(|dot_git| {
228 dot_git
229 .lines()
230 .find_map(|line| line.strip_prefix("gitdir: "))
231 .map(str::trim)
232 .filter(|gitdir| !gitdir.is_empty())
233 .map(PathBuf::from)
234 });
235 let Some(registration) = registration else {
236 return false;
237 };
238 let registration = if registration.is_absolute() {
239 registration
240 } else {
241 worktree_path.join(registration)
242 };
243 fs::canonicalize(registration).is_ok_and(|resolved| resolved.starts_with(registrations))
244 }
245
246 /// Delete the branch a removed lane worktree was on.
247 ///
248 /// Lane branch names are derived from the user's launch name (`codex/{slug}`),
249 /// not from a UUID, so leaving the branch behind makes reusing that name fail
250 /// with "branch already exists" — a worktree directory that no longer exists
251 /// still blocking a legitimate lane.
252 ///
253 /// This uses `branch -d`, not `-D`: a lane branch with nothing on it beyond
254 /// its base is merged and deletes cleanly, which is the case that was broken.
255 /// A branch carrying unmerged commits is someone's work, and a TTL timer is
256 /// not a mandate to throw it away — that one is kept, and the name stays taken
257 /// until a human decides otherwise.
258 fn delete_lane_branch(repo_root: &Path, branch: &str) {
259 let output = Command::new("git")
260 .current_dir(repo_root)
261 .args(["branch", "-d", branch])
262 .output();
263 match output {
264 Ok(output) if output.status.success() => {}
265 Ok(output) => {
266 tracing::debug!(
267 "kept lane branch {branch} after worktree cleanup: {}",
268 String::from_utf8_lossy(&output.stderr).trim()
269 );
270 }
271 Err(err) => {
272 tracing::debug!("could not delete lane branch {branch}: {err}");
273 }
274 }
275 }
276
277 #[cfg(test)]
278 mod tests {
279 use super::*;
280 use std::process::Command;
281 use tempfile::tempdir;
282
283 fn init_repo(root: &Path) {
284 assert!(
285 Command::new("git")
286 .args(["init", "-b", "main"])
287 .current_dir(root)
288 .status()
289 .unwrap()
290 .success()
291 );
292 assert!(
293 Command::new("git")
294 .args(["config", "user.email", "lane@test"])
295 .current_dir(root)
296 .status()
297 .unwrap()
298 .success()
299 );
300 assert!(
301 Command::new("git")
302 .args(["config", "user.name", "lane"])
303 .current_dir(root)
304 .status()
305 .unwrap()
306 .success()
307 );
308 fs::write(root.join("README"), "lane").unwrap();
309 assert!(
310 Command::new("git")
311 .args(["add", "README"])
312 .current_dir(root)
313 .status()
314 .unwrap()
315 .success()
316 );
317 assert!(
318 Command::new("git")
319 .args(["commit", "-m", "init"])
320 .current_dir(root)
321 .status()
322 .unwrap()
323 .success()
324 );
325 }
326
327 #[test]
328 fn provision_and_ttl_zero_cleanup() {
329 let dir = tempdir().unwrap();
330 let repo = dir.path().join("repo");
331 fs::create_dir_all(&repo).unwrap();
332 init_repo(&repo);
333 let wt_path = dir.path().join("wt-lane");
334 let provisioned = provision_worktree(&WorktreeProvision {
335 repo_root: repo,
336 branch: "codex/lane-test".into(),
337 path: wt_path.clone(),
338 base_ref: Some("main".into()),
339 })
340 .unwrap();
341 assert!(provisioned.path.is_dir());
342 assert!(wt_path.join("README").is_file());
343
344 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
345 assert!(
346 !wt_path.exists(),
347 "TTL 0 should remove worktree immediately"
348 );
349 }
350
351 fn branch_exists(repo: &Path, branch: &str) -> bool {
352 Command::new("git")
353 .current_dir(repo)
354 .args(["rev-parse", "--verify", "--quiet", branch])
355 .status()
356 .unwrap()
357 .success()
358 }
359
360 #[test]
361 fn expired_cleanup_deletes_the_branch_so_the_lane_name_is_reusable() {
362 // #4731: cleanup removed the worktree directory but left the branch.
363 // Lane branches are named from the user's launch name, so reusing that
364 // name then failed with "branch already exists" — pointing at a
365 // worktree that no longer existed.
366 let dir = tempdir().unwrap();
367 let repo = dir.path().join("repo");
368 fs::create_dir_all(&repo).unwrap();
369 init_repo(&repo);
370
371 let wt_path = dir.path().join("wt-lane");
372 let spec = WorktreeProvision {
373 repo_root: repo.clone(),
374 branch: "codex/reused-name".into(),
375 path: wt_path.clone(),
376 base_ref: Some("main".into()),
377 };
378 provision_worktree(&spec).unwrap();
379 assert!(branch_exists(&repo, "codex/reused-name"));
380
381 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
382 assert!(!wt_path.exists());
383 assert!(
384 !branch_exists(&repo, "codex/reused-name"),
385 "an unused lane branch must not outlive its worktree"
386 );
387
388 // The whole point: the same launch name provisions again.
389 provision_worktree(&spec).expect("re-provisioning the same lane name must succeed");
390 assert!(wt_path.join("README").is_file());
391 }
392
393 #[test]
394 fn expired_cleanup_keeps_a_branch_with_unmerged_work() {
395 // A TTL timer is not a mandate to discard commits. The worktree goes;
396 // the branch carrying work stays, and the name stays taken until a
397 // human decides otherwise.
398 let dir = tempdir().unwrap();
399 let repo = dir.path().join("repo");
400 fs::create_dir_all(&repo).unwrap();
401 init_repo(&repo);
402
403 let wt_path = dir.path().join("wt-lane");
404 provision_worktree(&WorktreeProvision {
405 repo_root: repo.clone(),
406 branch: "codex/has-work".into(),
407 path: wt_path.clone(),
408 base_ref: Some("main".into()),
409 })
410 .unwrap();
411
412 fs::write(wt_path.join("work.txt"), "unmerged").unwrap();
413 for args in [
414 vec!["add", "work.txt"],
415 vec!["commit", "-m", "lane work worth keeping"],
416 ] {
417 assert!(
418 Command::new("git")
419 .args(&args)
420 .current_dir(&wt_path)
421 .status()
422 .unwrap()
423 .success()
424 );
425 }
426
427 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
428 assert!(!wt_path.exists(), "the worktree directory is disposable");
429 assert!(
430 branch_exists(&repo, "codex/has-work"),
431 "a branch with unmerged commits must survive worktree cleanup"
432 );
433 }
434
435 #[test]
436 fn ttl_cleanup_never_deletes_a_plain_directory() {
437 // #5824: a stale or malformed record pointing at an unrelated
438 // directory must not turn TTL cleanup into an unbounded recursive
439 // delete just because the TTL is zero.
440 let dir = tempdir().unwrap();
441 let precious = dir.path().join("not-a-worktree");
442 fs::create_dir_all(precious.join("nested")).unwrap();
443 fs::write(precious.join("nested/keep.txt"), "keep").unwrap();
444
445 remove_worktree_if_expired(&precious, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
446 assert!(
447 precious.exists(),
448 "git cannot identify this path as a managed worktree, so cleanup must do nothing"
449 );
450 assert_eq!(
451 fs::read_to_string(precious.join("nested/keep.txt")).unwrap(),
452 "keep"
453 );
454 }
455
456 #[test]
457 fn ttl_cleanup_never_deletes_inside_an_unrelated_repository() {
458 // Git commands succeed from within a subdirectory of some unrelated
459 // repo, but that repo does not list the subdirectory as a worktree.
460 let dir = tempdir().unwrap();
461 let repo = dir.path().join("repo");
462 fs::create_dir_all(&repo).unwrap();
463 init_repo(&repo);
464 let precious = repo.join("src");
465 fs::create_dir_all(&precious).unwrap();
466 fs::write(precious.join("keep.txt"), "keep").unwrap();
467
468 remove_worktree_if_expired(&precious, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
469 assert!(
470 precious.exists(),
471 "a subdirectory of an unrelated repo is not a managed worktree"
472 );
473 assert!(precious.join("keep.txt").exists());
474 }
475
476 #[test]
477 fn ttl_cleanup_never_deletes_a_repository_root() {
478 // The main worktree of a repo is not a linked lane worktree: its
479 // `.git` is a directory with no registration beneath
480 // `.git/worktrees/`. A record pointing there must not wipe a repo.
481 let dir = tempdir().unwrap();
482 let repo = dir.path().join("repo");
483 fs::create_dir_all(&repo).unwrap();
484 init_repo(&repo);
485
486 remove_worktree_if_expired(&repo, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
487 assert!(
488 repo.exists(),
489 "a repository root must never be recursively deleted by TTL cleanup"
490 );
491 assert!(repo.join(".git").exists());
492 }
493
494 #[test]
495 fn ttl_cleanup_leaves_a_path_swapped_after_provisioning_intact() {
496 // The record was written when the path was a managed worktree; by the
497 // time cleanup runs, the directory has been replaced by an unrelated
498 // one. Deletion must see the path as it is now, not as the record
499 // claims it was.
500 let dir = tempdir().unwrap();
501 let repo = dir.path().join("repo");
502 fs::create_dir_all(&repo).unwrap();
503 init_repo(&repo);
504 let wt_path = dir.path().join("wt-lane");
505 provision_worktree(&WorktreeProvision {
506 repo_root: repo,
507 branch: "codex/swapped".into(),
508 path: wt_path.clone(),
509 base_ref: Some("main".into()),
510 })
511 .unwrap();
512
513 fs::remove_dir_all(&wt_path).unwrap();
514 fs::create_dir_all(&wt_path).unwrap();
515 fs::write(wt_path.join("keep.txt"), "keep").unwrap();
516
517 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
518 assert!(
519 wt_path.exists(),
520 "the replacement directory is not the identified worktree and must survive"
521 );
522 assert!(wt_path.join("keep.txt").exists());
523 }
524
525 #[test]
526 fn managed_identity_holds_for_a_real_worktree_and_fails_after_a_swap() {
527 // The gate that guards the window between identification and removal:
528 // it must accept the worktree git provisioned and refuse the same
529 // path once its contents no longer resolve to that worktree.
530 let dir = tempdir().unwrap();
531 let repo = dir.path().join("repo");
532 fs::create_dir_all(&repo).unwrap();
533 init_repo(&repo);
534 let wt_path = dir.path().join("wt-lane");
535 provision_worktree(&WorktreeProvision {
536 repo_root: repo.clone(),
537 branch: "codex/identity".into(),
538 path: wt_path.clone(),
539 base_ref: Some("main".into()),
540 })
541 .unwrap();
542
543 let details = worktree_details(&wt_path).expect("a provisioned worktree identifies itself");
544 assert!(is_managed_worktree(&wt_path, &details));
545
546 fs::remove_dir_all(&wt_path).unwrap();
547 fs::create_dir_all(&wt_path).unwrap();
548 fs::write(wt_path.join("keep.txt"), "keep").unwrap();
549 assert!(
550 !is_managed_worktree(&wt_path, &details),
551 "a swapped directory no longer resolves to the identified worktree"
552 );
553 }
554 }
555
555 lines RUST