authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-08-24 10:54:47+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:54:47-07:00
logba30177b5a5e9b47885b59afcc4f00a8a7cc0efd
tree4201324c3b2d7cab3598c00b8f8c6f6ef17dfc63
parentbe3da50e96bb6700fbd111e061d8071a04427fb0
signature Signed by PGP key B5690EEEBB952194

fix(server): preserve merged PR badges after branch deletion (#6216)


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 );
976976
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 most1270 * `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 ever1271 * terminal and agent pushes land), and configured upstream metadata survives
1272 * reach the host" test than looking for upstream config, and the glob spans1272 * 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 no1273 * distinguish branches known to have reached a host from genuinely local
1274 * remotes at all cannot answer the question, because then every branch looks1274 * 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));
12931295
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 });
13021315
1303 const findOpenPr = Effect.fn("findOpenPr")(function* (1316 const findOpenPr = Effect.fn("findOpenPr")(function* (