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
6 changes: 6 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
url: "https://github.com/pingdotgg/t3code/pull/216",
baseRefName: "main",
headRefName: "feature/saved-branch",
headRefOid: "a".repeat(40),
state: "OPEN",
updatedAt: "2026-04-03T15:00:00Z",
},
Expand All @@ -1387,6 +1388,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
url: "https://github.com/pingdotgg/t3code/pull/216",
baseRef: "main",
headRef: "feature/saved-branch",
headSha: "a".repeat(40),
state: "open",
closedAt: null,
mergedAt: null,
Expand Down Expand Up @@ -1664,6 +1666,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
url: "https://github.com/pingdotgg/codething-mvp/pull/220",
baseRefName: "main",
headRefName: "feature/shared-pr-cache",
headRefOid: "a".repeat(40),
state: "MERGED",
updatedAt: "2026-04-07T15:00:00Z",
},
Expand All @@ -1675,6 +1678,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
url: "https://github.com/pingdotgg/codething-mvp/pull/221",
baseRefName: "main",
headRefName: "feature/shared-pr-cache",
headRefOid: "b".repeat(40),
state: "OPEN",
updatedAt: "2026-04-08T15:00:00Z",
},
Expand All @@ -1691,6 +1695,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {

expect(status.pr?.state).toBe("merged");
expect(pullRequest?.state).toBe("merged");
expect(pullRequest?.headSha).toBe("a".repeat(40));
expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
const refreshed = yield* manager.branchPullRequest(
{ cwd: repoDir, branch: "feature/shared-pr-cache" },
Expand All @@ -1699,6 +1704,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
expect(refreshed).toMatchObject({
number: 221,
state: "open",
headSha: "b".repeat(40),
repositoryKey: "github.com/pingdotgg/codething-mvp",
});
expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {
readonly updatedAt: string | null;
readonly closedAt?: string | null;
readonly mergedAt?: string | null;
/** The pull request's head commit, when the host read reports it. */
readonly headSha?: string | null;
};

interface SourceControlTextGenerationSettings {
Expand Down Expand Up @@ -201,6 +203,7 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
closedAt?: string | null;
mergedAt?: string | null;
updatedAt: Option.Option<DateTime.Utc>;
headSha?: string | undefined;
}

const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
Expand Down Expand Up @@ -460,6 +463,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
url: summary.url,
baseRefName: summary.baseRefName,
headRefName: summary.headRefName,
...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
state: summary.state ?? "open",
...(summary.isDraft === true ? { isDraft: true } : {}),
closedAt: summary.closedAt ?? null,
Expand Down Expand Up @@ -2321,6 +2325,7 @@ export const make = Effect.gen(function* () {
...toStatusPr(latest),
closedAt: latest.closedAt ?? null,
mergedAt: latest.mergedAt ?? null,
headSha: latest.headSha ?? null,
// Hosting CLIs can select an upstream repository instead of origin.
// The returned PR URL names the repository that actually owns it.
repositoryKey: pullRequestRepositoryKey(latest.url),
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,12 @@ describe("GitHubCli.listPullRequestsByHead", () => {
Effect.sync(() => {
documents.push(input);
return encodeJson({
data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } },
data: {
repository: {
h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] },
h1: { nodes: [] },
},
},
});
}),
},
Expand Down Expand Up @@ -288,8 +293,10 @@ describe("GitHubCli.listPullRequestsByHead", () => {
first?.map((pr) => pr.number),
[7],
);
assert.strictEqual(first?.[0]?.headSha, "a".repeat(40));
assert.deepStrictEqual(second, []);
assert.strictEqual(documents.length, 1);
assert.include(documents[0]!.query, "headRefOid");
assert.deepStrictEqual(documents[0]!.variables, {
owner: "acme",
name: "web",
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export interface GitHubPullRequestSummary {
readonly url: string;
readonly baseRefName: string;
readonly headRefName: string;
readonly headSha?: string;
readonly state?: "open" | "closed" | "merged";
readonly isDraft?: boolean;
readonly closedAt?: string | null;
Expand Down Expand Up @@ -337,7 +338,7 @@ type PullRequestListState = "open" | "closed" | "merged" | "all";

/** The pull request fields every read selects, in GraphQL. */
const PULL_REQUEST_NODE_SELECTION =
"number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
"number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
open: ["OPEN"],
closed: ["CLOSED"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ it.effect("lists change request history through the batched head lookup", () =>
url: "https://enterprise.test/acme/web/pull/7",
baseRefName: "main",
headRefName: "feature/merged",
headSha: "a".repeat(40),
state: "merged",
mergedAt: "2026-01-01T00:00:00Z",
updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
Expand Down Expand Up @@ -171,6 +172,7 @@ it.effect("lists change request history through the batched head lookup", () =>
});
assert.strictEqual(changeRequests[0]?.provider, "github");
assert.strictEqual(changeRequests[0]?.state, "merged");
assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40));
assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");
assert.deepStrictEqual(
changeRequests[0]?.updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
url: summary.url,
baseRefName: summary.baseRefName,
headRefName: summary.headRefName,
...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
state: summary.state ?? "open",
...(summary.isDraft === true ? { isDraft: true } : {}),
closedAt: summary.closedAt ?? null,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/sourceControl/gitHubPullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface NormalizedGitHubPullRequestRecord {
readonly url: string;
readonly baseRefName: string;
readonly headRefName: string;
/** The head commit, when the read asked for `headRefOid`. */
readonly headSha?: string;
readonly state: "open" | "closed" | "merged";
readonly isDraft?: boolean;
readonly closedAt?: string | null;
Expand All @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({
url: TrimmedNonEmptyString,
baseRefName: TrimmedNonEmptyString,
headRefName: TrimmedNonEmptyString,
headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
state: Schema.optional(Schema.NullOr(Schema.String)),
isDraft: Schema.optional(Schema.Boolean),
closedAt: Schema.optional(Schema.NullOr(Schema.String)),
Expand Down Expand Up @@ -90,13 +93,15 @@ function normalizeGitHubPullRequestRecord(
(headRepositoryOwnerLogin && headRepositoryName
? `${headRepositoryOwnerLogin}/${headRepositoryName}`
: null);
const headSha = trimOptionalString(raw.headRefOid);

return {
number: raw.number,
title: raw.title,
url: raw.url,
baseRefName: raw.baseRefName,
headRefName: raw.headRefName,
...(headSha ? { headSha } : {}),
state: normalizeGitHubPullRequestState(raw),
...(raw.isDraft === true ? { isDraft: true } : {}),
closedAt: raw.closedAt ?? null,
Expand Down
52 changes: 51 additions & 1 deletion apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./storageCleanup.ts";
import {
storageCleanupActivityAt,
storageCleanupPullRequestMerged,
storageCleanupThreadIdle,
} from "./storageCleanup.ts";

const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
const DAY_MS = 24 * 60 * 60 * 1_000;
Expand Down Expand Up @@ -104,3 +108,49 @@ describe("V2 storage cleanup eligibility", () => {
return { ...candidate(), status };
}
});

describe("merged pull request cleanup", () => {
const HEAD_SHA = "a".repeat(40);
const integrated = {
branch: "feature",
defaultBranch: "main",
headSha: HEAD_SHA,
integrated: true,
};
const squashed = { ...integrated, integrated: false };
const pullRequest = (
overrides: Partial<NonNullable<Parameters<typeof storageCleanupPullRequestMerged>[0]>> = {},
) => ({
state: "merged" as const,
headRef: "feature",
baseRef: "main",
headSha: HEAD_SHA,
...overrides,
});

it("removes a worktree whose head reached the default branch through a merged pull request", () => {
expect(storageCleanupPullRequestMerged(pullRequest({ headSha: null }), integrated)).toBe(true);
});

it("removes a squash-merged worktree when the pull request names its exact head", () => {
expect(storageCleanupPullRequestMerged(pullRequest(), squashed)).toBe(true);
});

it.each([
["has a later commit than the merged head", { headSha: "c".repeat(40) }],
["was merged into a release branch", { baseRef: "release" }],
["was merged into its stack parent", { baseRef: "stack-parent" }],
["was merged without a reported head commit", { headSha: null }],
["belongs to a different branch", { headRef: "other" }],
["is still open", { state: "open" }],
["was closed without merging", { state: "closed" }],
] as const)("keeps a squash worktree whose pull request %s", (_name, overrides) => {
expect(storageCleanupPullRequestMerged(pullRequest(overrides), squashed)).toBe(false);
});

it("keeps a worktree with no pull request, or one that is not merged", () => {
expect(storageCleanupPullRequestMerged(null, squashed)).toBe(false);
expect(storageCleanupPullRequestMerged(null, integrated)).toBe(false);
expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false);
});
});
37 changes: 34 additions & 3 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,32 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu
);
}

/**
* Whether the host's pull request proves this worktree's head was merged. A
* squash or rebase merge leaves the head outside the default branch, so the
* merged pull request then has to name this exact commit.
*/
export function storageCleanupPullRequestMerged(
pullRequest: Pick<
GitManager.GitBranchPullRequest,
"state" | "headRef" | "baseRef" | "headSha"
> | null,
worktree: {
readonly branch: string;
readonly defaultBranch: string;
readonly headSha: string;
readonly integrated: boolean;
},
): boolean {
return (
pullRequest?.state === "merged" &&
(worktree.integrated ||
(pullRequest.headRef === worktree.branch &&
pullRequest.baseRef === worktree.defaultBranch &&
pullRequest.headSha === worktree.headSha))
);
}

export const make = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const settingsService = yield* Settings.ServerSettingsService;
Expand Down Expand Up @@ -295,14 +321,19 @@ export const make = Effect.gen(function* () {
args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha],
allowNonZeroExit: true,
});
if (ancestor.exitCode !== 0) return;
eligible = settings.worktreeUnchanged;
const integrated = ancestor.exitCode === 0;
eligible = integrated && settings.worktreeUnchanged;
if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
const pullRequest = yield* gitManager.branchPullRequest(
{ cwd: worktreePath, branch: thread.branch },
{ refresh: true },
);
eligible = pullRequest?.state === "merged";
eligible = storageCleanupPullRequestMerged(pullRequest, {
branch: thread.branch,
defaultBranch: branch,
headSha: head.commitSha,
integrated,
});
}
}
if (!eligible) return;
Expand Down
5 changes: 3 additions & 2 deletions docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ Worktrees can be removed after a chosen number of inactive days, after merging,
have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active
sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules`
prevent removal. Branches and thread history stay; starting another turn recreates the checkout.
Merge cleanup requires the commits to be included in the remote default branch, so squash merges
may need the inactivity rule instead.
Merge cleanup requires a merged pull request whose commits are included in the remote default
branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default
branch and the worktree is still at the pull request's last commit.

Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last
thread is deleted, including archived threads and worktrees left by earlier deletions. The
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({
url: Schema.String,
baseRefName: TrimmedNonEmptyString,
headRefName: TrimmedNonEmptyString,
/** The head commit, when the provider's read includes it. */
headSha: Schema.optional(TrimmedNonEmptyString),
state: ChangeRequestState,
/** Present when the provider can tell that an open change request is still a draft. */
isDraft: Schema.optional(Schema.Boolean),
Expand Down
Loading