Skip to content

Commit 94551c2

Browse files
Track upstream branch when deciding PR availability
- Propagate normalized upstream branch metadata through git status - Block PR actions when the branch tracks the default branch - Add coverage for upstream parsing, caching, and UI logic
1 parent e2b74bd commit 94551c2

10 files changed

Lines changed: 215 additions & 4 deletions

File tree

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,25 @@ it.layer(TestLayer)("git integration", (it) => {
14931493
}),
14941494
);
14951495

1496+
it.effect("reports the tracked branch name without the remote prefix", () =>
1497+
Effect.gen(function* () {
1498+
const remote = yield* makeTmpDir();
1499+
const tmp = yield* makeTmpDir();
1500+
const remoteName = "my-org/upstream";
1501+
const branchName = "feature/status-upstream";
1502+
1503+
yield* git(remote, ["init", "--bare"]);
1504+
yield* initRepoWithCommit(tmp);
1505+
yield* git(tmp, ["remote", "add", remoteName, remote]);
1506+
yield* git(tmp, ["checkout", "-b", branchName]);
1507+
yield* git(tmp, ["push", "-u", remoteName, branchName]);
1508+
1509+
const details = yield* (yield* GitCore).statusDetails(tmp);
1510+
expect(details.upstreamRef).toBe(`${remoteName}/${branchName}`);
1511+
expect(details.upstreamBranch).toBe(branchName);
1512+
}),
1513+
);
1514+
14961515
it.effect("counts untracked text files in working tree totals", () =>
14971516
Effect.gen(function* () {
14981517
const tmp = yield* makeTmpDir();

apps/server/src/git/Layers/GitCore.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({
5454
isDefaultBranch: false,
5555
branch: null,
5656
upstreamRef: null,
57+
upstreamBranch: null,
5758
hasWorkingTreeChanges: false,
5859
workingTree: { files: [], insertions: 0, deletions: 0 },
5960
hasUpstream: false,
@@ -98,6 +99,13 @@ function parseBranchAb(value: string): { ahead: number; behind: number } {
9899
};
99100
}
100101

102+
function normalizeConfiguredMergeBranch(value: string): string | null {
103+
const trimmed = value.trim();
104+
if (trimmed.length === 0) return null;
105+
const normalized = trimmed.replace(/^refs\/heads\//, "");
106+
return normalized.length > 0 ? normalized : null;
107+
}
108+
101109
function normalizeNumstatPath(rawPath: string): string {
102110
const renameArrowIndex = rawPath.indexOf(" => ");
103111
if (renameArrowIndex < 0) return rawPath;
@@ -1308,6 +1316,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
13081316

13091317
let branch: string | null = null;
13101318
let upstreamRef: string | null = null;
1319+
let upstreamBranch: string | null = null;
13111320
let aheadCount = 0;
13121321
let behindCount = 0;
13131322
let hasWorkingTreeChanges = false;
@@ -1354,6 +1363,18 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
13541363
}
13551364
}
13561365

1366+
if (branch && upstreamRef) {
1367+
upstreamBranch = yield* runGitStdout(
1368+
"GitCore.statusDetails.upstreamMergeBranch",
1369+
cwd,
1370+
["config", "--get", `branch.${branch}.merge`],
1371+
true,
1372+
).pipe(
1373+
Effect.map(normalizeConfiguredMergeBranch),
1374+
Effect.catch(() => Effect.succeed(null)),
1375+
);
1376+
}
1377+
13571378
if (!upstreamRef && branch) {
13581379
aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
13591380
Effect.catch(() => Effect.succeed(0)),
@@ -1371,6 +1392,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
13711392
return {
13721393
branch,
13731394
upstreamRef,
1395+
upstreamBranch,
13741396
hasWorkingTreeChanges,
13751397
workingTree: moveAwareWorkingTree,
13761398
hasUpstream: upstreamRef !== null,
@@ -1428,6 +1450,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
14281450
return {
14291451
branch,
14301452
upstreamRef,
1453+
upstreamBranch,
14311454
hasWorkingTreeChanges,
14321455
workingTree: {
14331456
files,
@@ -1447,6 +1470,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
14471470
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
14481471
workingTree: details.workingTree,
14491472
hasUpstream: details.hasUpstream,
1473+
upstreamBranch: details.upstreamBranch,
14501474
aheadCount: details.aheadCount,
14511475
behindCount: details.behindCount,
14521476
pr: null,

apps/server/src/git/Layers/GitManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,7 @@ export const makeGitManager = Effect.gen(function* () {
14751475
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
14761476
workingTree: details.workingTree,
14771477
hasUpstream: details.hasUpstream,
1478+
upstreamBranch: details.upstreamBranch,
14781479
aheadCount: details.aheadCount,
14791480
behindCount: details.behindCount,
14801481
pr,

apps/server/src/git/Layers/GitStatusBroadcaster.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const baseStatus: GitStatusResult = {
1313
hasWorkingTreeChanges: false,
1414
workingTree: { files: [], insertions: 0, deletions: 0 },
1515
hasUpstream: true,
16+
upstreamBranch: "feature/status-broadcast",
1617
aheadCount: 0,
1718
behindCount: 0,
1819
pr: null,
@@ -21,6 +22,7 @@ const baseStatus: GitStatusResult = {
2122
const baseDetails: GitStatusDetails = {
2223
branch: baseStatus.branch,
2324
upstreamRef: "origin/feature/status-broadcast",
25+
upstreamBranch: baseStatus.upstreamBranch,
2426
hasWorkingTreeChanges: baseStatus.hasWorkingTreeChanges,
2527
workingTree: baseStatus.workingTree,
2628
hasUpstream: baseStatus.hasUpstream,
@@ -267,6 +269,7 @@ describe("GitStatusBroadcasterLive", () => {
267269
},
268270
remote: {
269271
hasUpstream: baseStatus.hasUpstream,
272+
upstreamBranch: baseStatus.upstreamBranch,
270273
aheadCount: baseStatus.aheadCount,
271274
behindCount: baseStatus.behindCount,
272275
pr: baseStatus.pr,

apps/server/src/git/gitStatusCache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function splitLocalStatusDetails(status: GitStatusDetails): GitStatusLoca
4646
export function splitRemoteStatus(status: GitStatusResult): GitStatusRemoteResult {
4747
return {
4848
hasUpstream: status.hasUpstream,
49+
upstreamBranch: status.upstreamBranch,
4950
aheadCount: status.aheadCount,
5051
behindCount: status.behindCount,
5152
pr: status.pr,
@@ -58,6 +59,7 @@ export function splitRemoteStatusDetails(
5859
): GitStatusRemoteResult {
5960
return {
6061
hasUpstream: status.hasUpstream,
62+
upstreamBranch: status.upstreamBranch,
6163
aheadCount: status.aheadCount,
6264
behindCount: status.behindCount,
6365
pr: cachedRemote?.pr ?? null,
@@ -71,7 +73,9 @@ export function canReuseCachedRemoteStatus(input: {
7173
readonly ttlMs?: number;
7274
}): boolean {
7375
if (!input.cached.local || !input.cached.remote) return false;
76+
if (!input.cached.remote.value) return false;
7477
if (input.details.branch !== input.cached.local.value.branch) return false;
78+
if (input.details.upstreamBranch !== input.cached.remote.value.upstreamBranch) return false;
7579
return (
7680
(input.now ?? Date.now()) - input.cached.remote.updatedAt <
7781
(input.ttlMs ?? REMOTE_STATUS_CACHE_TTL_MS)

apps/web/src/components/GitActionsControl.logic.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
2424
deletions: 0,
2525
},
2626
hasUpstream: true,
27+
upstreamBranch: "feature/test",
2728
aheadCount: 0,
2829
behindCount: 0,
2930
pr: null,
@@ -296,6 +297,92 @@ describe("when: branch is clean, up to date, and has no open PR", () => {
296297
},
297298
]);
298299
});
300+
301+
it("resolveQuickAction blocks PR when the branch tracks the default branch", () => {
302+
const quick = resolveQuickAction(
303+
status({
304+
branch: "dpcode/pi-cleanup",
305+
upstreamBranch: "main",
306+
aheadCount: 0,
307+
behindCount: 0,
308+
pr: null,
309+
}),
310+
false,
311+
false,
312+
true,
313+
false,
314+
"main",
315+
);
316+
317+
assert.deepEqual(quick, {
318+
kind: "show_hint",
319+
label: "Create PR",
320+
hint: "No branch changes to include in a PR.",
321+
disabled: true,
322+
});
323+
});
324+
325+
it("buildMenuItems disables create PR when the branch tracks the default branch", () => {
326+
const items = buildMenuItems(
327+
status({
328+
branch: "dpcode/pi-cleanup",
329+
upstreamBranch: "main",
330+
aheadCount: 0,
331+
behindCount: 0,
332+
pr: null,
333+
}),
334+
false,
335+
true,
336+
false,
337+
"main",
338+
);
339+
340+
assert.deepEqual(items, [
341+
{
342+
id: "commit",
343+
label: "Commit",
344+
disabled: true,
345+
icon: "commit",
346+
kind: "open_dialog",
347+
dialogAction: "commit",
348+
},
349+
{
350+
id: "push",
351+
label: "Push",
352+
disabled: true,
353+
icon: "push",
354+
kind: "open_dialog",
355+
dialogAction: "push",
356+
},
357+
{
358+
id: "pr",
359+
label: "Create PR",
360+
disabled: true,
361+
icon: "pr",
362+
kind: "open_dialog",
363+
dialogAction: "create_pr",
364+
},
365+
]);
366+
});
367+
368+
it("resolveQuickAction blocks PR when the upstream branch name is unknown", () => {
369+
const quick = resolveQuickAction(
370+
status({
371+
upstreamBranch: null,
372+
aheadCount: 0,
373+
behindCount: 0,
374+
pr: null,
375+
}),
376+
false,
377+
);
378+
379+
assert.deepEqual(quick, {
380+
kind: "show_hint",
381+
label: "Create PR",
382+
hint: "No branch changes to include in a PR.",
383+
disabled: true,
384+
});
385+
});
299386
});
300387

301388
describe("when: branch is behind upstream", () => {

apps/web/src/components/GitActionsControl.logic.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface GitQuickAction {
2626
hint?: string;
2727
}
2828

29+
const FALLBACK_DEFAULT_BRANCH_NAMES = new Set(["main", "master"]);
30+
2931
export interface DefaultBranchActionDialogCopy {
3032
title: string;
3133
description: string;
@@ -105,6 +107,22 @@ export function buildGitActionProgressStages(input: {
105107
const withDescription = (title: string, description: string | undefined) =>
106108
description ? { title, description } : { title };
107109

110+
function extractTrackedBranchName(upstreamBranch: string | null | undefined): string | null {
111+
if (!upstreamBranch) return null;
112+
const branchName = upstreamBranch.trim();
113+
return branchName.length > 0 ? branchName : null;
114+
}
115+
116+
function tracksDefaultUpstream(
117+
gitStatus: GitStatusResult,
118+
defaultBranchName?: string | null,
119+
): boolean {
120+
const trackedBranchName = extractTrackedBranchName(gitStatus.upstreamBranch);
121+
if (!trackedBranchName) return false;
122+
if (defaultBranchName) return trackedBranchName === defaultBranchName;
123+
return FALLBACK_DEFAULT_BRANCH_NAMES.has(trackedBranchName);
124+
}
125+
108126
export function summarizeGitResult(result: GitRunStackedActionResult): {
109127
title: string;
110128
description?: string;
@@ -140,13 +158,19 @@ export function buildMenuItems(
140158
isBusy: boolean,
141159
hasOriginRemote = true,
142160
isDefaultBranch = false,
161+
defaultBranchName?: string | null,
143162
): GitActionMenuItem[] {
144163
if (!gitStatus) return [];
145164

146165
const hasBranch = gitStatus.branch !== null;
147166
const hasChanges = gitStatus.hasWorkingTreeChanges;
148167
const hasOpenPr = gitStatus.pr?.state === "open";
149168
const isBehind = gitStatus.behindCount > 0;
169+
const canCreateCleanPublishedPr =
170+
!isDefaultBranch &&
171+
gitStatus.hasUpstream &&
172+
gitStatus.upstreamBranch !== null &&
173+
!tracksDefaultUpstream(gitStatus, defaultBranchName);
150174
const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
151175
const canCommit = !isBusy && hasChanges;
152176
const canPush =
@@ -168,7 +192,7 @@ export function buildMenuItems(
168192
!hasChanges &&
169193
!hasOpenPr &&
170194
!isBehind &&
171-
((!isDefaultBranch && gitStatus.hasUpstream) ||
195+
(canCreateCleanPublishedPr ||
172196
(gitStatus.aheadCount > 0 && (gitStatus.hasUpstream || canPushWithoutUpstream)));
173197
const canOpenPr = !isBusy && hasOpenPr;
174198

@@ -226,6 +250,7 @@ export function resolveQuickAction(
226250
isDefaultBranch = false,
227251
hasOriginRemote = true,
228252
shouldOfferCreateBranch = false,
253+
defaultBranchName?: string | null,
229254
): GitQuickAction {
230255
if (isBusy) {
231256
return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
@@ -246,6 +271,8 @@ export function resolveQuickAction(
246271
const isAhead = gitStatus.aheadCount > 0;
247272
const isBehind = gitStatus.behindCount > 0;
248273
const isDiverged = isAhead && isBehind;
274+
const isTrackingDefaultUpstream = tracksDefaultUpstream(gitStatus, defaultBranchName);
275+
const hasKnownUpstreamBranch = gitStatus.upstreamBranch !== null;
249276

250277
if (!hasBranch) {
251278
return {
@@ -288,7 +315,12 @@ export function resolveQuickAction(
288315
return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
289316
}
290317
if (hasOpenPr || isDefaultBranch) {
291-
return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
318+
return {
319+
label: "Commit & push",
320+
disabled: false,
321+
kind: "run_action",
322+
action: "commit_push",
323+
};
292324
}
293325
return {
294326
label: "Commit, push & PR",
@@ -358,6 +390,24 @@ export function resolveQuickAction(
358390
return { label: "View PR", disabled: false, kind: "open_pr" };
359391
}
360392

393+
if (gitStatus.hasUpstream && !hasKnownUpstreamBranch) {
394+
return {
395+
label: "Create PR",
396+
disabled: true,
397+
kind: "show_hint",
398+
hint: "No branch changes to include in a PR.",
399+
};
400+
}
401+
402+
if (isTrackingDefaultUpstream) {
403+
return {
404+
label: "Create PR",
405+
disabled: true,
406+
kind: "show_hint",
407+
hint: "No branch changes to include in a PR.",
408+
};
409+
}
410+
361411
if (!isDefaultBranch) {
362412
return {
363413
label: "Create PR",

0 commit comments

Comments
 (0)