Skip to content

Commit 1627e83

Browse files
authored
Use default tree for new chats and show stash badges (#372)
- surface stash counts on git branches - create shortcut chats from the default tree - preserve worktree selection behavior in the branch picker
1 parent 9f92857 commit 1627e83

10 files changed

Lines changed: 370 additions & 78 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,30 @@ it.layer(TestLayer)("git integration", (it) => {
226226
}),
227227
);
228228

229+
it.effect("marks worktree branches that still have stashed changes", () =>
230+
Effect.gen(function* () {
231+
const tmp = yield* makeTmpDir();
232+
const { initialBranch } = yield* initRepoWithCommit(tmp);
233+
const worktree = yield* (yield* GitCore).createWorktree({
234+
cwd: tmp,
235+
branch: initialBranch,
236+
newBranch: "feature/stashed-worktree",
237+
path: null,
238+
});
239+
240+
yield* writeTextFile(path.join(worktree.worktree.path, "README.md"), "stashed change\n");
241+
yield* git(worktree.worktree.path, ["stash", "push", "-m", "save worktree state"]);
242+
243+
const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
244+
const stashedBranch = result.branches.find(
245+
(branch) => branch.name === "feature/stashed-worktree",
246+
);
247+
expect(stashedBranch?.name).toBe("feature/stashed-worktree");
248+
expect(stashedBranch?.worktreePath).toBe(fs.realpathSync(worktree.worktree.path));
249+
expect(stashedBranch?.stashCount).toBe(1);
250+
}),
251+
);
252+
229253
it.effect(
230254
"does not include detached HEAD pseudo-refs as branches",
231255
() =>

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

Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ function parseRemoteRefWithRemoteNames(
230230
return null;
231231
}
232232

233+
function parseStashBranchName(subject: string): string | null {
234+
const trimmed = subject.trim();
235+
if (trimmed.length === 0) {
236+
return null;
237+
}
238+
239+
const match = /^(?:WIP on|On)\s+(.+?):\s/.exec(trimmed);
240+
const branchName = match?.[1]?.trim() ?? "";
241+
return branchName.length > 0 ? branchName : null;
242+
}
243+
244+
function parseStashCounts(stdout: string): ReadonlyMap<string, number> {
245+
const counts = new Map<string, number>();
246+
for (const line of stdout.split("\n")) {
247+
const branchName = parseStashBranchName(line);
248+
if (!branchName) {
249+
continue;
250+
}
251+
counts.set(branchName, (counts.get(branchName) ?? 0) + 1);
252+
}
253+
return counts;
254+
}
255+
233256
function parseUpstreamRef(
234257
value: string,
235258
): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
@@ -1662,36 +1685,57 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
16621685
),
16631686
);
16641687

1665-
const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1666-
yield* Effect.all(
1667-
[
1668-
executeGit(
1669-
"GitCore.listBranches.defaultRef",
1670-
input.cwd,
1671-
["symbolic-ref", "refs/remotes/origin/HEAD"],
1672-
{
1673-
timeoutMs: 5_000,
1674-
allowNonZeroExit: true,
1675-
},
1676-
),
1677-
executeGit(
1678-
"GitCore.listBranches.worktreeList",
1679-
input.cwd,
1680-
["worktree", "list", "--porcelain"],
1681-
{
1682-
timeoutMs: 5_000,
1683-
allowNonZeroExit: true,
1684-
},
1685-
),
1686-
remoteBranchResultEffect,
1687-
remoteNamesResultEffect,
1688-
branchRecencyPromise,
1689-
],
1690-
{ concurrency: "unbounded" },
1691-
);
1688+
const stashListResultEffect = executeGit("GitCore.listBranches.stashList", input.cwd, [
1689+
"stash",
1690+
"list",
1691+
"--format=%gs",
1692+
]).pipe(
1693+
Effect.catch((error) =>
1694+
Effect.logWarning(
1695+
`GitCore.listBranches: stash lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty stash list.`,
1696+
).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1697+
),
1698+
);
1699+
1700+
const [
1701+
defaultRef,
1702+
worktreeList,
1703+
remoteBranchResult,
1704+
remoteNamesResult,
1705+
stashListResult,
1706+
branchLastCommit,
1707+
] = yield* Effect.all(
1708+
[
1709+
executeGit(
1710+
"GitCore.listBranches.defaultRef",
1711+
input.cwd,
1712+
["symbolic-ref", "refs/remotes/origin/HEAD"],
1713+
{
1714+
timeoutMs: 5_000,
1715+
allowNonZeroExit: true,
1716+
},
1717+
),
1718+
executeGit(
1719+
"GitCore.listBranches.worktreeList",
1720+
input.cwd,
1721+
["worktree", "list", "--porcelain"],
1722+
{
1723+
timeoutMs: 5_000,
1724+
allowNonZeroExit: true,
1725+
},
1726+
),
1727+
remoteBranchResultEffect,
1728+
remoteNamesResultEffect,
1729+
stashListResultEffect,
1730+
branchRecencyPromise,
1731+
],
1732+
{ concurrency: "unbounded" },
1733+
);
16921734

16931735
const remoteNames =
16941736
remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1737+
const stashCounts =
1738+
stashListResult.code === 0 ? parseStashCounts(stashListResult.stdout) : new Map();
16951739
if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
16961740
yield* Effect.logWarning(
16971741
`GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
@@ -1702,6 +1746,11 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
17021746
`GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
17031747
);
17041748
}
1749+
if (stashListResult.code !== 0 && stashListResult.stderr.trim().length > 0) {
1750+
yield* Effect.logWarning(
1751+
`GitCore.listBranches: stash lookup returned code ${stashListResult.code} for ${input.cwd}: ${stashListResult.stderr.trim()}. Falling back to an empty stash list.`,
1752+
);
1753+
}
17051754

