Skip to content
Open
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
193 changes: 189 additions & 4 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"--limit",
String(input.limit ?? 1),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,headRefOid,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
Expand Down Expand Up @@ -552,7 +552,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"view",
input.reference,
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,headRefOid,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
Expand Down Expand Up @@ -1458,6 +1458,191 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("status drops a merged PR once its long-lived branch moves past it", () =>
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", "develop"]);
yield* runGit(repoDir, ["push", "-u", "origin", "develop"]);
// The commit the release PR was opened from.
const releasedHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]);

// `develop` is an integration branch, so work continues on it after the
// release merges into `main`.
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(NodePath.join(repoDir, "next.md"), "next\n");
yield* runGit(repoDir, ["add", "next.md"]);
yield* runGit(repoDir, ["commit", "-m", "Work after the release merged"]);
yield* runGit(repoDir, ["push", "origin", "develop"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 3,
title: "Release develop into main",
url: "https://github.com/pingdotgg/t3code/pull/3",
baseRefName: "main",
headRefName: "develop",
headRefOid: releasedHead.stdout.trim(),
state: "MERGED",
mergedAt: "2026-04-02T15:00:00Z",
updatedAt: "2026-04-02T15:00:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });

expect(status.refName).toBe("develop");
expect(status.pr).toBeNull();
}),
);

it.effect("status drops a merged PR once its branch is committed to without pushing", () =>
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", "develop"]);
yield* runGit(repoDir, ["push", "-u", "origin", "develop"]);
const releasedHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]);

// Local work that has not been pushed: `origin/develop` still sits on the
// released commit, but the branch a thread works on has moved past it.
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(NodePath.join(repoDir, "local.md"), "local\n");
yield* runGit(repoDir, ["add", "local.md"]);
yield* runGit(repoDir, ["commit", "-m", "Unpushed work after the release merged"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 3,
title: "Release develop into main",
url: "https://github.com/pingdotgg/t3code/pull/3",
baseRefName: "main",
headRefName: "develop",
headRefOid: releasedHead.stdout.trim(),
state: "MERGED",
mergedAt: "2026-04-02T15:00:00Z",
updatedAt: "2026-04-02T15:00:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });

expect(status.pr).toBeNull();
}),
);

it.effect("branch PR lookup ignores a nested child ref once the branch itself is gone", () =>
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/foo"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/foo"]);

// Git forbids `feature/foo` and `feature/foo/child` at once, so the
// sibling only surfaces once the branch itself is gone — exactly when the
// merged badge is supposed to be kept. `for-each-ref` still reports the
// child for the pattern `refs/heads/feature/foo`, and standing in for the
// deleted branch is what would wrongly drop the badge.
yield* runGit(repoDir, ["checkout", "main"]);
yield* runGit(repoDir, ["push", "origin", "--delete", "feature/foo"]);
yield* runGit(repoDir, ["branch", "-D", "feature/foo"]);
yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Later work on main"]);
yield* runGit(repoDir, ["branch", "feature/foo/child"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 217,
title: "Merged then deleted",
url: "https://github.com/pingdotgg/t3code/pull/217",
baseRefName: "main",
headRefName: "feature/foo",
headRefOid: "0".repeat(40),
state: "MERGED",
mergedAt: "2026-04-02T15:00:00Z",
updatedAt: "2026-04-02T15:00:00Z",
},
]),
],
},
});

const pullRequest = yield* manager.branchPullRequest({
cwd: repoDir,
branch: "feature/foo",
});

expect(pullRequest?.state).toBe("merged");
}),
);

it.effect("status keeps a merged PR while its branch still sits on the merged commit", () =>
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/merged-in-place"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-in-place"]);
// Squash merges leave the head branch on its own last commit, so the
// recorded head commit still matches and the badge has to survive.
const mergedHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 216,
title: "Merged in place",
url: "https://github.com/pingdotgg/t3code/pull/216",
baseRefName: "main",
headRefName: "feature/merged-in-place",
headRefOid: mergedHead.stdout.trim(),
state: "MERGED",
mergedAt: "2026-04-02T15:00:00Z",
updatedAt: "2026-04-02T15:00:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });

expect(status.pr?.number).toBe(216);
expect(status.pr?.state).toBe("merged");
}),
);

it.effect("status still looks up PRs for a branch pushed without --set-upstream", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -1612,7 +1797,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
updatedAt: "2026-03-10T07:00:00.000Z",
});
expect(ghCalls).toContain(
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,headRefOid,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
);
}),
20_000,
Expand Down Expand Up @@ -1678,7 +1863,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
updatedAt: "2026-03-10T07:00:00.000Z",
});
expect(ghCalls).toContain(
"pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,headRefOid,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
);
}),
20_000,
Expand Down
116 changes: 116 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const TOAST_DESCRIPTION_MAX = 72;
const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
const STATUS_RESULT_CACHE_CAPACITY = 2_048;
const PR_LOOKUP_CACHE_TTL = Duration.minutes(2);
const REMOTE_REF_PREFIX = "refs/remotes/";
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;
Expand Down Expand Up @@ -160,6 +161,7 @@ interface OpenPrInfo {
interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
state: "open" | "closed" | "merged";
updatedAt: Option.Option<DateTime.Utc>;
headRefOid?: string | null;
}

