Unified · Split
| @@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 973 unmodified lines | ||
| 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 | 1046 | it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => |
| 978 | 1047 | Effect.gen(function* () { |
| 979 | 1048 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { · 1267 unmodified lines | ||
| 1268 | 1268 | * cannot exist for it and asking the provider is a guaranteed-empty API call. |
| 1269 | 1269 | * |
| 1270 | 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 | |
| 1272 | − * reach the host" test than looking for upstream config, and the glob spans | |
| 1273 | − * every remote so a fork branch still counts. A repository that tracks no | |
| 1274 | − * remotes at all cannot answer the question, because then every branch looks | |
| 1275 | − * unpublished; it, and any failed probe, keeps the lookup. | |
| 1271 | + * terminal and agent pushes land), and configured upstream metadata survives | |
| 1272 | + * when a merged change request's remote branch is deleted. Together they | |
| 1273 | + * distinguish branches known to have reached a host from genuinely local | |
| 1274 | + * branches. The ref glob spans every remote so a fork branch still counts. A | |
| 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 | 1279 | const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( |
| 1278 | 1280 | cwd: string, |
| 1279 | − headContext: Pick<BranchHeadContext, "headBranch">, | |
| 1281 | + headContext: Pick<BranchHeadContext, "headBranch" | "localBranch">, | |
| 1280 | 1282 | ) { |
| 1281 | 1283 | if (headContext.headBranch.length === 0) { |
| 1282 | 1284 | return false; |
| @@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { · 8 unmodified lines | ||
| 1291 | 1293 | }) |
| 1292 | 1294 | .pipe(Effect.map((result) => result.stdout.trim().length > 0)); |
| 1293 | 1295 | |
| 1294 | − return yield* Effect.all( | |
| 1295 | − [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], | |
| 1296 | − { concurrency: "unbounded" }, | |
| 1297 | − ).pipe( | |
| 1298 | − Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), | |
| 1299 | − Effect.orElseSucceed(() => false), | |
| 1300 | − ); | |
| 1296 | + return yield* Effect.gen(function* () { | |
| 1297 | + const [configuredRemote, configuredMerge] = yield* Effect.all( | |
| 1298 | + [ | |
| 1299 | + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), | |
| 1300 | + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), | |
| 1301 | + ], | |
| 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 | 1316 | const findOpenPr = Effect.fn("findOpenPr")(function* ( |