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
251 changes: 247 additions & 4 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"--limit",
String(input.limit ?? 1),
"--json",
"number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,headRefOid,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
Expand Down Expand Up @@ -566,7 +566,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"view",
input.reference,
"--json",
"number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,headRefOid,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
Expand Down Expand Up @@ -1593,6 +1593,249 @@ 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("branch lookup reports the terminal PR a reused branch has outgrown", () =>
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"]);

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 pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "develop" });
const outgrown = yield* manager.branchSupersededPullRequest({
cwd: repoDir,
branch: "develop",
pullRequest: { number: 3, url: "https://github.com/pingdotgg/t3code/pull/3" },
});
// A different change request on the same branch is not what was rejected,
// so a thread holding it keeps its historical reference.
const other = yield* manager.branchSupersededPullRequest({
cwd: repoDir,
branch: "develop",
pullRequest: { number: 9, url: "https://github.com/pingdotgg/t3code/pull/9" },
});

expect(pullRequest).toBeNull();
expect(outgrown).toBe(true);
expect(other).toBe(false);
}),
);

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 @@ -1927,7 +2170,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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,headRefOid,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
);
}),
20_000,
Expand Down Expand Up @@ -1993,7 +2236,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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,headRefOid,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
);
}),
20_000,
Expand Down
Loading
Loading