| author | |
| committer | |
| log | ba30177b5a5e9b47885b59afcc4f00a8a7cc0efd |
| tree | 4201324c3b2d7cab3598c00b8f8c6f6ef17dfc63 |
| parent | be3da50e96bb6700fbd111e061d8071a04427fb0 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 95 insertions(+), 13 deletions(-)
apps/server/src/git/GitManager.test.ts+69| ... | @@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 974 | }), | 974 | }), |
| 975 | ); | 975 | ); |
| 976 | 976 | ||
| 977 | it.effect("status finds a merged PR after its remote branch was deleted", () => | ||
| 978 | Effect.gen(function* () { | ||
| 979 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 980 | yield* initRepo(repoDir); | ||
| 981 | const remoteDir = yield* createBareRemote(); | ||
| 982 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 983 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 984 | yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]); | ||
| 985 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); | ||
| 986 | |||
| 987 | // GitHub commonly deletes a pull request's head branch after merge. Git | ||
| 988 | // removes the remote-tracking ref, but preserves the local branch's | ||
| 989 | // remote and merge configuration as evidence that it was published. | ||
| 990 | yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); | ||
| 991 | const configuredRemote = yield* runGit(repoDir, [ | ||
| 992 | "config", | ||
| 993 | "--get", | ||
| 994 | "branch.feature/merged-branch-deleted.remote", | ||
| 995 | ]); | ||
| 996 | const configuredMerge = yield* runGit(repoDir, [ | ||
| 997 | "config", | ||
| 998 | "--get", | ||
| 999 | "branch.feature/merged-branch-deleted.merge", | ||
| 1000 | ]); | ||
| 1001 | const trackingRef = yield* runGit(repoDir, [ | ||
| 1002 | "for-each-ref", | ||
| 1003 | "--format=%(refname)", | ||
| 1004 | "refs/remotes/origin/feature/merged-branch-deleted", | ||
| 1005 | ]); | ||
| 1006 | expect(configuredRemote.stdout.trim()).toBe("origin"); | ||
| 1007 | expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); | ||
| 1008 | expect(trackingRef.stdout.trim()).toBe(""); | ||
| 1009 | |||
| 1010 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1011 | ghScenario: { | ||
| 1012 | prListSequence: [ | ||
| 1013 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1014 | JSON.stringify([ | ||
| 1015 | { | ||
| 1016 | number: 215, | ||
| 1017 | title: "Merged branch was deleted", | ||
| 1018 | url: "https://github.com/pingdotgg/t3code/pull/215", | ||
| 1019 | baseRefName: "main", | ||
| 1020 | headRefName: "feature/merged-branch-deleted", | ||
| 1021 | state: "MERGED", | ||
| 1022 | mergedAt: "2026-04-02T15:00:00Z", | ||
| 1023 | updatedAt: "2026-04-02T15:00:00Z", | ||
| 1024 | }, | ||
| 1025 | ]), | ||
| 1026 | ], | ||
| 1027 | }, | ||
| 1028 | }); | ||
| 1029 | |||
| 1030 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1031 | |||
| 1032 | expect(status.hasUpstream).toBe(false); | ||
| 1033 | expect(status.pr).toEqual({ | ||
| 1034 | number: 215, | ||
| 1035 | title: "Merged branch was deleted", | ||
| 1036 | url: "https://github.com/pingdotgg/t3code/pull/215", | ||
| 1037 | baseRef: "main", | ||
| 1038 | headRef: "feature/merged-branch-deleted", | ||
| 1039 | state: "merged", | ||
| 1040 | updatedAt: "2026-04-02T15:00:00.000Z", | ||
| 1041 | }); | ||
| 1042 | expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); | ||
| 1043 | }), | ||
| 1044 | ); | ||
| 1045 | |||
| 977 | it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => | 1046 | it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => |
| 978 | Effect.gen(function* () { | 1047 | Effect.gen(function* () { |
| 979 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 1048 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/GitManager.ts+26-13| ... | @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { | ... | @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { |
| 1268 | * cannot exist for it and asking the provider is a guaranteed-empty API call. | 1268 | * cannot exist for it and asking the provider is a guaranteed-empty API call. |
| 1269 | * | 1269 | * |
| 1270 | * `git push` writes the remote-tracking ref even without `-u` (how most | 1270 | * `git push` writes the remote-tracking ref even without `-u` (how most |
| 1271 | * terminal and agent pushes land), which makes this a safer "did it ever | 1271 | * terminal and agent pushes land), and configured upstream metadata survives |
| 1272 | * reach the host" test than looking for upstream config, and the glob spans | 1272 | * when a merged change request's remote branch is deleted. Together they |
| 1273 | * every remote so a fork branch still counts. A repository that tracks no | 1273 | * distinguish branches known to have reached a host from genuinely local |
| 1274 | * remotes at all cannot answer the question, because then every branch looks | 1274 | * branches. The ref glob spans every remote so a fork branch still counts. A |
| 1275 | * unpublished; it, and any failed probe, keeps the lookup. | 1275 | * repository that tracks no remotes at all cannot answer the question, |
| 1276 | * because then every branch looks unpublished; it, and any failed probe, | ||
| 1277 | * keeps the lookup. | ||
| 1276 | */ | 1278 | */ |
| 1277 | const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( | 1279 | const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( |
| 1278 | cwd: string, | 1280 | cwd: string, |
| 1279 | headContext: Pick<BranchHeadContext, "headBranch">, | 1281 | headContext: Pick<BranchHeadContext, "headBranch" | "localBranch">, |
| 1280 | ) { | 1282 | ) { |
| 1281 | if (headContext.headBranch.length === 0) { | 1283 | if (headContext.headBranch.length === 0) { |
| 1282 | return false; | 1284 | return false; |
| ... | @@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { | ... | @@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { |
| 1291 | }) | 1293 | }) |
| 1292 | .pipe(Effect.map((result) => result.stdout.trim().length > 0)); | 1294 | .pipe(Effect.map((result) => result.stdout.trim().length > 0)); |
| 1293 | 1295 | ||
| 1294 | return yield* Effect.all( | 1296 | return yield* Effect.gen(function* () { |
| 1295 | [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], | 1297 | const [configuredRemote, configuredMerge] = yield* Effect.all( |
| 1296 | { concurrency: "unbounded" }, | 1298 | [ |
| 1297 | ).pipe( | 1299 | gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), |
| 1298 | Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), | 1300 | gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), |
| 1299 | Effect.orElseSucceed(() => false), | 1301 | ], |
| 1300 | ); | 1302 | { concurrency: "unbounded" }, |
| 1303 | ); | ||
| 1304 | if (configuredRemote !== null && configuredMerge !== null) { | ||
| 1305 | return false; | ||
| 1306 | } | ||
| 1307 | |||
| 1308 | const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( | ||
| 1309 | [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], | ||
| 1310 | { concurrency: "unbounded" }, | ||
| 1311 | ); | ||
| 1312 | return tracksAnyRemote && !tracksThisBranch; | ||
| 1313 | }).pipe(Effect.orElseSucceed(() => false)); | ||
| 1301 | }); | 1314 | }); |
| 1302 | 1315 | ||
| 1303 | const findOpenPr = Effect.fn("findOpenPr")(function* ( | 1316 | const findOpenPr = Effect.fn("findOpenPr")(function* ( |