| 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 |