Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2101,6 +2101,36 @@ describe("storage cleanup reports and local file policies", () => {
}),
),
);
it.live.each([
["unchanged", "removed", "No commits beyond the project branch"],
["unmerged", "kept", "Has commits beyond the project branch"],
["detached", "kept", "Project branch is unavailable"],
["merged-only", "kept", "No remote is configured for merged-worktree cleanup"],
] as const)("cleans archived local-only worktrees: %s", ([state, outcome, reason]) =>
runCleanupTest(
Effect.gen(function* () {
const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({
...DEFAULT_SERVER_SETTINGS.storageCleanup,
worktreeUnchanged: state !== "merged-only",
worktreeOnMerge: true,
});
yield* command(repo, ["branch", "-m", "main", "trunk"]);
setThreads([shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) })]);
if (state === "unmerged") {
yield* fs.writeFileString(`${worktree}/tracked.txt`, "unmerged work\n");
yield* command(worktree, ["commit", "-am", "unmerged work"]);
}
if (state === "detached") yield* command(repo, ["checkout", "--detach"]);

const report = yield* service.runNow;
expect(report.entries[0]).toMatchObject({ outcome, reason });
expect(yield* fs.exists(worktree)).toBe(outcome === "kept");
expect(
(yield* command(repo, ["show-ref", "--verify", "refs/heads/feature"])).stdout,
).not.toBe("");
}),
),
);
it.live(
"discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree",
() =>
Expand Down
101 changes: 65 additions & 36 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,42 +892,66 @@ const make = Effect.gen(function* () {
: `settled for ${settledDays} days`;
if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
const repositoryCwd = path.resolve(project.workspaceRoot);
const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
const defaultBranchKey = `${repositoryCwd}\0${remote}`;
let branch = defaultBranches.get(defaultBranchKey);
if (branch === undefined) {
branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
// Without a local HEAD ref, only the remote can identify its default;
// a conventional name may be a feature branch.
if (branch === null) {
branch = yield* git
.execute({
operation: "StorageCleanup.resolveDefaultBranch",
cwd: repositoryCwd,
args: ["ls-remote", "--symref", "--", remote, "HEAD"],
timeoutMs: 20_000,
})
.pipe(
Effect.map(
(result) =>
/^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null,
),
Effect.orElseSucceed(() => null),
);
}
defaultBranches.set(defaultBranchKey, branch);
}
if (branch === null) return keep("default branch is unavailable");
const defaultRef = `refs/remotes/${remote}/${branch}`;
const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>();
if (!refreshed.has(defaultRef)) {
yield* git.fetchRemoteTrackingBranch({
const remotes = yield* git.execute({
operation: "StorageCleanup.listRemotes",
cwd: repositoryCwd,
args: ["remote"],
});
const remote =
remotes.stdout.trim() === ""
? null
: yield* git.resolvePrimaryRemoteName(repositoryCwd);
let branch: string | null | undefined;
let defaultRef: string;
if (remote === null) {
if (!settings.worktreeUnchanged)
return keep("no remote is configured for merged-worktree cleanup");
const projectHead = yield* git.execute({
operation: "StorageCleanup.resolveProjectBranch",
cwd: repositoryCwd,
remoteName: remote,
remoteBranch: branch,
args: ["symbolic-ref", "--quiet", "HEAD"],
allowNonZeroExit: true,
});
refreshed.add(defaultRef);
refreshedDefaultRefs.set(repositoryCwd, refreshed);
if (projectHead.exitCode !== 0) return keep("project branch is unavailable");
defaultRef = projectHead.stdout.trim();
branch = defaultRef.slice("refs/heads/".length);
Comment thread
melvinmng marked this conversation as resolved.
Resolved
} else {
const defaultBranchKey = `${repositoryCwd}\0${remote}`;
branch = defaultBranches.get(defaultBranchKey);
if (branch === undefined) {
branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
// Without a local HEAD ref, only the remote can identify its default;
// a conventional name may be a feature branch.
if (branch === null) {
branch = yield* git
.execute({
operation: "StorageCleanup.resolveDefaultBranch",
cwd: repositoryCwd,
args: ["ls-remote", "--symref", "--", remote, "HEAD"],
timeoutMs: 20_000,
})
.pipe(
Effect.map(
(result) =>
/^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null,
),
Effect.orElseSucceed(() => null),
);
}
defaultBranches.set(defaultBranchKey, branch);
}
if (branch === null) return keep("default branch is unavailable");
defaultRef = `refs/remotes/${remote}/${branch}`;
const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>();
if (!refreshed.has(defaultRef)) {
yield* git.fetchRemoteTrackingBranch({
cwd: repositoryCwd,
remoteName: remote,
remoteBranch: branch,
});
refreshed.add(defaultRef);
refreshedDefaultRefs.set(repositoryCwd, refreshed);
}
}
const base = yield* git.resolveCommit({
cwd: worktreePath,
Expand All @@ -941,8 +965,12 @@ const make = Effect.gen(function* () {
});
const integrated = ancestor.exitCode === 0;
eligible = integrated && settings.worktreeUnchanged;
if (eligible) removalReason = "no commits beyond the default branch";
if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
if (eligible)
removalReason =
remote === null
? "no commits beyond the project branch"
: "no commits beyond the default branch";
if (!eligible && remote !== null && settings.worktreeOnMerge && thread.branch !== null) {
const pullRequest = yield* gitManager.branchPullRequest(
{ cwd: worktreePath, branch: thread.branch },
{ refresh: true },
Expand All @@ -955,6 +983,7 @@ const make = Effect.gen(function* () {
});
if (eligible) removalReason = "pull request was merged";
}
if (!eligible && remote === null) return keep("has commits beyond the project branch");
}
if (!eligible)
return keep(
Expand Down
4 changes: 3 additions & 1 deletion docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ captures and log retention remain machine-wide.

Worktrees can be removed after a chosen number of inactive days, after a thread settles, after
merging, or when they have no commits beyond the default branch. Set settled retention to 0 days
to remove a worktree at the next cleanup after settlement. Only T3-managed worktrees are eligible.
to remove a worktree at the next cleanup after settlement. In repositories without remotes,
unchanged-worktree cleanup compares against the branch checked out in the project workspace;
if that checkout has a detached HEAD, the worktree is kept. Only T3-managed worktrees are eligible.
Active sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults
to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored
files such as `.env` and build output. **Any local files** also protects ignored files other than
Expand Down
Loading