History

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

Tristan Knight committed 2 months ago

Commit ba301771 parent be3da502 files · +95 −13Browse files

Unified · Split

2 files
  1. apps/server/src/git/GitManager.test.ts +69 −0
  2. apps/server/src/git/GitManager.ts +26 −13
apps/server/src/git/GitManager.test.ts+69−0
@@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 973 unmodified lines
974974 }),
975975 );
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+
9771046 it.effect("status still looks up PRs for a branch pushed without --set-upstream", () =>
9781047 Effect.gen(function* () {
9791048 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* () { · 1267 unmodified lines
12681268 * cannot exist for it and asking the provider is a guaranteed-empty API call.
12691269 *
12701270 * `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.
12761278 */
12771279 const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* (
12781280 cwd: string,
1279− headContext: Pick<BranchHeadContext, "headBranch">,
1281+ headContext: Pick<BranchHeadContext, "headBranch" | "localBranch">,
12801282 ) {
12811283 if (headContext.headBranch.length === 0) {
12821284 return false;
@@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
12911293 })
12921294 .pipe(Effect.map((result) => result.stdout.trim().length > 0));
12931295
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));
13011314 });
13021315
13031316 const findOpenPr = Effect.fn("findOpenPr")(function* (