const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
Expand Down Expand Up @@ -403,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
...(summary.headRepositoryOwnerLogin !== undefined
? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
: {}),
...(summary.headRefOid !== undefined ? { headRefOid: summary.headRefOid } : {}),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
};
}

Expand Down Expand Up @@ -985,6 +988,107 @@ export const make = Effect.gen(function* () {
prLookupFailureStreakByKey.set(key, streak);
return prLookupFailureTtl(streak);
};

/**
* Whether `refName` is the remote-tracking ref for `headBranch`. With a known
* remote that is one exact name; without one, any single remote segment in
* front of the branch qualifies, which is what the `*` pattern asked for.
*/
const isRemoteTrackingRefFor = (
refName: string,
headBranch: string,
remoteName: string | null,
) => {
if (remoteName !== null) {
return refName === `${REMOTE_REF_PREFIX}${remoteName}/${headBranch}`;
}
if (!refName.startsWith(REMOTE_REF_PREFIX)) {
return false;
}
const withoutPrefix = refName.slice(REMOTE_REF_PREFIX.length);
const remoteSegmentEnd = withoutPrefix.indexOf("/");
return remoteSegmentEnd > 0 && withoutPrefix.slice(remoteSegmentEnd + 1) === headBranch;
};

/**
* The commit this branch currently points at: its local ref where it has one,
* otherwise the remote-tracking ref a change request would have been opened
* from. `null` when neither resolves, which is the deleted-branch case.
*
* The local ref wins because that is where a thread's work lands. A branch
* committed to but not yet pushed has still moved on from a merged change
* request, even while the remote-tracking ref sits at the old head.
*
* `for-each-ref` matches a pattern literally *or* up to a slash, so
* `refs/heads/feature/foo` also reports `refs/heads/feature/foo/child`. Every
* row is matched back against the ref it has to be, which rules those
* siblings out.
*/
const readBranchTipOid = Effect.fn("readBranchTipOid")(function* (
cwd: string,
headContext: Pick<BranchHeadContext, "headBranch" | "localBranch" | "remoteName">,
) {
const localRefs: string[] = [];
appendUnique(localRefs, `refs/heads/${headContext.localBranch}`);
appendUnique(localRefs, `refs/heads/${headContext.headBranch}`);
const remotePattern =
headContext.remoteName === null
? `${REMOTE_REF_PREFIX}*/${headContext.headBranch}`
: `${REMOTE_REF_PREFIX}${headContext.remoteName}/${headContext.headBranch}`;
const result = yield* gitCore.execute({
operation: "GitManager.readBranchTipOid",
cwd,
args: ["for-each-ref", "--format=%(refname)%00%(objectname)", ...localRefs, remotePattern],
timeoutMs: 5_000,
});
const oidByRefName = new Map<string, string>();
for (const line of result.stdout.split("\n")) {
const [refName = "", objectName = ""] = line.trim().split("\u0000");
if (refName.length > 0 && objectName.length > 0) {
oidByRefName.set(refName, objectName);
}
}
for (const localRef of localRefs) {
const oid = oidByRefName.get(localRef);
if (oid !== undefined) {
return oid;
}
}
for (const [refName, oid] of oidByRefName) {
if (isRemoteTrackingRefFor(refName, headContext.headBranch, headContext.remoteName)) {
return oid;
}
}
return null;
});

/**
* Whether the branch has moved off the commit a terminal change request was
* opened from. A merged or closed change request describes the branch as it
* stood at that commit; a branch pointing somewhere else was reused for later
* work, so the change request is history rather than this branch's context.
* Comparing commits rather than dates keeps squash and rebase merges working,
* since the recorded head commit is the branch's own, not the base's.
*
* `false` whenever the answer is not knowable — a forge that reports no head
* commit, no ref left to compare, or a failed git call — so losing the
* comparison can never drop a badge that is otherwise correct.
*/
const branchMovedPastChangeRequest = Effect.fn("branchMovedPastChangeRequest")(function* (
cwd: string,
headContext: Pick<BranchHeadContext, "headBranch" | "localBranch" | "remoteName">,
pullRequest: PullRequestInfo,
) {
const headRefOid = pullRequest.headRefOid?.trim().toLowerCase() ?? "";
if (headRefOid.length === 0 || headContext.headBranch.length === 0) {
return false;
}
return yield* Effect.gen(function* () {
const tipOid = yield* readBranchTipOid(cwd, headContext);
return tipOid !== null && tipOid.toLowerCase() !== headRefOid;
}).pipe(Effect.orElseSucceed(() => false));
});

const prLookupCache = yield* Cache.makeWith(
(key: string) => {
const [
Expand Down Expand Up @@ -1029,6 +1133,18 @@ export const make = Effect.gen(function* () {
return { latest: null, headContext };
}
const latest = yield* findLatestPrForHeadContext(cwd, headContext);
// A long-lived branch reused after its release merged (`develop` into
// `main`, then developed on) has moved off that change request's head
// commit, and every later thread on the branch would otherwise inherit
// the same historical number. Open change requests still follow their
// branch, so only terminal ones are checked.
if (
latest !== null &&
latest.state !== "open" &&
(yield* branchMovedPastChangeRequest(cwd, headContext, latest))
) {
return { latest: null, headContext };
}
return { latest, headContext };
});
},
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("GitHubCli.layer", () => {
"view",
"#42",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,headRefOid,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
cwd: "/repo",
timeoutMs: 30_000,
Expand Down
Loading
Loading