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
238 changes: 238 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,244 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

function squashMergeIntoRemoteMain(repoDir: string, branch: string) {
return Effect.gen(function* () {
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", branch]);
NodeFS.writeFileSync(NodePath.join(repoDir, "squash.txt"), "one\ntwo\n");
yield* runGit(repoDir, ["add", "squash.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Feature work"]);
yield* runGit(repoDir, ["push", "-u", "origin", branch]);
// The host squash-merges: a new commit on main with the branch's tree, not the branch's commit.
const squash = yield* runGit(repoDir, [
"commit-tree",
"HEAD^{tree}",
"-p",
"origin/main",
"-m",
"Feature work (#217)",
]);
yield* runGit(repoDir, ["push", "origin", `${squash.stdout.trim()}:refs/heads/main`]);
return (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
});
}

const squashMergedPr = (branch: string, headSha: string): FakeChangeRequest => ({
number: 217,
title: "Feature work",
url: "https://github.com/pingdotgg/t3code/pull/217",
baseRefName: "main",
headRefName: branch,
headSha,
state: "merged",
mergedAt: "2026-04-02T15:00:00Z",
updatedAt: "2026-04-02T15:00:00Z",
});

it.effect("status reports nothing to ship after a squash merge deleted the remote branch", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/squash-deleted";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
yield* runGit(repoDir, ["push", "origin", "--delete", branch]);
yield* runGit(repoDir, ["fetch", "--prune", "origin"]);

const { manager } = yield* makeManager({
providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] },
});

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

expect(status.pr?.state).toBe("merged");
expect(status.hasUpstream).toBe(false);
expect({
aheadCount: status.aheadCount,
aheadOfDefaultCount: status.aheadOfDefaultCount,
}).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 });
}),
);

it.effect("status reports nothing to ship after a squash merge kept the remote branch", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/squash-kept";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
yield* runGit(repoDir, ["fetch", "--prune", "origin"]);

const { manager } = yield* makeManager({
providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] },
});

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

expect(status.pr?.state).toBe("merged");
expect(status.hasUpstream).toBe(true);
expect({
aheadCount: status.aheadCount,
aheadOfDefaultCount: status.aheadOfDefaultCount,
}).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 });
}),
);

it.effect("status still reports work to ship for a commit added after a squash merge", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/squash-follow-up";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
NodeFS.writeFileSync(NodePath.join(repoDir, "follow-up.txt"), "after merge\n");
yield* runGit(repoDir, ["add", "follow-up.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Follow-up work"]);
yield* runGit(repoDir, ["fetch", "--prune", "origin"]);

const { manager } = yield* makeManager({
providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] },
});

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

expect(status.pr?.state).toBe("merged");
expect(status.aheadCount).toBe(1);
expect(status.aheadOfDefaultCount).toBe(2);
}),
);

it.effect("status still reports work against main after a merge into another base", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const initialSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/release-only";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
// Move the merge to release and leave main without it.
const squashSha = (yield* runGit(repoDir, ["rev-parse", "origin/main"])).stdout.trim();
yield* runGit(repoDir, ["push", "origin", `${squashSha}:refs/heads/release`]);
yield* runGit(repoDir, ["push", "--force", "origin", `${initialSha}:refs/heads/main`]);
yield* runGit(repoDir, ["fetch", "origin"]);

const { manager } = yield* makeManager({
providerScenario: {
prListSequence: [[{ ...squashMergedPr(branch, headSha), baseRefName: "release" }]],
},
});

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

expect(status.pr?.state).toBe("merged");
expect(status.aheadOfDefaultCount).toBe(1);
}),
);

it.effect("status keeps a squash-merged branch shipped when a later PR lookup fails", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/squash-lookup-fails";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
yield* runGit(repoDir, ["push", "origin", "--delete", branch]);
yield* runGit(repoDir, ["fetch", "--prune", "origin"]);

const { manager } = yield* makeManager({
providerScenario: {
prListSequence: [[squashMergedPr(branch, headSha)]],
failWith: "Rate limited",
failAfterCalls: 1,
},
});

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

expect(first.aheadOfDefaultCount).toBe(0);
expect(second.pr?.state).toBe("merged");
expect({
aheadCount: second.aheadCount,
aheadOfDefaultCount: second.aheadOfDefaultCount,
}).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 });
}),
);

it.effect("status keeps real divergence for a squash-merged branch that tracks its base", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
const branch = "feature/squash-tracks-main";
const headSha = yield* squashMergeIntoRemoteMain(repoDir, branch);
yield* runGit(repoDir, ["fetch", "origin"]);
yield* runGit(repoDir, ["branch", "--set-upstream-to", "origin/main"]);

const { manager } = yield* makeManager({
providerScenario: { prListSequence: [[squashMergedPr(branch, headSha)]] },
});

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

expect(status.pr?.state).toBe("merged");
expect({ aheadCount: status.aheadCount, behindCount: status.behindCount }).toEqual({
aheadCount: 1,
behindCount: 1,
});
}),
);

it.effect("status reports nothing to ship after a squash merge into a non-origin default", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["branch", "-m", "develop"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]);
yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]);
const branch = "feature/squash-develop";
yield* runGit(repoDir, ["checkout", "-b", branch]);
NodeFS.writeFileSync(NodePath.join(repoDir, "squash.txt"), "one\ntwo\n");
yield* runGit(repoDir, ["add", "squash.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Feature work"]);
yield* runGit(repoDir, ["push", "-u", "upstream", branch]);
const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
const squash = yield* runGit(repoDir, [
"commit-tree",
"HEAD^{tree}",
"-p",
"upstream/develop",
"-m",
"Feature work (#217)",
]);
yield* runGit(repoDir, ["push", "upstream", `${squash.stdout.trim()}:refs/heads/develop`]);
yield* runGit(repoDir, ["fetch", "upstream"]);

