返回 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 pub fn remove_worktree_if_expired(
78 worktree_path: &Path,
79 ttl_secs: Option<u64>,
80 stopped_at: Option<&str>,
81 ) -> Result<()> {
82 let Some(ttl) = ttl_secs else {
83 return Ok(());
84 };
85 if !worktree_path.exists() {
86 return Ok(());
87 }
88 if ttl > 0 {
89 let Some(stopped) = stopped_at else {
90 return Ok(());
91 };
92 let stopped_ts = DateTime::parse_from_rfc3339(stopped)
93 .with_context(|| format!("parse stopped_at {stopped}"))?
94 .timestamp() as u64;
95 let now = SystemTime::now()
96 .duration_since(UNIX_EPOCH)
97 .map(|d| d.as_secs())
98 .unwrap_or(0);
99 if now.saturating_sub(stopped_ts) < ttl {
100 return Ok(());
101 }
102 }
103
104 // Ask the worktree what it is before deleting it: once the directory is
105 // gone, neither its branch nor its repository is recoverable from the path.
106 let details = worktree_details(worktree_path);
107
108 // Best-effort: git worktree remove --force, then rm -rf.
109 let removed = details.as_ref().is_some_and(|details| {
110 Command::new("git")
111 .current_dir(&details.repo_root)
112 .args([
113 "worktree",
114 "remove",
115 "--force",
116 &worktree_path.to_string_lossy(),
117 ])
118 .status()
119 .is_ok_and(|status| status.success())
120 });
121 if worktree_path.exists() {
122 fs::remove_dir_all(worktree_path)
123 .with_context(|| format!("remove worktree {}", worktree_path.display()))?;
124 }
125
126 let Some(details) = details else {
127 return Ok(());
128 };
129 if !removed {
130 // The directory is gone but git still has it registered, and
131 // `git worktree add` refuses a path it already knows about.
132 let _ = Command::new("git")
133 .current_dir(&details.repo_root)
134 .args(["worktree", "prune"])
135 .status();
136 }
137 if let Some(branch) = details.branch.as_deref() {
138 delete_lane_branch(&details.repo_root, branch);
139 }
140 Ok(())
141 }
142
143 /// What a lane worktree is: which repository owns it, and which branch it has
144 /// checked out (`None` when detached).
145 struct WorktreeDetails {
146 repo_root: PathBuf,
147 branch: Option<String>,
148 }
149
150 fn worktree_details(worktree_path: &Path) -> Option<WorktreeDetails> {
151 let listing = Command::new("git")
152 .current_dir(worktree_path)
153 .args(["worktree", "list", "--porcelain"])
154 .output()
155 .ok()
156 .filter(|output| output.status.success())?;
157 // The main worktree is listed first, so its path is the repository root.
158 let listing = String::from_utf8_lossy(&listing.stdout);
159 let repo_root = listing
160 .lines()
161 .find_map(|line| line.strip_prefix("worktree "))
162 .map(PathBuf::from)?;
163
164 let branch = Command::new("git")
165 .current_dir(worktree_path)
166 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
167 .output()
168 .ok()
169 .filter(|output| output.status.success())
170 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
171 .filter(|branch| !branch.is_empty());
172
173 Some(WorktreeDetails { repo_root, branch })
174 }
175
176 /// Delete the branch a removed lane worktree was on.
177 ///
178 /// Lane branch names are derived from the user's launch name (`codex/{slug}`),
179 /// not from a UUID, so leaving the branch behind makes reusing that name fail
180 /// with "branch already exists" — a worktree directory that no longer exists
181 /// still blocking a legitimate lane.
182 ///
183 /// This uses `branch -d`, not `-D`: a lane branch with nothing on it beyond
184 /// its base is merged and deletes cleanly, which is the case that was broken.
185 /// A branch carrying unmerged commits is someone's work, and a TTL timer is
186 /// not a mandate to throw it away — that one is kept, and the name stays taken
187 /// until a human decides otherwise.
188 fn delete_lane_branch(repo_root: &Path, branch: &str) {
189 let output = Command::new("git")
190 .current_dir(repo_root)
191 .args(["branch", "-d", branch])
192 .output();
193 match output {
194 Ok(output) if output.status.success() => {}
195 Ok(output) => {
196 tracing::debug!(
197 "kept lane branch {branch} after worktree cleanup: {}",
198 String::from_utf8_lossy(&output.stderr).trim()
199 );
200 }
201 Err(err) => {
202 tracing::debug!("could not delete lane branch {branch}: {err}");
203 }
204 }
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210 use std::process::Command;
211 use tempfile::tempdir;
212
213 fn init_repo(root: &Path) {
214 assert!(
215 Command::new("git")
216 .args(["init", "-b", "main"])
217 .current_dir(root)
218 .status()
219 .unwrap()
220 .success()
221 );
222 assert!(
223 Command::new("git")
224 .args(["config", "user.email", "lane@test"])
225 .current_dir(root)
226 .status()
227 .unwrap()
228 .success()
229 );
230 assert!(
231 Command::new("git")
232 .args(["config", "user.name", "lane"])
233 .current_dir(root)
234 .status()
235 .unwrap()
236 .success()
237 );
238 fs::write(root.join("README"), "lane").unwrap();
239 assert!(
240 Command::new("git")
241 .args(["add", "README"])
242 .current_dir(root)
243 .status()
244 .unwrap()
245 .success()
246 );
247 assert!(
248 Command::new("git")
249 .args(["commit", "-m", "init"])
250 .current_dir(root)
251 .status()
252 .unwrap()
253 .success()
254 );
255 }
256
257 #[test]
258 fn provision_and_ttl_zero_cleanup() {
259 let dir = tempdir().unwrap();
260 let repo = dir.path().join("repo");
261 fs::create_dir_all(&repo).unwrap();
262 init_repo(&repo);
263 let wt_path = dir.path().join("wt-lane");
264 let provisioned = provision_worktree(&WorktreeProvision {
265 repo_root: repo,
266 branch: "codex/lane-test".into(),
267 path: wt_path.clone(),
268 base_ref: Some("main".into()),
269 })
270 .unwrap();
271 assert!(provisioned.path.is_dir());
272 assert!(wt_path.join("README").is_file());
273
274 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
275 assert!(
276 !wt_path.exists(),
277 "TTL 0 should remove worktree immediately"
278 );
279 }
280
281 fn branch_exists(repo: &Path, branch: &str) -> bool {
282 Command::new("git")
283 .current_dir(repo)
284 .args(["rev-parse", "--verify", "--quiet", branch])
285 .status()
286 .unwrap()
287 .success()
288 }
289
290 #[test]
291 fn expired_cleanup_deletes_the_branch_so_the_lane_name_is_reusable() {
292 // #4731: cleanup removed the worktree directory but left the branch.
293 // Lane branches are named from the user's launch name, so reusing that
294 // name then failed with "branch already exists" — pointing at a
295 // worktree that no longer existed.
296 let dir = tempdir().unwrap();
297 let repo = dir.path().join("repo");
298 fs::create_dir_all(&repo).unwrap();
299 init_repo(&repo);
300
301 let wt_path = dir.path().join("wt-lane");
302 let spec = WorktreeProvision {
303 repo_root: repo.clone(),
304 branch: "codex/reused-name".into(),
305 path: wt_path.clone(),
306 base_ref: Some("main".into()),
307 };
308 provision_worktree(&spec).unwrap();
309 assert!(branch_exists(&repo, "codex/reused-name"));
310
311 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
312 assert!(!wt_path.exists());
313 assert!(
314 !branch_exists(&repo, "codex/reused-name"),
315 "an unused lane branch must not outlive its worktree"
316 );
317
318 // The whole point: the same launch name provisions again.
319 provision_worktree(&spec).expect("re-provisioning the same lane name must succeed");
320 assert!(wt_path.join("README").is_file());
321 }
322
323 #[test]
324 fn expired_cleanup_keeps_a_branch_with_unmerged_work() {
325 // A TTL timer is not a mandate to discard commits. The worktree goes;
326 // the branch carrying work stays, and the name stays taken until a
327 // human decides otherwise.
328 let dir = tempdir().unwrap();
329 let repo = dir.path().join("repo");
330 fs::create_dir_all(&repo).unwrap();
331 init_repo(&repo);
332
333 let wt_path = dir.path().join("wt-lane");
334 provision_worktree(&WorktreeProvision {
335 repo_root: repo.clone(),
336 branch: "codex/has-work".into(),
337 path: wt_path.clone(),
338 base_ref: Some("main".into()),
339 })
340 .unwrap();
341
342 fs::write(wt_path.join("work.txt"), "unmerged").unwrap();
343 for args in [
344 vec!["add", "work.txt"],
345 vec!["commit", "-m", "lane work worth keeping"],
346 ] {
347 assert!(
348 Command::new("git")
349 .args(&args)
350 .current_dir(&wt_path)
351 .status()
352 .unwrap()
353 .success()
354 );
355 }
356
357 remove_worktree_if_expired(&wt_path, Some(0), Some("2020-01-01T00:00:00Z")).unwrap();
358 assert!(!wt_path.exists(), "the worktree directory is disposable");
359 assert!(
360 branch_exists(&repo, "codex/has-work"),
361 "a branch with unmerged commits must survive worktree cleanup"
362 );
363 }
364 }
365
365 lines RUST