Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as PlatformError from "effect/PlatformError";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/process";
import { expect } from "vite-plus/test";
import type {
Expand Down Expand Up @@ -1076,6 +1077,78 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("a settled branch PR is rechecked only when the branch moves or every 15 minutes", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/settled"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/settled"]);
const pullRequest = (number: number, state: "open" | "merged"): FakeChangeRequest => ({
number,
title: "Settled",
url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`,
baseRefName: "main",
headRefName: "feature/settled",
state,
updatedAt: `2026-04-0${number}T15:00:00Z`,
});
const gitCommands: string[] = [];
const { manager } = yield* makeManager({
gitCommands,
providerScenario: {
prListSequence: [
[pullRequest(1, "merged")],
[pullRequest(1, "merged")],
[pullRequest(1, "merged")],
],
prListOtherwise: [pullRequest(2, "open")],
},
});
const lookup = manager.branchPullRequest({ cwd: repoDir, branch: "feature/settled" });
const freshLookups = () => gitCommands.filter((args) => args === "remote").length;

expect((yield* lookup)?.state).toBe("merged");
gitCommands.length = 0;
expect((yield* lookup)?.state).toBe("merged");
expect((yield* lookup)?.state).toBe("merged");
expect(gitCommands.map((args) => args.split(" ", 1)[0]).toSorted()).toEqual([
"config",
"config",
"for-each-ref",
"for-each-ref",
]);
expect(freshLookups()).toBe(0);

NodeFS.writeFileSync(NodePath.join(repoDir, "settled.txt"), "new work\n");
yield* runGit(repoDir, ["add", "settled.txt"]);
yield* runGit(repoDir, ["commit", "-m", "New work"]);
gitCommands.length = 0;
expect((yield* lookup)?.state).toBe("merged");
expect(freshLookups()).toBe(1);
gitCommands.length = 0;
yield* lookup;
expect(freshLookups()).toBe(0);

// Remembered git state expires after 5 minutes; that rereads git without a recheck.
yield* TestClock.adjust("10 minutes");
gitCommands.length = 0;
expect((yield* lookup)?.state).toBe("merged");
expect(freshLookups()).toBe(1);

yield* TestClock.adjust("5 minutes");
gitCommands.length = 0;
expect((yield* lookup)?.state).toBe("merged");
expect(freshLookups()).toBe(2);

// A new PR from the reused branch shows on the next host query.
yield* TestClock.adjust("5 minutes");
expect((yield* lookup)?.number).toBe(2);
}),
);

it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
83 changes: 68 additions & 15 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
const PR_LOOKUP_CACHE_CAPACITY = 2_048;
const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5);
const SETTLED_PR_LOOKUP_RECHECK_INTERVAL = Duration.minutes(15);
const PR_THREAD_CONTEXT_BYTES = 16_000;
const PR_THREAD_COMMAND_OUTPUT_CHARS = 2_000;
const isSourceControlProviderError = Schema.is(SourceControlProviderError);
Expand Down Expand Up @@ -2425,6 +2426,10 @@ export const make = Effect.gen(function* () {
prLookupGitStateByKey.set(entryKey, { readAtMs, value });
return value;
});
const settledPrLookupByBranchKey = new Map<
string,
{ readonly url: string; readonly fingerprint: string; readonly confirmedAtMs: number }
>();
const forgetPrLookupGitState = (cwd: string) =>
normalizeStatusCacheKey(cwd).pipe(
Effect.map((cacheCwd) => {
Expand Down Expand Up @@ -2470,6 +2475,39 @@ export const make = Effect.gen(function* () {
};
},
);
// objectname lets a settled PR result notice new commits on its branch.
const readPrLookupBranchRef = (cacheCwd: string, branch: string, fresh: boolean) =>
rememberPrLookupGitState(
cacheCwd,
`branchRef\u0000${branch}`,
fresh,
gitCore
.execute({
operation: "GitManager.branchPullRequest.branchRef",
cwd: cacheCwd,
args: [
"for-each-ref",
"--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)%00%(objectname)",
`refs/heads/${branch}`,
],
})
.pipe(Effect.map((result) => result.stdout)),
);
const readSettledPrLookupFingerprint = (cacheCwd: string, branch: string) =>
Effect.all(
[
readPrLookupBranchRef(cacheCwd, branch, true),
gitCore
.execute({
operation: "GitManager.branchPullRequest.remoteUrls",
cwd: cacheCwd,
args: ["config", "--get-regexp", "^remote\\..*\\.url$"],
allowNonZeroExit: true,
})
.pipe(Effect.map((result) => result.stdout)),
],
{ concurrency: "unbounded" },
).pipe(Effect.map((parts) => parts.join("\u0000")));
const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* (
cacheCwd: string,
branch: string,
Expand All @@ -2493,20 +2531,7 @@ export const make = Effect.gen(function* () {
.filter((remoteName) => remoteName.length > 0);
const [firstRemoteName] = remoteNames;
if (firstRemoteName === undefined) return null;
const branchRef = yield* remember(
`branchRef\u0000${branch}`,
gitCore
.execute({
operation: "GitManager.branchPullRequest.branchRef",
cwd: cacheCwd,
args: [
"for-each-ref",
"--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)",
`refs/heads/${branch}`,
],
})
.pipe(Effect.map((result) => result.stdout)),
);
const branchRef = yield* readPrLookupBranchRef(cacheCwd, branch, options.freshGitState);
const expectedRefName = `refs/heads/${branch}`;
const exactBranch = (branchRef ?? "")
.split("\n")
Expand Down Expand Up @@ -2657,10 +2682,38 @@ export const make = Effect.gen(function* () {
freshGitState,
});
if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest;
return yield* lookupBranchPullRequest(cacheCwd, branch, {
// A confirmed closed or merged result stays final until the branch or remotes change,
// with a periodic fresh lookup so a new PR from a reused branch still shows up.
const settledKey = `${cacheCwd}\u0000${branch}`;
const now = yield* Clock.currentTimeMillis;
const settled = settledPrLookupByBranchKey.get(settledKey);
// Read before any fresh lookup, so a change made during it forces another one.
const fingerprint = yield* readSettledPrLookupFingerprint(cacheCwd, branch);
if (
settled !== undefined &&
settled.url === pullRequest.url &&
settled.fingerprint === fingerprint &&
now - settled.confirmedAtMs < Duration.toMillis(SETTLED_PR_LOOKUP_RECHECK_INTERVAL)
) {
return pullRequest;
}
const confirmed = yield* lookupBranchPullRequest(cacheCwd, branch, {
refresh: false,
freshGitState: true,
});
settledPrLookupByBranchKey.delete(settledKey);
if (confirmed !== null && confirmed.state !== "open") {
if (settledPrLookupByBranchKey.size >= PR_LOOKUP_CACHE_CAPACITY) {
const oldestKey = settledPrLookupByBranchKey.keys().next().value;
if (oldestKey !== undefined) settledPrLookupByBranchKey.delete(oldestKey);
}
settledPrLookupByBranchKey.set(settledKey, {
url: confirmed.url,
fingerprint,
confirmedAtMs: now,
});
}
return confirmed;
});
const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn(
"invalidateLocalStatus",
Expand Down
Loading