| author | |
| committer | |
| log | 93e864042cf066f4cdb3efe65dbefaaf0d168894 |
| tree | 042911da4bfd55f52c15382382db8511094a2d5b |
| parent | d9068d38099fe5cb4f3fed4cb5455fee878db491 |
| signature | Signed by PGP key B5690EEEBB952194 |
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) => { |
| 1691 | 1695 | ||
| 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 | }; |
| 99 | 101 | ||
| 100 | interface SourceControlTextGenerationSettings { | 102 | interface 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 | } |
| 205 | 208 | ||
| 206 | const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput( | 209 | const 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"; |
| 337 | 338 | ||
| 338 | /** The pull request fields every read selects, in GraphQL. */ | 339 | /** The pull request fields every read selects, in GraphQL. */ |
| 339 | const PULL_REQUEST_NODE_SELECTION = | 340 | const 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 }"; |
| 341 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | 342 | const 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 && headRepositoryName | 93 | (headRepositoryOwnerLogin && headRepositoryName |
| 91 | ? `${headRepositoryOwnerLogin}/${headRepositoryName}` | 94 | ? `${headRepositoryOwnerLogin}/${headRepositoryName}` |
| 92 | : null); | 95 | : null); |
| 96 | const headSha = trimOptionalString(raw.headRefOid); | ||
| 93 | 97 | ||
| 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"; |
| 9 | import * as DateTime from "effect/DateTime"; | 9 | import * as DateTime from "effect/DateTime"; |
| 10 | import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./storageCleanup.ts"; | 10 | import { |
| 11 | storageCleanupActivityAt, | ||
| 12 | storageCleanupPullRequestMerged, | ||
| 13 | storageCleanupThreadIdle, | ||
| 14 | } from "./storageCleanup.ts"; | ||
| 11 | 15 | ||
| 12 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); | 16 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); |
| 13 | const DAY_MS = 24 * 60 * 60 * 1_000; | 17 | const 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 | |||
| 112 | describe("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 | } |
| 108 | 108 | ||
| 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 | */ | ||
| 114 | export 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 | |||
| 109 | export const make = Effect.gen(function* () { | 135 | export 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, |
| 145 | have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active | 145 | have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active |
| 146 | sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` | 146 | sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` |
| 147 | prevent removal. Branches and thread history stay; starting another turn recreates the checkout. | 147 | prevent removal. Branches and thread history stay; starting another turn recreates the checkout. |
| 148 | Merge cleanup requires the commits to be included in the remote default branch, so squash merges | 148 | Merge cleanup requires a merged pull request whose commits are included in the remote default |
| 149 | may need the inactivity rule instead. | 149 | branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default |
| 150 | branch and the worktree is still at the pull request's last commit. | ||
| 150 | 151 | ||
| 151 | Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last | 152 | Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last |
| 152 | thread is deleted, including archived threads and worktrees left by earlier deletions. The | 153 | thread 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), |