1- import { execFile } from "node:child_process" ;
21import * as fs from "node:fs/promises" ;
32import * as path from "node:path" ;
4- import { promisify } from "node:util" ;
53import * as watcher from "@parcel/watcher" ;
4+ import {
5+ getHeadSha ,
6+ branchExists as gitBranchExists ,
7+ getCurrentBranch as gitGetCurrentBranch ,
8+ hasChanges ,
9+ } from "@twig/git/queries" ;
10+ import { SwitchBranchSaga } from "@twig/git/sagas/branch" ;
11+ import { CleanWorkingTreeSaga } from "@twig/git/sagas/clean" ;
12+ import { DetachHeadSaga , ReattachBranchSaga } from "@twig/git/sagas/head" ;
13+ import {
14+ StashApplySaga ,
15+ StashPopSaga ,
16+ StashPushSaga ,
17+ } from "@twig/git/sagas/stash" ;
618import { injectable , preDestroy } from "inversify" ;
719import { logger } from "../../lib/logger" ;
820import { TypedEventEmitter } from "../../lib/typed-event-emitter" ;
921import { type FocusSession , focusStore } from "../../utils/store.js" ;
1022import { getWorktreeLocation } from "../settingsStore" ;
1123import type { FocusResult , StashResult } from "./schemas.js" ;
1224
13- const execFileAsync = promisify ( execFile ) ;
14-
1525const log = logger . scope ( "focus" ) ;
1626
1727export const FocusServiceEvent = {
@@ -34,47 +44,6 @@ export interface FocusServiceEvents {
3444 } ;
3545}
3646
37- function getErrorMessage ( error : unknown ) : string {
38- return error instanceof Error ? error . message : String ( error ) ;
39- }
40-
41- let gitMutex : Promise < void > = Promise . resolve ( ) ;
42-
43- export async function withGitLock < T > ( fn : ( ) => Promise < T > ) : Promise < T > {
44- const prev = gitMutex ;
45- let resolve : ( ) => void = ( ) => { } ;
46- gitMutex = new Promise ( ( r ) => {
47- resolve = r ;
48- } ) ;
49-
50- try {
51- await prev ;
52- return await fn ( ) ;
53- } finally {
54- resolve ( ) ;
55- }
56- }
57-
58- export async function git ( cwd : string , ...args : string [ ] ) : Promise < string > {
59- return withGitLock ( async ( ) => {
60- const { stdout } = await execFileAsync ( "git" , args , { cwd } ) ;
61- return stdout . trim ( ) ;
62- } ) ;
63- }
64-
65- async function gitOp < T extends FocusResult > (
66- operation : string ,
67- fn : ( ) => Promise < T > ,
68- ) : Promise < T > {
69- try {
70- return await fn ( ) ;
71- } catch ( error ) {
72- const message = getErrorMessage ( error ) ;
73- log . error ( `${ operation } :` , message ) ;
74- return { success : false , error : `${ operation } : ${ message } ` } as T ;
75- }
76- }
77-
7847@injectable ( )
7948export class FocusService extends TypedEventEmitter < FocusServiceEvents > {
8049 private mainRepoWatcher : watcher . AsyncSubscription | null = null ;
@@ -162,16 +131,11 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
162131 repoPath : string ,
163132 branch : string ,
164133 ) : Promise < boolean > {
165- try {
166- await git ( repoPath , "rev-parse" , "--verify" , `refs/heads/${ branch } ` ) ;
167- return true ;
168- } catch {
169- return false ;
170- }
134+ return gitBranchExists ( repoPath , branch ) ;
171135 }
172136
173137 async getCommitSha ( repoPath : string ) : Promise < string > {
174- return git ( repoPath , "rev-parse" , "HEAD" ) ;
138+ return getHeadSha ( repoPath ) ;
175139 }
176140
177141 /**
@@ -243,58 +207,46 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
243207 }
244208
245209 async cleanWorkingTree ( repoPath : string ) : Promise < void > {
246- await this . cleanStaleLockFile ( repoPath ) ;
247- await git ( repoPath , "reset" ) ;
248- await git ( repoPath , "restore" , "." ) ;
249- await git ( repoPath , "clean" , "-fd" ) ;
250- await this . forceRemoveLockFile ( repoPath ) ;
251- }
252-
253- private async cleanStaleLockFile ( repoPath : string ) : Promise < void > {
254- const lockPath = path . join ( repoPath , ".git" , "index.lock" ) ;
255- try {
256- const stat = await fs . stat ( lockPath ) ;
257- const ageMs = Date . now ( ) - stat . mtimeMs ;
258- if ( ageMs > 2000 ) {
259- await fs . rm ( lockPath ) ;
260- log . info (
261- `Removed stale index.lock (age: ${ Math . round ( ageMs / 1000 ) } s)` ,
262- ) ;
263- }
264- } catch { }
265- }
266-
267- private async forceRemoveLockFile ( repoPath : string ) : Promise < void > {
268- const lockPath = path . join ( repoPath , ".git" , "index.lock" ) ;
269- try {
270- await fs . rm ( lockPath ) ;
271- log . info ( "Removed index.lock after cleaning working tree" ) ;
272- } catch { }
210+ const saga = new CleanWorkingTreeSaga ( ) ;
211+ const result = await saga . run ( { baseDir : repoPath } ) ;
212+ if ( ! result . success ) {
213+ throw new Error ( `Failed to clean working tree: ${ result . error } ` ) ;
214+ }
273215 }
274216
275217 async detachWorktree ( worktreePath : string ) : Promise < FocusResult > {
276- return gitOp ( "Failed to detach worktree" , async ( ) => {
277- await git ( worktreePath , "checkout" , "--detach" ) ;
278- log . info ( `Detached worktree at ${ worktreePath } ` ) ;
279- return { success : true } ;
280- } ) ;
218+ const saga = new DetachHeadSaga ( ) ;
219+ const result = await saga . run ( { baseDir : worktreePath } ) ;
220+ if ( ! result . success ) {
221+ log . error ( "Failed to detach worktree:" , result . error ) ;
222+ return {
223+ success : false ,
224+ error : `Failed to detach worktree: ${ result . error } ` ,
225+ } ;
226+ }
227+ log . info ( `Detached worktree at ${ worktreePath } ` ) ;
228+ return { success : true } ;
281229 }
282230
283231 async reattachWorktree (
284232 worktreePath : string ,
285233 branchName : string ,
286234 ) : Promise < FocusResult > {
287- return gitOp ( "Failed to reattach worktree" , async ( ) => {
288- await git ( worktreePath , "checkout" , "-B" , branchName ) ;
289- log . info (
290- `Reattached worktree at ${ worktreePath } to branch ${ branchName } ` ,
291- ) ;
292- return { success : true } ;
293- } ) ;
235+ const saga = new ReattachBranchSaga ( ) ;
236+ const result = await saga . run ( { baseDir : worktreePath , branchName } ) ;
237+ if ( ! result . success ) {
238+ log . error ( "Failed to reattach worktree:" , result . error ) ;
239+ return {
240+ success : false ,
241+ error : `Failed to reattach worktree: ${ result . error } ` ,
242+ } ;
243+ }
244+ log . info ( `Reattached worktree at ${ worktreePath } to branch ${ branchName } ` ) ;
245+ return { success : true } ;
294246 }
295247
296248 async getCurrentBranch ( repoPath : string ) : Promise < string | null > {
297- const branch = await git ( repoPath , "branch" , "--show-current" ) ;
249+ const branch = await gitGetCurrentBranch ( repoPath ) ;
298250 if ( ! branch ) {
299251 log . warn ( "getCurrentBranch returned empty (detached HEAD?)" ) ;
300252 return null ;
@@ -303,80 +255,59 @@ export class FocusService extends TypedEventEmitter<FocusServiceEvents> {
303255 }
304256
305257 async isDirty ( repoPath : string ) : Promise < boolean > {
306- const stdout = await git ( repoPath , "status" , "--porcelain" ) ;
307- return stdout . length > 0 ;
258+ return hasChanges ( repoPath ) ;
308259 }
309260
310261 async stash ( repoPath : string , message : string ) : Promise < StashResult > {
311- return gitOp ( "Failed to stash" , async ( ) => {
312- await this . cleanStaleLockFile ( repoPath ) ;
313- const beforeList = await git ( repoPath , "stash" , "list" ) ;
314- const beforeCount = beforeList . split ( "\n" ) . filter ( Boolean ) . length ;
315-
316- await git ( repoPath , "add" , "-A" ) ;
317- await git (
318- repoPath ,
319- "stash" ,
320- "push" ,
321- "--include-untracked" ,
322- "-m" ,
323- message ,
324- ) ;
325-
326- const afterList = await git ( repoPath , "stash" , "list" ) ;
327- const afterCount = afterList . split ( "\n" ) . filter ( Boolean ) . length ;
328-
329- if ( afterCount > beforeCount ) {
330- // Get the SHA of the stash commit (survives other stashes being added)
331- const stashSha = await git ( repoPath , "rev-parse" , "stash@{0}" ) ;
332- return { success : true , stashRef : stashSha } ;
333- }
334- return { success : true } ;
335- } ) ;
262+ const saga = new StashPushSaga ( ) ;
263+ const result = await saga . run ( { baseDir : repoPath , message } ) ;
264+ if ( ! result . success ) {
265+ log . error ( "Failed to stash:" , result . error ) ;
266+ return { success : false , error : `Failed to stash: ${ result . error } ` } ;
267+ }
268+ if ( result . data . stashSha ) {
269+ return { success : true , stashRef : result . data . stashSha } ;
270+ }
271+ return { success : true } ;
336272 }
337273
338274 async stashApply ( repoPath : string , stashRef : string ) : Promise < FocusResult > {
339- return gitOp ( "Failed to apply stash" , async ( ) => {
340- await this . cleanStaleLockFile ( repoPath ) ;
341- await git ( repoPath , "stash" , "apply" , stashRef ) ;
342-
343- // Find the stash reference that matches this SHA
344- // Format: "<sha> stash@{N}"
345- const reflog = await git (
346- repoPath ,
347- "reflog" ,
348- "show" ,
349- "--format=%H %gd" ,
350- "refs/stash" ,
351- ) ;
352- const match = reflog
353- . split ( "\n" )
354- . find ( ( line ) => line . startsWith ( stashRef ) ) ;
355-
356- if ( match ) {
357- const stashIndex = match . split ( " " ) [ 1 ] ; // e.g., "stash@{0}"
358- await git ( repoPath , "stash" , "drop" , stashIndex ) ;
359- } else {
360- log . warn ( `Stash SHA ${ stashRef } not found in reflog, skipping drop` ) ;
361- }
362-
363- return { success : true } ;
364- } ) ;
275+ const saga = new StashApplySaga ( ) ;
276+ const result = await saga . run ( { baseDir : repoPath , stashSha : stashRef } ) ;
277+ if ( ! result . success ) {
278+ log . error ( "Failed to apply stash:" , result . error ) ;
279+ return {
280+ success : false ,
281+ error : `Failed to apply stash: ${ result . error } ` ,
282+ } ;
283+ }
284+ if ( ! result . data . dropped ) {
285+ log . warn ( `Stash SHA ${ stashRef } not found in reflog, skipping drop` ) ;
286+ }
287+ return { success : true } ;
365288 }
366289
367290 async stashPop ( repoPath : string ) : Promise < FocusResult > {
368- return gitOp ( "Failed to pop stash" , async ( ) => {
369- await git ( repoPath , "stash" , "pop" ) ;
370- return { success : true } ;
371- } ) ;
291+ const saga = new StashPopSaga ( ) ;
292+ const result = await saga . run ( { baseDir : repoPath } ) ;
293+ if ( ! result . success ) {
294+ log . error ( "Failed to pop stash:" , result . error ) ;
295+ return { success : false , error : `Failed to pop stash: ${ result . error } ` } ;
296+ }
297+ return { success : true } ;
372298 }
373299
374300 async checkout ( repoPath : string , branch : string ) : Promise < FocusResult > {
375- return gitOp ( `Failed to checkout ${ branch } ` , async ( ) => {
376- await this . cleanStaleLockFile ( repoPath ) ;
377- await git ( repoPath , "checkout" , branch ) ;
378- return { success : true } ;
379- } ) ;
301+ const saga = new SwitchBranchSaga ( ) ;
302+ const result = await saga . run ( { baseDir : repoPath , branchName : branch } ) ;
303+ if ( ! result . success ) {
304+ log . error ( `Failed to checkout ${ branch } :` , result . error ) ;
305+ return {
306+ success : false ,
307+ error : `Failed to checkout ${ branch } : ${ result . error } ` ,
308+ } ;
309+ }
310+ return { success : true } ;
380311 }
381312
382313 getSession ( mainRepoPath : string ) : FocusSession | null {
0 commit comments