| 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 | 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-"); |
apps/server/src/git/GitManager.ts+26-13| ... | ... | @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { |
| 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* () { |
| 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* ( |