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