17061755
const defaultBranch =
17071756
defaultRef.code === 0
@@ -1731,13 +1780,27 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
17311780
.split("\n")
17321781
.map(parseBranchLine)
17331782
.filter((branch): branch is { name: string; current: boolean } => branch !== null)
1734-
.map((branch) => ({
1735-
name: branch.name,
1736-
current: branch.current,
1737-
isRemote: false,
1738-
isDefault: branch.name === defaultBranch,
1739-
worktreePath: worktreeMap.get(branch.name) ?? null,
1740-
}))
1783+
.map((branch) => {
1784+
const localBranch: {
1785+
name: string;
1786+
current: boolean;
1787+
isRemote: boolean;
1788+
isDefault: boolean;
1789+
worktreePath: string | null;
1790+
stashCount?: number;
1791+
} = {
1792+
name: branch.name,
1793+
current: branch.current,
1794+
isRemote: false,
1795+
isDefault: branch.name === defaultBranch,
1796+
worktreePath: worktreeMap.get(branch.name) ?? null,
1797+
};
1798+
const stashCount = stashCounts.get(branch.name);
1799+
if (stashCount !== undefined) {
1800+
localBranch.stashCount = stashCount;
1801+
}
1802+
return localBranch;
1803+
})
17411804
.toSorted((a, b) => {
17421805
const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
17431806
const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { GitBranch } from "@okcode/contracts";
22
import { describe, expect, it } from "vitest";
33
import {
4+
buildBranchMetadataBadges,
45
dedupeRemoteBranchesWithLocalMatches,
56
deriveLocalBranchNameFromRemoteRef,
67
filterSelectableBranches,
8+
formatBranchStashBadgeLabel,
79
resolveBranchSelectionTarget,
810
resolveDraftEnvModeAfterBranchChange,
911
resolveBranchToolbarValue,
@@ -133,6 +135,50 @@ describe("filterSelectableBranches", () => {
133135
});
134136
});
135137

138+
describe("formatBranchStashBadgeLabel", () => {
139+
it("returns null when a branch has no stashes", () => {
140+
expect(formatBranchStashBadgeLabel(undefined)).toBeNull();
141+
expect(formatBranchStashBadgeLabel(0)).toBeNull();
142+
});
143+
144+
it("formats stash badges compactly", () => {
145+
expect(formatBranchStashBadgeLabel(1)).toBe("stash");
146+
expect(formatBranchStashBadgeLabel(3)).toBe("stash 3");
147+
});
148+
});
149+
150+
describe("buildBranchMetadataBadges", () => {
151+
it("includes stash badges alongside worktree metadata", () => {
152+
expect(
153+
buildBranchMetadataBadges({
154+
activeProjectCwd: "/repo/project",
155+
branch: {
156+
current: false,
157+
isDefault: false,
158+
isRemote: false,
159+
stashCount: 2,
160+
worktreePath: "/repo/worktrees/feature-a",
161+
},
162+
}),
163+
).toEqual(["worktree", "stash 2"]);
164+
});
165+
166+
it("does not treat the root checkout as a secondary worktree", () => {
167+
expect(
168+
buildBranchMetadataBadges({
169+
activeProjectCwd: "/repo/project",
170+
branch: {
171+
current: true,
172+
isDefault: true,
173+
isRemote: false,
174+
stashCount: 1,
175+
worktreePath: "/repo/project",
176+
},
177+
}),
178+
).toEqual(["current", "default", "stash"]);
179+
});
180+
});
181+
136182
describe("dedupeRemoteBranchesWithLocalMatches", () => {
137183
it("hides remote refs when the matching local branch exists", () => {
138184
const input: GitBranch[] = [

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,41 @@ export function filterSelectableBranches(
7777
return branches.filter((branch) => !branch.isRemote);
7878
}
7979

80+
export function formatBranchStashBadgeLabel(stashCount: number | undefined): string | null {
81+
if (!stashCount || stashCount < 1) {
82+
return null;
83+
}
84+
return stashCount === 1 ? "stash" : `stash ${stashCount}`;
85+
}
86+
87+
export function buildBranchMetadataBadges(input: {
88+
activeProjectCwd: string;
89+
branch: Pick<GitBranch, "current" | "isDefault" | "isRemote" | "stashCount" | "worktreePath">;
90+
}): ReadonlyArray<string> {
91+
const badges: string[] = [];
92+
const hasSecondaryWorktree =
93+
input.branch.worktreePath !== null && input.branch.worktreePath !== input.activeProjectCwd;
94+
95+
if (input.branch.current) {
96+
badges.push("current");
97+
}
98+
if (hasSecondaryWorktree) {
99+
badges.push("worktree");
100+
}
101+
if (input.branch.isRemote) {
102+
badges.push("remote");
103+
}
104+
if (input.branch.isDefault) {
105+
badges.push("default");
106+
}
107+
const stashBadge = formatBranchStashBadgeLabel(input.branch.stashCount);
108+
if (stashBadge) {
109+
badges.push(stashBadge);
110+
}
111+
112+
return badges;
113+
}
114+
80115
export function dedupeRemoteBranchesWithLocalMatches(
81116
branches: ReadonlyArray<GitBranch>,
82117
options?: {

0 commit comments

Comments
 (0)