authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-10-07 18:21:43+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 10:21:43-07:00
log93e864042cf066f4cdb3efe65dbefaaf0d168894
tree042911da4bfd55f52c15382382db8511094a2d5b
parentd9068d38099fe5cb4f3fed4cb5455fee878db491
signature Signed by PGP key B5690EEEBB952194

fix(server): merged-worktree cleanup removes worktrees after squash merges (#14847)


11 files changed, 119 insertions(+), 8 deletions(-)

apps/server/src/git/GitManager.test.ts+6
...@@ -1368,6 +1368,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1368,6 +1368,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1368 url: "https://github.com/pingdotgg/t3code/pull/216",1368 url: "https://github.com/pingdotgg/t3code/pull/216",
1369 baseRefName: "main",1369 baseRefName: "main",
1370 headRefName: "feature/saved-branch",1370 headRefName: "feature/saved-branch",
1371 headRefOid: "a".repeat(40),
1371 state: "OPEN",1372 state: "OPEN",
1372 updatedAt: "2026-04-03T15:00:00Z",1373 updatedAt: "2026-04-03T15:00:00Z",
1373 },1374 },
...@@ -1387,6 +1388,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1387,6 +1388,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1387 url: "https://github.com/pingdotgg/t3code/pull/216",1388 url: "https://github.com/pingdotgg/t3code/pull/216",
1388 baseRef: "main",1389 baseRef: "main",
1389 headRef: "feature/saved-branch",1390 headRef: "feature/saved-branch",
1391 headSha: "a".repeat(40),
1390 state: "open",1392 state: "open",
1391 closedAt: null,1393 closedAt: null,
1392 mergedAt: null,1394 mergedAt: null,
...@@ -1664,6 +1666,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1664,6 +1666,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1664 url: "https://github.com/pingdotgg/codething-mvp/pull/220",1666 url: "https://github.com/pingdotgg/codething-mvp/pull/220",
1665 baseRefName: "main",1667 baseRefName: "main",
1666 headRefName: "feature/shared-pr-cache",1668 headRefName: "feature/shared-pr-cache",
1669 headRefOid: "a".repeat(40),
1667 state: "MERGED",1670 state: "MERGED",
1668 updatedAt: "2026-04-07T15:00:00Z",1671 updatedAt: "2026-04-07T15:00:00Z",
1669 },1672 },
...@@ -1675,6 +1678,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1675,6 +1678,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1675 url: "https://github.com/pingdotgg/codething-mvp/pull/221",1678 url: "https://github.com/pingdotgg/codething-mvp/pull/221",
1676 baseRefName: "main",1679 baseRefName: "main",
1677 headRefName: "feature/shared-pr-cache",1680 headRefName: "feature/shared-pr-cache",
1681 headRefOid: "b".repeat(40),
1678 state: "OPEN",1682 state: "OPEN",
1679 updatedAt: "2026-04-08T15:00:00Z",1683 updatedAt: "2026-04-08T15:00:00Z",
1680 },1684 },
...@@ -1691,6 +1695,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1691,6 +1695,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
16911695
1692 expect(status.pr?.state).toBe("merged");1696 expect(status.pr?.state).toBe("merged");
1693 expect(pullRequest?.state).toBe("merged");1697 expect(pullRequest?.state).toBe("merged");
1698 expect(pullRequest?.headSha).toBe("a".repeat(40));
1694 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);1699 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
1695 const refreshed = yield* manager.branchPullRequest(1700 const refreshed = yield* manager.branchPullRequest(
1696 { cwd: repoDir, branch: "feature/shared-pr-cache" },1701 { cwd: repoDir, branch: "feature/shared-pr-cache" },
...@@ -1699,6 +1704,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1699,6 +1704,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1699 expect(refreshed).toMatchObject({1704 expect(refreshed).toMatchObject({
1700 number: 221,1705 number: 221,
1701 state: "open",1706 state: "open",
1707 headSha: "b".repeat(40),
1702 repositoryKey: "github.com/pingdotgg/codething-mvp",1708 repositoryKey: "github.com/pingdotgg/codething-mvp",
1703 });1709 });
1704 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);1710 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
apps/server/src/git/GitManager.ts+5
...@@ -95,6 +95,8 @@ export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {...@@ -95,6 +95,8 @@ export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {
95 readonly updatedAt: string | null;95 readonly updatedAt: string | null;
96 readonly closedAt?: string | null;96 readonly closedAt?: string | null;
97 readonly mergedAt?: string | null;97 readonly mergedAt?: string | null;
98 /** The pull request's head commit, when the host read reports it. */
99 readonly headSha?: string | null;
98};100};
99101
100interface SourceControlTextGenerationSettings {102interface SourceControlTextGenerationSettings {
...@@ -201,6 +203,7 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {...@@ -201,6 +203,7 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
201 closedAt?: string | null;203 closedAt?: string | null;
202 mergedAt?: string | null;204 mergedAt?: string | null;
203 updatedAt: Option.Option<DateTime.Utc>;205 updatedAt: Option.Option<DateTime.Utc>;
206 headSha?: string | undefined;
204}207}
205208
206const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(209const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
...@@ -460,6 +463,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {...@@ -460,6 +463,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
460 url: summary.url,463 url: summary.url,
461 baseRefName: summary.baseRefName,464 baseRefName: summary.baseRefName,
462 headRefName: summary.headRefName,465 headRefName: summary.headRefName,
466 ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
463 state: summary.state ?? "open",467 state: summary.state ?? "open",
464 ...(summary.isDraft === true ? { isDraft: true } : {}),468 ...(summary.isDraft === true ? { isDraft: true } : {}),
465 closedAt: summary.closedAt ?? null,469 closedAt: summary.closedAt ?? null,
...@@ -2321,6 +2325,7 @@ export const make = Effect.gen(function* () {...@@ -2321,6 +2325,7 @@ export const make = Effect.gen(function* () {
2321 ...toStatusPr(latest),2325 ...toStatusPr(latest),
2322 closedAt: latest.closedAt ?? null,2326 closedAt: latest.closedAt ?? null,
2323 mergedAt: latest.mergedAt ?? null,2327 mergedAt: latest.mergedAt ?? null,
2328 headSha: latest.headSha ?? null,
2324 // Hosting CLIs can select an upstream repository instead of origin.2329 // Hosting CLIs can select an upstream repository instead of origin.
2325 // The returned PR URL names the repository that actually owns it.2330 // The returned PR URL names the repository that actually owns it.
2326 repositoryKey: pullRequestRepositoryKey(latest.url),2331 repositoryKey: pullRequestRepositoryKey(latest.url),
apps/server/src/sourceControl/GitHubCli.test.ts+8-1
...@@ -260,7 +260,12 @@ describe("GitHubCli.listPullRequestsByHead", () => {...@@ -260,7 +260,12 @@ describe("GitHubCli.listPullRequestsByHead", () => {
260 Effect.sync(() => {260 Effect.sync(() => {
261 documents.push(input);261 documents.push(input);
262 return encodeJson({262 return encodeJson({
263 data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } },263 data: {
264 repository: {
265 h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] },
266 h1: { nodes: [] },
267 },
268 },
264 });269 });
265 }),270 }),
266 },271 },
...@@ -288,8 +293,10 @@ describe("GitHubCli.listPullRequestsByHead", () => {...@@ -288,8 +293,10 @@ describe("GitHubCli.listPullRequestsByHead", () => {
288 first?.map((pr) => pr.number),293 first?.map((pr) => pr.number),
289 [7],294 [7],
290 );295 );
296 assert.strictEqual(first?.[0]?.headSha, "a".repeat(40));
291 assert.deepStrictEqual(second, []);297 assert.deepStrictEqual(second, []);
292 assert.strictEqual(documents.length, 1);298 assert.strictEqual(documents.length, 1);
299 assert.include(documents[0]!.query, "headRefOid");
293 assert.deepStrictEqual(documents[0]!.variables, {300 assert.deepStrictEqual(documents[0]!.variables, {
294 owner: "acme",301 owner: "acme",
295 name: "web",302 name: "web",
apps/server/src/sourceControl/GitHubCli.ts+2-1
...@@ -183,6 +183,7 @@ export interface GitHubPullRequestSummary {...@@ -183,6 +183,7 @@ export interface GitHubPullRequestSummary {
183 readonly url: string;183 readonly url: string;
184 readonly baseRefName: string;184 readonly baseRefName: string;
185 readonly headRefName: string;185 readonly headRefName: string;
186 readonly headSha?: string;
186 readonly state?: "open" | "closed" | "merged";187 readonly state?: "open" | "closed" | "merged";
187 readonly isDraft?: boolean;188 readonly isDraft?: boolean;
188 readonly closedAt?: string | null;189 readonly closedAt?: string | null;
...@@ -337,7 +338,7 @@ type PullRequestListState = "open" | "closed" | "merged" | "all";...@@ -337,7 +338,7 @@ type PullRequestListState = "open" | "closed" | "merged" | "all";
337338
338/** The pull request fields every read selects, in GraphQL. */339/** The pull request fields every read selects, in GraphQL. */
339const PULL_REQUEST_NODE_SELECTION =340const PULL_REQUEST_NODE_SELECTION =
340 "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";341 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
341const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {342const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
342 open: ["OPEN"],343 open: ["OPEN"],
343 closed: ["CLOSED"],344 closed: ["CLOSED"],
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+2
...@@ -142,6 +142,7 @@ it.effect("lists change request history through the batched head lookup", () =>...@@ -142,6 +142,7 @@ it.effect("lists change request history through the batched head lookup", () =>
142 url: "https://enterprise.test/acme/web/pull/7",142 url: "https://enterprise.test/acme/web/pull/7",
143 baseRefName: "main",143 baseRefName: "main",
144 headRefName: "feature/merged",144 headRefName: "feature/merged",
145 headSha: "a".repeat(40),
145 state: "merged",146 state: "merged",
146 mergedAt: "2026-01-01T00:00:00Z",147 mergedAt: "2026-01-01T00:00:00Z",
147 updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),148 updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
...@@ -171,6 +172,7 @@ it.effect("lists change request history through the batched head lookup", () =>...@@ -171,6 +172,7 @@ it.effect("lists change request history through the batched head lookup", () =>
171 });172 });
172 assert.strictEqual(changeRequests[0]?.provider, "github");173 assert.strictEqual(changeRequests[0]?.provider, "github");
173 assert.strictEqual(changeRequests[0]?.state, "merged");174 assert.strictEqual(changeRequests[0]?.state, "merged");
175 assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40));
174 assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");176 assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");
175 assert.deepStrictEqual(177 assert.deepStrictEqual(
176 changeRequests[0]?.updatedAt,178 changeRequests[0]?.updatedAt,
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+1
...@@ -48,6 +48,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -48,6 +48,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
48 url: summary.url,48 url: summary.url,
49 baseRefName: summary.baseRefName,49 baseRefName: summary.baseRefName,
50 headRefName: summary.headRefName,50 headRefName: summary.headRefName,
51 ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
51 state: summary.state ?? "open",52 state: summary.state ?? "open",
52 ...(summary.isDraft === true ? { isDraft: true } : {}),53 ...(summary.isDraft === true ? { isDraft: true } : {}),
53 closedAt: summary.closedAt ?? null,54 closedAt: summary.closedAt ?? null,
apps/server/src/sourceControl/gitHubPullRequests.ts+5
...@@ -13,6 +13,8 @@ export interface NormalizedGitHubPullRequestRecord {...@@ -13,6 +13,8 @@ export interface NormalizedGitHubPullRequestRecord {
13 readonly url: string;13 readonly url: string;
14 readonly baseRefName: string;14 readonly baseRefName: string;
15 readonly headRefName: string;15 readonly headRefName: string;
16 /** The head commit, when the read asked for `headRefOid`. */
17 readonly headSha?: string;
16 readonly state: "open" | "closed" | "merged";18 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;19 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;20 readonly closedAt?: string | null;
...@@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({...@@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({
29 url: TrimmedNonEmptyString,31 url: TrimmedNonEmptyString,
30 baseRefName: TrimmedNonEmptyString,32 baseRefName: TrimmedNonEmptyString,
31 headRefName: TrimmedNonEmptyString,33 headRefName: TrimmedNonEmptyString,
34 headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
32 state: Schema.optional(Schema.NullOr(Schema.String)),35 state: Schema.optional(Schema.NullOr(Schema.String)),
33 isDraft: Schema.optional(Schema.Boolean),36 isDraft: Schema.optional(Schema.Boolean),
34 closedAt: Schema.optional(Schema.NullOr(Schema.String)),37 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
...@@ -90,6 +93,7 @@ function normalizeGitHubPullRequestRecord(...@@ -90,6 +93,7 @@ function normalizeGitHubPullRequestRecord(
90 (headRepositoryOwnerLogin && headRepositoryName93 (headRepositoryOwnerLogin && headRepositoryName
91 ? `${headRepositoryOwnerLogin}/${headRepositoryName}`94 ? `${headRepositoryOwnerLogin}/${headRepositoryName}`
92 : null);95 : null);
96 const headSha = trimOptionalString(raw.headRefOid);
9397
94 return {98 return {
95 number: raw.number,99 number: raw.number,
...@@ -97,6 +101,7 @@ function normalizeGitHubPullRequestRecord(...@@ -97,6 +101,7 @@ function normalizeGitHubPullRequestRecord(
97 url: raw.url,101 url: raw.url,
98 baseRefName: raw.baseRefName,102 baseRefName: raw.baseRefName,
99 headRefName: raw.headRefName,103 headRefName: raw.headRefName,
104 ...(headSha ? { headSha } : {}),
100 state: normalizeGitHubPullRequestState(raw),105 state: normalizeGitHubPullRequestState(raw),
101 ...(raw.isDraft === true ? { isDraft: true } : {}),106 ...(raw.isDraft === true ? { isDraft: true } : {}),
102 closedAt: raw.closedAt ?? null,107 closedAt: raw.closedAt ?? null,
apps/server/src/storageCleanup.test.ts+51-1
...@@ -7,7 +7,11 @@ import {...@@ -7,7 +7,11 @@ import {
7 type OrchestrationV2ThreadShell,7 type OrchestrationV2ThreadShell,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
10import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./storageCleanup.ts";10import {
11 storageCleanupActivityAt,
12 storageCleanupPullRequestMerged,
13 storageCleanupThreadIdle,
14} from "./storageCleanup.ts";
1115
12const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");16const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
13const DAY_MS = 24 * 60 * 60 * 1_000;17const DAY_MS = 24 * 60 * 60 * 1_000;
...@@ -104,3 +108,49 @@ describe("V2 storage cleanup eligibility", () => {...@@ -104,3 +108,49 @@ describe("V2 storage cleanup eligibility", () => {
104 return { ...candidate(), status };108 return { ...candidate(), status };
105 }109 }
106});110});
111
112describe("merged pull request cleanup", () => {
113 const HEAD_SHA = "a".repeat(40);
114 const integrated = {
115 branch: "feature",
116 defaultBranch: "main",
117 headSha: HEAD_SHA,
118 integrated: true,
119 };
120 const squashed = { ...integrated, integrated: false };
121 const pullRequest = (
122 overrides: Partial<NonNullable<Parameters<typeof storageCleanupPullRequestMerged>[0]>> = {},
123 ) => ({
124 state: "merged" as const,
125 headRef: "feature",
126 baseRef: "main",
127 headSha: HEAD_SHA,
128 ...overrides,
129 });
130
131 it("removes a worktree whose head reached the default branch through a merged pull request", () => {
132 expect(storageCleanupPullRequestMerged(pullRequest({ headSha: null }), integrated)).toBe(true);
133 });
134
135 it("removes a squash-merged worktree when the pull request names its exact head", () => {
136 expect(storageCleanupPullRequestMerged(pullRequest(), squashed)).toBe(true);
137 });
138
139 it.each([
140 ["has a later commit than the merged head", { headSha: "c".repeat(40) }],
141 ["was merged into a release branch", { baseRef: "release" }],
142 ["was merged into its stack parent", { baseRef: "stack-parent" }],
143 ["was merged without a reported head commit", { headSha: null }],
144 ["belongs to a different branch", { headRef: "other" }],
145 ["is still open", { state: "open" }],
146 ["was closed without merging", { state: "closed" }],
147 ] as const)("keeps a squash worktree whose pull request %s", (_name, overrides) => {
148 expect(storageCleanupPullRequestMerged(pullRequest(overrides), squashed)).toBe(false);
149 });
150
151 it("keeps a worktree with no pull request, or one that is not merged", () => {
152 expect(storageCleanupPullRequestMerged(null, squashed)).toBe(false);
153 expect(storageCleanupPullRequestMerged(null, integrated)).toBe(false);
154 expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false);
155 });
156});
apps/server/src/storageCleanup.ts+34-3
...@@ -106,6 +106,32 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu...@@ -106,6 +106,32 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu
106 );106 );
107}107}
108108
109/**
110 * Whether the host's pull request proves this worktree's head was merged. A
111 * squash or rebase merge leaves the head outside the default branch, so the
112 * merged pull request then has to name this exact commit.
113 */
114export function storageCleanupPullRequestMerged(
115 pullRequest: Pick<
116 GitManager.GitBranchPullRequest,
117 "state" | "headRef" | "baseRef" | "headSha"
118 > | null,
119 worktree: {
120 readonly branch: string;
121 readonly defaultBranch: string;
122 readonly headSha: string;
123 readonly integrated: boolean;
124 },
125): boolean {
126 return (
127 pullRequest?.state === "merged" &&
128 (worktree.integrated ||
129 (pullRequest.headRef === worktree.branch &&
130 pullRequest.baseRef === worktree.defaultBranch &&
131 pullRequest.headSha === worktree.headSha))
132 );
133}
134
109export const make = Effect.gen(function* () {135export const make = Effect.gen(function* () {
110 const config = yield* ServerConfig.ServerConfig;136 const config = yield* ServerConfig.ServerConfig;
111 const settingsService = yield* Settings.ServerSettingsService;137 const settingsService = yield* Settings.ServerSettingsService;
...@@ -295,14 +321,19 @@ export const make = Effect.gen(function* () {...@@ -295,14 +321,19 @@ export const make = Effect.gen(function* () {
295 args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha],321 args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha],
296 allowNonZeroExit: true,322 allowNonZeroExit: true,
297 });323 });
298 if (ancestor.exitCode !== 0) return;324 const integrated = ancestor.exitCode === 0;
299 eligible = settings.worktreeUnchanged;325 eligible = integrated && settings.worktreeUnchanged;
300 if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {326 if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
301 const pullRequest = yield* gitManager.branchPullRequest(327 const pullRequest = yield* gitManager.branchPullRequest(
302 { cwd: worktreePath, branch: thread.branch },328 { cwd: worktreePath, branch: thread.branch },
303 { refresh: true },329 { refresh: true },
304 );330 );
305 eligible = pullRequest?.state === "merged";331 eligible = storageCleanupPullRequestMerged(pullRequest, {
332 branch: thread.branch,
333 defaultBranch: branch,
334 headSha: head.commitSha,
335 integrated,
336 });
306 }337 }
307 }338 }
308 if (!eligible) return;339 if (!eligible) return;
docs/user/project-settings.md+3-2
...@@ -145,8 +145,9 @@ Worktrees can be removed after a chosen number of inactive days, after merging,...@@ -145,8 +145,9 @@ Worktrees can be removed after a chosen number of inactive days, after merging,
145have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active145have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active
146sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules`146sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules`
147prevent removal. Branches and thread history stay; starting another turn recreates the checkout.147prevent removal. Branches and thread history stay; starting another turn recreates the checkout.
148Merge cleanup requires the commits to be included in the remote default branch, so squash merges148Merge cleanup requires a merged pull request whose commits are included in the remote default
149may need the inactivity rule instead.149branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default
150branch and the worktree is still at the pull request's last commit.
150151
151Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last152Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last
152thread is deleted, including archived threads and worktrees left by earlier deletions. The153thread is deleted, including archived threads and worktrees left by earlier deletions. The
packages/contracts/src/sourceControl.ts+2
...@@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({...@@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({
29 url: Schema.String,29 url: Schema.String,
30 baseRefName: TrimmedNonEmptyString,30 baseRefName: TrimmedNonEmptyString,
31 headRefName: TrimmedNonEmptyString,31 headRefName: TrimmedNonEmptyString,
32 /** The head commit, when the provider's read includes it. */
33 headSha: Schema.optional(TrimmedNonEmptyString),
32 state: ChangeRequestState,34 state: ChangeRequestState,
33 /** Present when the provider can tell that an open change request is still a draft. */35 /** Present when the provider can tell that an open change request is still a draft. */
34 isDraft: Schema.optional(Schema.Boolean),36 isDraft: Schema.optional(Schema.Boolean),