| author | |
| committer | |
| log | 636caf4c70345d74c5391cbf9a607a91426a6e39 |
| tree | a0747cece953e6ba982bb93d38c6b35a8ffb9e69 |
| parent | 26af903b9bec7f8da56bb5f545d9980d08d418e1 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 146 insertions(+), 6 deletions(-)
apps/server/src/git/GitManager.test.ts+102| ... | ... | @@ -1129,6 +1129,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1129 | 1129 | 20_000, |
| 1130 | 1130 | ); |
| 1131 | 1131 | |
| 1132 | it.effect( | |
| 1133 | "status preserves a fork PR whose head is named after the default branch", | |
| 1134 | () => | |
| 1135 | Effect.gen(function* () { | |
| 1136 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1137 | yield* initRepo(repoDir); | |
| 1138 | const originDir = yield* createBareRemote(); | |
| 1139 | const forkDir = yield* createBareRemote(); | |
| 1140 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | |
| 1141 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1142 | yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); | |
| 1143 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | |
| 1144 | yield* runGit(repoDir, ["push", "fork-seed", "main"]); | |
| 1145 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]); | |
| 1146 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]); | |
| 1147 | yield* configureVisibleRemoteUrlWithLocalRewrite( | |
| 1148 | repoDir, | |
| 1149 | "fork-seed", | |
| 1150 | "git@github.com:contributor/codething-mvp.git", | |
| 1151 | forkDir, | |
| 1152 | ); | |
| 1153 | ||
| 1154 | const { manager, ghCalls } = yield* makeManager({ | |
| 1155 | ghScenario: { | |
| 1156 | prListByHeadSelector: { | |
| 1157 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 1158 | "contributor:main": JSON.stringify([ | |
| 1159 | { | |
| 1160 | number: 777, | |
| 1161 | title: "Fork PR from main", | |
| 1162 | url: "https://github.com/pingdotgg/codething-mvp/pull/777", | |
| 1163 | baseRefName: "main", | |
| 1164 | headRefName: "main", | |
| 1165 | state: "OPEN", | |
| 1166 | updatedAt: "2026-03-10T07:00:00Z", | |
| 1167 | isCrossRepository: true, | |
| 1168 | headRepository: { | |
| 1169 | nameWithOwner: "contributor/codething-mvp", | |
| 1170 | }, | |
| 1171 | headRepositoryOwner: { | |
| 1172 | login: "contributor", | |
| 1173 | }, | |
| 1174 | }, | |
| 1175 | ]), | |
| 1176 | }, | |
| 1177 | }, | |
| 1178 | }); | |
| 1179 | ||
| 1180 | const status = yield* manager.status({ cwd: repoDir }); | |
| 1181 | expect(status.refName).toBe("t3code/pr-777/main"); | |
| 1182 | expect(status.pr).toEqual({ | |
| 1183 | number: 777, | |
| 1184 | title: "Fork PR from main", | |
| 1185 | url: "https://github.com/pingdotgg/codething-mvp/pull/777", | |
| 1186 | baseRef: "main", | |
| 1187 | headRef: "main", | |
| 1188 | state: "open", | |
| 1189 | }); | |
| 1190 | expect(ghCalls).toContain( | |
| 1191 | "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 1192 | ); | |
| 1193 | }), | |
| 1194 | 20_000, | |
| 1195 | ); | |
| 1196 | ||
| 1132 | 1197 | it.effect( |
| 1133 | 1198 | "status ignores synthetic local branch aliases when the upstream remote name contains slashes", |
| 1134 | 1199 | () => |
| ... | ... | @@ -1315,6 +1380,43 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1315 | 1380 | }), |
| 1316 | 1381 | ); |
| 1317 | 1382 | |
| 1383 | it.effect("status does not inherit a merged PR from a feature branch's default upstream", () => | |
| 1384 | Effect.gen(function* () { | |
| 1385 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 1386 | yield* initRepo(repoDir); | |
| 1387 | const remoteDir = yield* createBareRemote(); | |
| 1388 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 1389 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 1390 | yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); | |
| 1391 | yield* runGit(repoDir, ["checkout", "-b", "feature/from-main", "origin/main"]); | |
| 1392 | ||
| 1393 | const { manager, ghCalls } = yield* makeManager({ | |
| 1394 | ghScenario: { | |
| 1395 | prListSequence: [ | |
| 1396 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 1397 | JSON.stringify([ | |
| 1398 | { | |
| 1399 | number: 54, | |
| 1400 | title: "Reverse merge from main", | |
| 1401 | url: "https://github.com/pingdotgg/codething-mvp/pull/54", | |
| 1402 | baseRefName: "je-filter-list", | |
| 1403 | headRefName: "main", | |
| 1404 | state: "MERGED", | |
| 1405 | mergedAt: "2023-09-28T03:21:10Z", | |
| 1406 | updatedAt: "2023-09-28T03:21:10Z", | |
| 1407 | }, | |
| 1408 | ]), | |
| 1409 | ], | |
| 1410 | }, | |
| 1411 | }); | |
| 1412 | ||
| 1413 | const status = yield* manager.status({ cwd: repoDir }); | |
| 1414 | expect(status.refName).toBe("feature/from-main"); | |
| 1415 | expect(status.pr).toBeNull(); | |
| 1416 | expect(ghCalls.some((call) => call.includes("pr list"))).toBe(false); | |
| 1417 | }), | |
| 1418 | ); | |
| 1419 | ||
| 1318 | 1420 | it.effect("status prefers open PR when merged PR has newer updatedAt", () => |
| 1319 | 1421 | Effect.gen(function* () { |
| 1320 | 1422 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/GitManager.ts+41-6| ... | ... | @@ -904,11 +904,24 @@ export const make = Effect.gen(function* () { |
| 904 | 904 | prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); |
| 905 | 905 | }), |
| 906 | 906 | ); |
| 907 | // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the | |
| 907 | // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the | |
| 908 | 908 | // segments can contain a NUL byte, and refs are never empty, so "" decodes |
| 909 | // back to a null upstreamRef. | |
| 910 | const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => | |
| 911 | [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); | |
| 909 | // back to a null ref. | |
| 910 | const prLookupCacheKey = ( | |
| 911 | cwd: string, | |
| 912 | details: { | |
| 913 | branch: string; | |
| 914 | upstreamRef: string | null; | |
| 915 | defaultBranch: string | null; | |
| 916 | }, | |
| 917 | ) => | |
| 918 | [ | |
| 919 | cwd, | |
| 920 | details.branch, | |
| 921 | details.upstreamRef ?? "", | |
| 922 | details.defaultBranch ?? "", | |
| 923 | String(prLookupEpoch(cwd)), | |
| 924 | ].join("\u0000"); | |
| 912 | 925 | // Consecutive failures per cache key, so a branch that keeps failing waits |
| 913 | 926 | // longer before the next attempt. Cleared as soon as a lookup succeeds. |
| 914 | 927 | const prLookupFailureStreakByKey = new Map<string, number>(); |
| ... | ... | @@ -928,13 +941,29 @@ export const make = Effect.gen(function* () { |
| 928 | 941 | }; |
| 929 | 942 | const prLookupCache = yield* Cache.makeWith( |
| 930 | 943 | (key: string) => { |
| 931 | const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); | |
| 944 | const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); | |
| 932 | 945 | const details = { |
| 933 | 946 | branch, |
| 934 | 947 | upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, |
| 948 | defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, | |
| 935 | 949 | }; |
| 936 | 950 | return Effect.gen(function* () { |
| 937 | 951 | const headContext = yield* resolveBranchHeadContext(cwd, details); |
| 952 | const upstreamHeadIsDefault = | |
| 953 | headContext.headBranch === details.defaultBranch || | |
| 954 | (details.defaultBranch === null && | |
| 955 | (headContext.headBranch === "main" || headContext.headBranch === "master")); | |
| 956 | // `git worktree add -b feature origin/main` makes the new local branch | |
| 957 | // track origin/main. That upstream is the branch's base, not its | |
| 958 | // published PR head. Looking up PRs for it can attach an old reverse | |
| 959 | // merge from main and auto-settle an unrelated feature thread. | |
| 960 | if ( | |
| 961 | headContext.headBranch !== details.branch && | |
| 962 | upstreamHeadIsDefault && | |
| 963 | !headContext.isCrossRepository | |
| 964 | ) { | |
| 965 | return { latest: null, headContext }; | |
| 966 | } | |
| 938 | 967 | // Only skip when the branch is untracked as well: anything carrying an |
| 939 | 968 | // upstream keeps the old behaviour. |
| 940 | 969 | if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { |
| ... | ... | @@ -1015,7 +1044,12 @@ export const make = Effect.gen(function* () { |
| 1015 | 1044 | }; |
| 1016 | 1045 | const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( |
| 1017 | 1046 | cwd: string, |
| 1018 | details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, | |
| 1047 | details: { | |
| 1048 | branch: string; | |
| 1049 | upstreamRef: string | null; | |
| 1050 | defaultBranch: string | null; | |
| 1051 | isDefaultBranch: boolean; | |
| 1052 | }, | |
| 1019 | 1053 | ) { |
| 1020 | 1054 | // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first |
| 1021 | 1055 | // `push -u`) must not orphan the fallback value for the same branch. |
| ... | ... | @@ -1089,6 +1123,7 @@ export const make = Effect.gen(function* () { |
| 1089 | 1123 | ? yield* lookupStatusPr(cwd, { |
| 1090 | 1124 | branch: details.branch, |
| 1091 | 1125 | upstreamRef: details.upstreamRef, |
| 1126 | defaultBranch: details.defaultBranch, | |
| 1092 | 1127 | isDefaultBranch: details.isDefaultBranch, |
| 1093 | 1128 | }) |
| 1094 | 1129 | : null; |
apps/server/src/vcs/GitVcsDriver.ts+1| ... | ... | @@ -72,6 +72,7 @@ export interface GitStatusDetails { |
| 72 | 72 | |
| 73 | 73 | export interface GitRemoteStatusDetails { |
| 74 | 74 | isRepo: boolean; |
| 75 | defaultBranch: string | null; | |
| 75 | 76 | isDefaultBranch: boolean; |
| 76 | 77 | branch: string | null; |
| 77 | 78 | upstreamRef: string | null; |
apps/server/src/vcs/GitVcsDriverCore.ts+2| ... | ... | @@ -89,6 +89,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetail |
| 89 | 89 | }); |
| 90 | 90 | const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({ |
| 91 | 91 | isRepo: false, |
| 92 | defaultBranch: null, | |
| 92 | 93 | isDefaultBranch: false, |
| 93 | 94 | branch: null, |
| 94 | 95 | upstreamRef: null, |
| ... | ... | @@ -1550,6 +1551,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1550 | 1551 | |
| 1551 | 1552 | return { |
| 1552 | 1553 | isRepo: true, |
| 1554 | defaultBranch, | |
| 1553 | 1555 | isDefaultBranch, |
| 1554 | 1556 | branch, |
| 1555 | 1557 | upstreamRef, |