const { manager } = yield* makeManager({
providerScenario: {
prListSequence: [[{ ...squashMergedPr(branch, headSha), baseRefName: "develop" }]],
},
});

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

expect(status.pr?.state).toBe("merged");
expect({
aheadCount: status.aheadCount,
aheadOfDefaultCount: status.aheadOfDefaultCount,
}).toEqual({ aheadCount: 0, aheadOfDefaultCount: 0 });
}),
);

it.each([
[
"https://github.example.com/team/repository/pull/42?tab=files",
Expand Down
54 changes: 41 additions & 13 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@ export const make = Effect.gen(function* () {
const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key));
interface LastKnownPr {
readonly pr: ReturnType<typeof toStatusPr> | null;
readonly headSha: string | null;
readonly upstreamRef: string | null;
readonly headBranch: string;
readonly remoteName: string | null;
Expand All @@ -1279,7 +1280,7 @@ export const make = Effect.gen(function* () {
const resolveLastKnownPr = (
branchKey: string,
current: Pick<LastKnownPr, "upstreamRef" | "headBranch" | "remoteName" | "headRemoteUrlKey">,
): ReturnType<typeof toStatusPr> | null => {
): LastKnownPr | null => {
const lastKnown = lastKnownPrByBranchKey.get(branchKey);
if (!lastKnown) return null;
if (lastKnown.headBranch !== current.headBranch) {
Expand All @@ -1288,7 +1289,7 @@ export const make = Effect.gen(function* () {

// Config read failures become null; require both remote URLs before treating a mismatch as real.
if (lastKnown.headRemoteUrlKey !== null && current.headRemoteUrlKey !== null) {
return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown.pr : null;
return lastKnown.headRemoteUrlKey === current.headRemoteUrlKey ? lastKnown : null;
}

if (
Expand All @@ -1297,9 +1298,9 @@ export const make = Effect.gen(function* () {
lastKnown.remoteName !== null &&
current.remoteName !== null
) {
return lastKnown.remoteName === current.remoteName ? lastKnown.pr : null;
return lastKnown.remoteName === current.remoteName ? lastKnown : null;
}
return lastKnown.pr;
return lastKnown;
};
const lookupStatusPr = Effect.fn("lookupStatusPr")(function* (
cwd: string,
Expand All @@ -1324,25 +1325,26 @@ export const make = Effect.gen(function* () {
}
return yield* getPrLookup(cacheKey).pipe(
Effect.map(({ latest, headContext }) => {
if (!latest) return { pr: null, headContext };
if (!latest) return { pr: null, headSha: null, headContext };
// Merged/closed matches are usually reverse-merge history, not the thread's PR context.
if (details.isDefaultBranch && latest.state !== "open") {
return { pr: null, headContext };
return { pr: null, headSha: null, headContext };
}
return { pr: toStatusPr(latest), headContext };
return { pr: toStatusPr(latest), headSha: latest.headSha ?? null, headContext };
}),
Effect.tap(({ pr, headContext }) =>
Effect.tap(({ pr, headSha, headContext }) =>
Effect.sync(() =>
rememberLastKnownPr(branchKey, {
pr,
headSha,
upstreamRef: details.upstreamRef,
headBranch: headContext.headBranch,
remoteName: headContext.remoteName,
headRemoteUrlKey: headContext.headRemoteUrlKey,
}),
),
),
Effect.map(({ pr }) => pr),
Effect.map(({ pr, headSha }) => ({ pr, headSha })),
Effect.catch((error) => {
const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr
? resolveLookupHeadContext(cwd, details).pipe(
Expand Down Expand Up @@ -1374,6 +1376,10 @@ export const make = Effect.gen(function* () {
: {}),
}),
Effect.andThen(fallback),
Effect.map((lastKnown) => ({
pr: lastKnown?.pr ?? null,
headSha: lastKnown?.headSha ?? null,
})),
);
}),
);
Expand All @@ -1389,7 +1395,7 @@ export const make = Effect.gen(function* () {
return null;
}

const pr =
const { pr, headSha } =
details.branch !== null
? yield* lookupStatusPr(
cwd,
Expand All @@ -1401,13 +1407,35 @@ export const make = Effect.gen(function* () {
},
options?.refreshMissingPullRequest,
)
: null;
: { pr: null, headSha: null };
// Squash and rebase merges land new commits, so HEAD never becomes reachable from the base.
const shippedByMergedPr =
pr?.state === "merged" &&
headSha !== null &&
(yield* Effect.gen(function* () {
const defaultBranch =
details.defaultBranch ??
(yield* gitCore.resolvePrimaryRemoteName(cwd).pipe(
Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)),
Effect.orElseSucceed(() => null),
));
const mergedIntoDefault =
defaultBranch === null
? pr.baseRef === "main" || pr.baseRef === "master"
: pr.baseRef === defaultBranch;
if (!mergedIntoDefault) return false;
return yield* gitCore.resolveCommit({ cwd, revision: "HEAD" }).pipe(
Effect.map(({ commitSha }) => commitSha === headSha),
Effect.orElseSucceed(() => false),
);
}));

return {
hasUpstream: details.hasUpstream,
aheadCount: details.aheadCount,
// A real upstream delta stays; only the no-upstream fallback counts against the base.
aheadCount: shippedByMergedPr && !details.hasUpstream ? 0 : details.aheadCount,
behindCount: details.behindCount,
aheadOfDefaultCount: details.aheadOfDefaultCount,
aheadOfDefaultCount: shippedByMergedPr ? 0 : details.aheadOfDefaultCount,
pr,
} satisfies VcsStatusRemoteResult;
});
Expand Down
Loading