@@ -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+ } ) ) ;
1723vi . mock ( "../../src/opencode/client.js" , ( ) => ( { opencodeClient : mocked . opencodeClient } ) ) ;
1824vi . mock ( "../../src/session/manager.js" , ( ) => ( { getCurrentSession : mocked . getCurrentSession } ) ) ;
1925vi . mock ( "../../src/settings/manager.js" , ( ) => ( {
@@ -28,7 +34,19 @@ vi.mock("../../src/model/context-limit.js", () => ({
2834} ) ) ;
2935vi . 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} ) ;
3351vi . 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