( cutoffDate: Date, )
| 1056 | * dir), it's left in place — a later readdir finding it stale again is harmless. |
| 1057 | */ |
| 1058 | export async function cleanupStaleAgentWorktrees( |
| 1059 | cutoffDate: Date, |
| 1060 | ): Promise<number> { |
| 1061 | const gitRoot = findCanonicalGitRoot(getCwd()) |
| 1062 | if (!gitRoot) { |
| 1063 | return 0 |
| 1064 | } |
| 1065 | |
| 1066 | const dir = worktreesDir(gitRoot) |
| 1067 | let entries: string[] |
| 1068 | try { |
| 1069 | entries = await readdir(dir) |
| 1070 | } catch { |
| 1071 | return 0 |
| 1072 | } |
| 1073 | |
| 1074 | const cutoffMs = cutoffDate.getTime() |
| 1075 | const currentPath = currentWorktreeSession?.worktreePath |
| 1076 | let removed = 0 |
| 1077 | |
| 1078 | for (const slug of entries) { |
| 1079 | if (!EPHEMERAL_WORKTREE_PATTERNS.some(p => p.test(slug))) { |
| 1080 | continue |
| 1081 | } |
| 1082 | |
| 1083 | const worktreePath = join(dir, slug) |
| 1084 | if (currentPath === worktreePath) { |
| 1085 | continue |
| 1086 | } |
| 1087 | |
| 1088 | let mtimeMs: number |
| 1089 | try { |
| 1090 | mtimeMs = (await stat(worktreePath)).mtimeMs |
| 1091 | } catch { |
| 1092 | continue |
| 1093 | } |
| 1094 | if (mtimeMs >= cutoffMs) { |
| 1095 | continue |
| 1096 | } |
| 1097 | |
| 1098 | // Both checks must succeed with empty output. Non-zero exit (corrupted |
| 1099 | // worktree, git not recognizing it, etc.) means skip — we don't know |
| 1100 | // what's in there. |
| 1101 | const [status, unpushed] = await Promise.all([ |
| 1102 | execFileNoThrowWithCwd( |
| 1103 | gitExe(), |
| 1104 | ['--no-optional-locks', 'status', '--porcelain', '-uno'], |
| 1105 | { cwd: worktreePath }, |
| 1106 | ), |
| 1107 | execFileNoThrowWithCwd( |
| 1108 | gitExe(), |
| 1109 | ['rev-list', '--max-count=1', 'HEAD', '--not', '--remotes'], |
| 1110 | { cwd: worktreePath }, |
| 1111 | ), |
| 1112 | ]) |
| 1113 | if (status.code !== 0 || status.stdout.trim().length > 0) { |
| 1114 | continue |
| 1115 | } |
no test coverage detected