Skip to content

Commit bd4504d

Browse files
committed
feat(pinned): show git branch in project status
1 parent 70a327b commit bd4504d

4 files changed

Lines changed: 179 additions & 5 deletions

File tree

src/pinned/manager.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Api } from "grammy";
2+
import { readFile, stat } from "node:fs/promises";
3+
import path from "node:path";
24
import { logger } from "../utils/logger.js";
35
import { opencodeClient } from "../opencode/client.js";
46
import { getCurrentSession } from "../session/manager.js";
@@ -28,6 +30,7 @@ class PinnedMessageManager {
2830
sessionId: null,
2931
sessionTitle: t("pinned.default_session_title"),
3032
projectName: "",
33+
projectBranch: null,
3134
tokensUsed: 0,
3235
tokensLimit: 0,
3336
lastUpdated: 0,
@@ -71,9 +74,7 @@ class PinnedMessageManager {
7174
this.state.sessionId = sessionId;
7275
this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
7376

74-
const project = getCurrentProject();
75-
this.state.projectName =
76-
project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
77+
await this.refreshProjectMetadata();
7778

7879
// Fetch context limit for current model
7980
await this.fetchContextLimit();
@@ -232,6 +233,7 @@ class PinnedMessageManager {
232233
* Used at thinking time to push accumulated silent updates to Telegram.
233234
*/
234235
async refresh(): Promise<void> {
236+
await this.refreshProjectMetadata();
235237
await this.updatePinnedMessage(true);
236238
}
237239

@@ -529,6 +531,69 @@ class PinnedMessageManager {
529531
}
530532
}
531533

534+
/**
535+
* Refresh current project name and git branch.
536+
*/
537+
private async refreshProjectMetadata(): Promise<void> {
538+
const project = getCurrentProject();
539+
this.state.projectName =
540+
project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
541+
this.state.projectBranch = await this.getGitBranchName(project?.worktree);
542+
}
543+
544+
/**
545+
* Resolve current git branch for a project worktree.
546+
*/
547+
private async getGitBranchName(worktree: string | undefined): Promise<string | null> {
548+
if (!worktree) {
549+
return null;
550+
}
551+
552+
try {
553+
const gitDir = await this.resolveGitDir(worktree);
554+
if (!gitDir) {
555+
return null;
556+
}
557+
558+
const headPath = path.join(gitDir, "HEAD");
559+
const headContent = (await readFile(headPath, "utf-8")).trim();
560+
const match = headContent.match(/^ref:\s+refs\/heads\/(.+)$/);
561+
return match?.[1] || null;
562+
} catch (err) {
563+
logger.debug("[PinnedManager] Could not resolve git branch:", err);
564+
return null;
565+
}
566+
}
567+
568+
/**
569+
* Resolve git directory for a normal repository or linked worktree.
570+
*/
571+
private async resolveGitDir(worktree: string): Promise<string | null> {
572+
const gitPath = path.join(worktree, ".git");
573+
574+
try {
575+
const gitStat = await stat(gitPath);
576+
577+
if (gitStat.isDirectory()) {
578+
return gitPath;
579+
}
580+
581+
if (!gitStat.isFile()) {
582+
return null;
583+
}
584+
585+
const gitPointer = (await readFile(gitPath, "utf-8")).trim();
586+
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
587+
if (!match) {
588+
return null;
589+
}
590+
591+
return path.resolve(worktree, match[1].trim());
592+
} catch {
593+
return null;
594+
}
595+
}
596+
532597
/**
533598
* Extract project name from worktree path
534599
*/
@@ -606,10 +671,13 @@ class PinnedMessageManager {
606671
private formatMessage(): string {
607672
const currentModel = getStoredModel();
608673
const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
674+
const projectDisplayName = this.state.projectBranch
675+
? `${this.state.projectName}: ${this.state.projectBranch}`
676+
: this.state.projectName;
609677

610678
const lines = [
611679
`${this.state.sessionTitle}`,
612-
t("pinned.line.project", { project: this.state.projectName }),
680+
t("pinned.line.project", { project: projectDisplayName }),
613681
t("pinned.line.model", { model: modelName }),
614682
formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
615683
];
@@ -805,6 +873,7 @@ class PinnedMessageManager {
805873
this.state.sessionId = null;
806874
this.state.tokensUsed = 0;
807875
this.state.tokensLimit = 0;
876+
this.state.projectBranch = null;
808877
this.state.changedFiles = [];
809878
this.lastRenderedMessageText = null;
810879
this.pendingUpdate = false;
@@ -822,6 +891,7 @@ class PinnedMessageManager {
822891
this.state.sessionId = null;
823892
this.state.sessionTitle = t("pinned.default_session_title");
824893
this.state.projectName = "";
894+
this.state.projectBranch = null;
825895
this.state.tokensUsed = 0;
826896
this.state.tokensLimit = 0;
827897
this.state.changedFiles = [];

src/pinned/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface PinnedMessageState {
2727
sessionId: string | null;
2828
sessionTitle: string;
2929
projectName: string;
30+
projectBranch: string | null;
3031
tokensUsed: number;
3132
tokensLimit: number;
3233
lastUpdated: number;

tests/helpers/reset-singleton-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface PinnedMessageManagerPrivateState {
3636
sessionId: null;
3737
sessionTitle: string;
3838
projectName: string;
39+
projectBranch: string | null;
3940
tokensUsed: number;
4041
tokensLimit: number;
4142
lastUpdated: number;
@@ -123,6 +124,7 @@ export async function resetSingletonState(): Promise<void> {
123124
sessionId: null,
124125
sessionTitle: "new session",
125126
projectName: "",
127+
projectBranch: null,
126128
tokensUsed: 0,
127129
tokensLimit: 0,
128130
lastUpdated: 0,

tests/pinned/manager.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const mocked = vi.hoisted(() => ({
55
session: { list: vi.fn().mockResolvedValue({ data: [] }) },
66
config: { get: vi.fn().mockResolvedValue({ data: {} }) },
77
},
8+
readFile: vi.fn(),
9+
stat: vi.fn(),
810
getCurrentSession: vi.fn(),
911
getCurrentProject: vi.fn(),
1012
getPinnedMessageId: vi.fn().mockReturnValue(null),
@@ -14,6 +16,10 @@ const mocked = vi.hoisted(() => ({
1416
getModelContextLimit: vi.fn().mockResolvedValue(204800),
1517
}));
1618

19+
vi.mock("node:fs/promises", () => ({
20+
readFile: mocked.readFile,
21+
stat: mocked.stat,
22+
}));
1723
vi.mock("../../src/opencode/client.js", () => ({ opencodeClient: mocked.opencodeClient }));
1824
vi.mock("../../src/session/manager.js", () => ({ getCurrentSession: mocked.getCurrentSession }));
1925
vi.mock("../../src/settings/manager.js", () => ({
@@ -28,7 +34,19 @@ vi.mock("../../src/model/context-limit.js", () => ({
2834
}));
2935
vi.mock("../../src/i18n/index.js", async (importOriginal) => {
3036
const actual = await importOriginal<typeof import("../../src/i18n/index.js")>();
31-
return { ...actual, t: (key: string) => key };
37+
return {
38+
...actual,
39+
t: (key: string, params?: Record<string, string | number>) => {
40+
if (key === "pinned.default_session_title") return "new session";
41+
if (key === "pinned.unknown") return "Unknown";
42+
if (key === "pinned.line.project") return `Project: ${params?.project ?? ""}`;
43+
if (key === "pinned.line.model") return `Model: ${params?.model ?? ""}`;
44+
if (key === "pinned.files.title") return `Files (${params?.count ?? 0}):`;
45+
if (key === "pinned.files.item") return ` ${params?.path ?? ""}${params?.diff ?? ""}`;
46+
if (key === "pinned.files.more") return ` ... and ${params?.count ?? 0} more`;
47+
return key;
48+
},
49+
};
3250
});
3351
vi.mock("../../src/pinned/format.js", () => ({
3452
DEFAULT_CONTEXT_LIMIT: 204800,
@@ -64,6 +82,17 @@ describe("pinned/manager", () => {
6482
mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" });
6583
mocked.getModelContextLimit.mockResolvedValue(204800);
6684
mocked.getPinnedMessageId.mockReturnValue(null);
85+
mocked.stat.mockImplementation(async (filePath: string) => ({
86+
isDirectory: () => filePath.endsWith(".git"),
87+
isFile: () => false,
88+
}));
89+
mocked.readFile.mockImplementation(async (filePath: string) => {
90+
if (filePath.endsWith("HEAD")) {
91+
return "ref: refs/heads/main\n";
92+
}
93+
94+
throw new Error(`Unexpected file read: ${filePath}`);
95+
});
6796
});
6897

6998
describe("updateTokensSilent", () => {
@@ -124,6 +153,78 @@ describe("pinned/manager", () => {
124153
// No pinned message was created → refresh should be a no-op
125154
await expect(pinnedMessageManager.refresh()).resolves.not.toThrow();
126155
});
156+
157+
it("refreshes git branch in the pinned project line", async () => {
158+
await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
159+
160+
fakeApi.editMessageText.mockClear();
161+
mocked.readFile.mockImplementation(async (filePath: string) => {
162+
if (filePath.endsWith("HEAD")) {
163+
return "ref: refs/heads/feature/mobile\n";
164+
}
165+
166+
throw new Error(`Unexpected file read: ${filePath}`);
167+
});
168+
169+
await pinnedMessageManager.refresh();
170+
171+
expect(fakeApi.editMessageText).toHaveBeenCalledWith(
172+
123,
173+
999,
174+
expect.stringContaining("Project: repo: feature/mobile"),
175+
);
176+
});
177+
});
178+
179+
describe("project branch display", () => {
180+
it("shows git branch after the project name", async () => {
181+
await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
182+
183+
expect(fakeApi.sendMessage).toHaveBeenCalledWith(
184+
123,
185+
expect.stringContaining("Project: repo: main"),
186+
);
187+
});
188+
189+
it("keeps only project name when branch is unavailable", async () => {
190+
mocked.stat.mockRejectedValue(new Error("not a git repo"));
191+
192+
await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
193+
194+
expect(fakeApi.sendMessage).toHaveBeenCalledWith(
195+
123,
196+
expect.stringContaining("Project: repo"),
197+
);
198+
expect(fakeApi.sendMessage).not.toHaveBeenCalledWith(
199+
123,
200+
expect.stringContaining("Project: repo:"),
201+
);
202+
});
203+
204+
it("supports linked worktrees via gitdir pointer", async () => {
205+
mocked.stat.mockImplementation(async (filePath: string) => ({
206+
isDirectory: () => false,
207+
isFile: () => filePath.endsWith(".git"),
208+
}));
209+
mocked.readFile.mockImplementation(async (filePath: string) => {
210+
if (filePath.endsWith(".git")) {
211+
return "gitdir: ../.git/worktrees/repo-feature\n";
212+
}
213+
214+
if (filePath.includes(".git\\worktrees\\repo-feature\\HEAD")) {
215+
return "ref: refs/heads/feature/worktree\n";
216+
}
217+
218+
throw new Error(`Unexpected file read: ${filePath}`);
219+
});
220+
221+
await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
222+
223+
expect(fakeApi.sendMessage).toHaveBeenCalledWith(
224+
123,
225+
expect.stringContaining("Project: repo: feature/worktree"),
226+
);
227+
});
127228
});
128229

129230
describe("setOnKeyboardUpdate race condition fix", () => {

0 commit comments

Comments
 (0)