| ... | ... | @@ -271,6 +271,63 @@ const make = Effect.gen(function* () { |
| 271 | 271 | return false; |
| 272 | 272 | }); |
| 273 | 273 | |
| 274 | // Whether cwd stopped being a checkout of this branch. Two small Git reads, |
| 275 | // since every worktree in a sweep is asked; statusDetailsLocal also diffs and |
| 276 | // counts. |
| 277 | const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( |
| 278 | cwd: string, |
| 279 | branch: string | null, |
| 280 | ) { |
| 281 | const failure = (detail: string) => |
| 282 | new GitCommandError({ |
| 283 | operation: "StorageCleanup.branchChanged", |
| 284 | command: "git", |
| 285 | cwd, |
| 286 | detail, |
| 287 | }); |
| 288 | // Null when cwd is not a repository, or is gone. Git's own messages are |
| 289 | // matched, so they must not be translated. |
| 290 | const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { |
| 291 | const result = yield* git |
| 292 | .execute({ |
| 293 | operation: "StorageCleanup.branchChanged", |
| 294 | cwd, |
| 295 | args, |
| 296 | env: { LC_ALL: "C" }, |
| 297 | allowNonZeroExit: true, |
| 298 | }) |
| 299 | .pipe( |
| 300 | Effect.catch((error) => |
| 301 | Effect.flatMap(fs.exists(cwd), (exists) => |
| 302 | exists ? Effect.fail(error) : Effect.succeed(null), |
| 303 | ), |
| 304 | ), |
| 305 | ); |
| 306 | if (result === null) return null; |
| 307 | if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; |
| 308 | if (result.stderr.toLowerCase().includes("not a git repository")) return null; |
| 309 | return yield* failure( |
| 310 | result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", |
| 311 | ); |
| 312 | }); |
| 313 | const index = yield* read(["rev-parse", "--git-path", "index"]); |
| 314 | if (index === null) return true; |
| 315 | // Another Git process is writing to this checkout. |
| 316 | if (yield* fs.exists(`${path.resolve(cwd, index.stdout.trim())}.lock`)) |
| 317 | return yield* failure( |
| 318 | "Git index is locked. Status will resume when the index lock is removed.", |
| 319 | ); |
| 320 | // Exits 1 for a detached HEAD. Reads the full ref name, which is never |
| 321 | // shortened to "heads/<branch>" when a tag shares the branch name. |
| 322 | const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); |
| 323 | if (head === null) return true; |
| 324 | const ref = head.exitCode === 0 ? head.stdout.trim() : ""; |
| 325 | const name = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length).trim() : null; |
| 326 | // Status output writes a detached HEAD as "(detached)", so a branch named |
| 327 | // with a leading "(" has always been read as one. |
| 328 | return (name?.startsWith("(") ? null : name) !== branch; |
| 329 | }); |
| 330 | |
| 274 | 331 | const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( |
| 275 | 332 | cwd: string, |
| 276 | 333 | rules: WorktreeCleanupRules, |
| ... | ... | @@ -415,8 +472,7 @@ const make = Effect.gen(function* () { |
| 415 | 472 | // A linked worktree has a .git file. Never remove a main checkout. |
| 416 | 473 | if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") |
| 417 | 474 | return keep("not a linked worktree"); |
| 418 | | const status = yield* git.statusDetailsLocal(worktreePath); |
| 419 | | if (!status.isRepo || status.branch !== thread.branch) |
| 475 | if (yield* branchChanged(worktreePath, thread.branch)) |
| 420 | 476 | return keep("repository or branch changed"); |
| 421 | 477 | const changes = yield* localChangesReason(worktreePath, settings); |
| 422 | 478 | if (changes !== null) return keep(changes); |
| ... | ... | @@ -480,55 +536,64 @@ const make = Effect.gen(function* () { |
| 480 | 536 | ? "not merged" |
| 481 | 537 | : "has commits beyond the default branch", |
| 482 | 538 | ); |
| 483 | | const bytes = yield* measureWorktree(worktreePath); |
| 484 | | // Re-read after Git/host calls and size measurement so a queued turn, |
| 485 | | // resumed session or new thread sharing this path cancels the removal. |
| 486 | | const latestSnapshot = yield* readThreads(); |
| 487 | | if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) |
| 488 | | return keep("contains a project checkout"); |
| 489 | | const latest = latestSnapshot.threads.filter( |
| 490 | | (entry) => |
| 491 | | entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, |
| 492 | | ); |
| 493 | | if (hasTerminal(worktreePath)) return keep("open terminal"); |
| 494 | | if (deleted) { |
| 495 | | if ( |
| 496 | | latest.length > 0 || |
| 497 | | !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) |
| 498 | | .worktreeOnDelete |
| 539 | // Threads, queued deletion work and provider sessions as they are now, so a |
| 540 | // queued turn, resumed session or new thread sharing this path cancels |
| 541 | // the removal. |
| 542 | const blockedSinceCheck = Effect.fnUntraced(function* () { |
| 543 | const latestSnapshot = yield* readThreads(); |
| 544 | if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) |
| 545 | return "contains a project checkout"; |
| 546 | const latest = latestSnapshot.threads.filter( |
| 547 | (entry) => |
| 548 | entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, |
| 549 | ); |
| 550 | if (hasTerminal(worktreePath)) return "open terminal"; |
| 551 | if (deleted) { |
| 552 | if ( |
| 553 | latest.length > 0 || |
| 554 | !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) |
| 555 | .worktreeOnDelete |
| 556 | ) |
| 557 | return "thread or cleanup settings changed since check"; |
| 558 | // V2 deletion queues durable cleanup. Do not remove its checkout until |
| 559 | // every effect has finished successfully or was explicitly cancelled. |
| 560 | const pendingCleanup = yield* sql` |
| 561 | SELECT 1 FROM orchestration_v2_effect_outbox |
| 562 | WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 |
| 563 | `; |
| 564 | if (pendingCleanup.length > 0) return "thread deletion is still pending"; |
| 565 | } else if ( |
| 566 | latest.length !== 1 || |
| 567 | latest[0]!.id !== thread.id || |
| 568 | !storageCleanupThreadIdle(latest[0]!, now) || |
| 569 | storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) |
| 499 | 570 | ) |
| 500 | | return keep("thread or cleanup settings changed since check"); |
| 501 | | // V2 deletion queues durable cleanup. Do not remove its checkout until |
| 502 | | // every effect has finished successfully or was explicitly cancelled. |
| 503 | | const pendingCleanup = yield* sql` |
| 504 | | SELECT 1 FROM orchestration_v2_effect_outbox |
| 505 | | WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 |
| 571 | return "thread activity or shared worktree changed since check"; |
| 572 | // Sessions can outlive their run and can be shared across app threads. |
| 573 | const sessionRows = yield* sql<{ payload_json: string }>` |
| 574 | SELECT payload_json FROM orchestration_v2_projection_provider_sessions |
| 575 | WHERE status != 'stopped' |
| 506 | 576 | `; |
| 507 | | if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); |
| 508 | | } else if ( |
| 509 | | latest.length !== 1 || |
| 510 | | latest[0]!.id !== thread.id || |
| 511 | | !storageCleanupThreadIdle(latest[0]!, now) || |
| 512 | | storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) |
| 513 | | ) |
| 514 | | return keep("thread activity or shared worktree changed since check"); |
| 515 | | // Sessions can outlive their run and can be shared across app threads. |
| 516 | | const sessionRows = yield* sql<{ payload_json: string }>` |
| 517 | | SELECT payload_json FROM orchestration_v2_projection_provider_sessions |
| 518 | | WHERE status != 'stopped' |
| 519 | | `; |
| 520 | | const sessions = yield* Effect.forEach(sessionRows, (row) => |
| 521 | | decodeCleanupSession(row.payload_json), |
| 522 | | ); |
| 523 | | if ( |
| 524 | | sessions.some((session) => { |
| 525 | | const cwd = path.resolve(session.cwd); |
| 526 | | return cwd === worktreePath || inside(worktreePath, cwd); |
| 527 | | }) |
| 528 | | ) |
| 529 | | return keep("provider session is still open"); |
| 530 | | const finalStatus = yield* git.statusDetailsLocal(worktreePath); |
| 531 | | if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) |
| 577 | const sessions = yield* Effect.forEach(sessionRows, (row) => |
| 578 | decodeCleanupSession(row.payload_json), |
| 579 | ); |
| 580 | if ( |
| 581 | sessions.some((session) => { |
| 582 | const cwd = path.resolve(session.cwd); |
| 583 | return cwd === worktreePath || inside(worktreePath, cwd); |
| 584 | }) |
| 585 | ) |
| 586 | return "provider session is still open"; |
| 587 | return null; |
| 588 | }); |
| 589 | // Most cancelled removals are caught here, before the worktree is walked. |
| 590 | const blocked = yield* blockedSinceCheck(); |
| 591 | if (blocked !== null) return keep(blocked); |
| 592 | const bytes = yield* measureWorktree(worktreePath); |
| 593 | // The walk can take a while; ask again for anything that started during it. |
| 594 | const blockedWhileMeasuring = yield* blockedSinceCheck(); |
| 595 | if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring); |
| 596 | if (yield* branchChanged(worktreePath, thread.branch)) |
| 532 | 597 | return keep("repository or branch changed since check"); |
| 533 | 598 | if ( |
| 534 | 599 | (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== |