@@ -34,6 +34,35 @@ export interface StageLinesOptions {
3434 targetLine : TargetLine ;
3535}
3636
37+ export interface StageHunkOptions {
38+ worktreePath : string ;
39+ sessionId : string ;
40+ filePath : string ;
41+ isStaging : boolean ;
42+ hunkHeader : string ;
43+ }
44+
45+ export interface RestoreHunkOptions {
46+ worktreePath : string ;
47+ sessionId : string ;
48+ filePath : string ;
49+ scope : 'staged' | 'unstaged' ;
50+ hunkHeader : string ;
51+ }
52+
53+ export interface ChangeAllStageOptions {
54+ worktreePath : string ;
55+ sessionId : string ;
56+ stage : boolean ;
57+ }
58+
59+ export interface ChangeFileStageOptions {
60+ worktreePath : string ;
61+ sessionId : string ;
62+ filePath : string ;
63+ stage : boolean ;
64+ }
65+
3766export interface StageLinesResult {
3867 success : boolean ;
3968 error ?: string ;
@@ -116,6 +145,162 @@ export class GitStagingManager {
116145 }
117146 }
118147
148+ /**
149+ * Stage or unstage a full hunk (block)
150+ */
151+ async stageHunk ( options : StageHunkOptions ) : Promise < StageLinesResult > {
152+ try {
153+ const scope = options . isStaging ? 'unstaged' : 'staged' ;
154+ const fullDiff = await this . getFileDiff ( options . worktreePath , options . filePath , scope , options . sessionId ) ;
155+
156+ if ( fullDiff . includes ( 'Binary files differ' ) ) {
157+ return { success : false , error : 'Cannot stage hunks of binary files' } ;
158+ }
159+
160+ const hunks = this . parseDiffIntoHunks ( fullDiff ) ;
161+ if ( hunks . length === 0 ) {
162+ return { success : false , error : 'No changes found in diff' } ;
163+ }
164+
165+ const normalizedHeader = options . hunkHeader . trim ( ) ;
166+ const targetHunk = hunks . find ( ( h ) => h . header . trim ( ) === normalizedHeader ) ;
167+ if ( ! targetHunk ) {
168+ return { success : false , error : 'Target hunk not found in diff' } ;
169+ }
170+
171+ const patch = this . generateHunkPatch ( targetHunk , options . filePath ) ;
172+ return await this . applyPatch ( options . worktreePath , patch , options . isStaging , options . sessionId , {
173+ operation : options . isStaging ? 'stage-hunk' : 'unstage-hunk' ,
174+ } ) ;
175+ } catch ( error ) {
176+ return {
177+ success : false ,
178+ error : error instanceof Error ? error . message : 'Unknown error occurred' ,
179+ } ;
180+ }
181+ }
182+
183+ /**
184+ * Restore (discard) a specific hunk in the working tree.
185+ *
186+ * For staged hunks we first unstage the hunk, then attempt to restore the same patch in
187+ * the working tree (best-effort; may fail if the working tree differs from the index).
188+ */
189+ async restoreHunk ( options : RestoreHunkOptions ) : Promise < StageLinesResult > {
190+ try {
191+ const fullDiffScope = options . scope === 'staged' ? 'staged' : 'unstaged' ;
192+ const fullDiff = await this . getFileDiff ( options . worktreePath , options . filePath , fullDiffScope , options . sessionId ) ;
193+
194+ if ( fullDiff . includes ( 'Binary files differ' ) ) {
195+ return { success : false , error : 'Cannot restore hunks of binary files' } ;
196+ }
197+
198+ const hunks = this . parseDiffIntoHunks ( fullDiff ) ;
199+ if ( hunks . length === 0 ) {
200+ return { success : false , error : 'No changes found in diff' } ;
201+ }
202+
203+ const normalizedHeader = options . hunkHeader . trim ( ) ;
204+ const targetHunk = hunks . find ( ( h ) => h . header . trim ( ) === normalizedHeader ) ;
205+ if ( ! targetHunk ) {
206+ return { success : false , error : 'Target hunk not found in diff' } ;
207+ }
208+
209+ const patch = this . generateHunkPatch ( targetHunk , options . filePath ) ;
210+
211+ if ( options . scope === 'staged' ) {
212+ const unstage = await this . applyPatch ( options . worktreePath , patch , false , options . sessionId , {
213+ operation : 'restore-hunk-unstage' ,
214+ } ) ;
215+ if ( ! unstage . success ) return unstage ;
216+ }
217+
218+ const worktreeRestore = await this . applyWorktreePatch ( options . worktreePath , patch , true , options . sessionId , {
219+ operation : 'restore-hunk-worktree' ,
220+ } ) ;
221+ if ( ! worktreeRestore . success ) {
222+ return worktreeRestore ;
223+ }
224+
225+ return { success : true } ;
226+ } catch ( error ) {
227+ return {
228+ success : false ,
229+ error : error instanceof Error ? error . message : 'Unknown error occurred' ,
230+ } ;
231+ }
232+ }
233+
234+ /**
235+ * Stage or unstage all changes.
236+ */
237+ async changeAllStage ( options : ChangeAllStageOptions ) : Promise < StageLinesResult > {
238+ try {
239+ const argv = options . stage
240+ ? [ 'git' , 'add' , '--all' ]
241+ : [ 'git' , 'reset' ] ;
242+
243+ const result = await this . gitExecutor . run ( {
244+ sessionId : options . sessionId ,
245+ cwd : options . worktreePath ,
246+ argv,
247+ op : 'write' ,
248+ recordTimeline : true ,
249+ meta : { source : 'gitStaging' , operation : options . stage ? 'stage-all' : 'unstage-all' } ,
250+ } ) ;
251+
252+ if ( result . exitCode !== 0 ) {
253+ return { success : false , error : result . stderr || 'git command failed' } ;
254+ }
255+
256+ this . statusManager . clearSessionCache ( options . sessionId ) ;
257+ return { success : true } ;
258+ } catch ( error ) {
259+ return {
260+ success : false ,
261+ error : error instanceof Error ? error . message : 'Unknown error occurred' ,
262+ } ;
263+ }
264+ }
265+
266+ /**
267+ * Stage or unstage a single file.
268+ *
269+ * - Stage: `git add --all -- <file>`
270+ * - Unstage: `git reset -- <file>`
271+ */
272+ async changeFileStage ( options : ChangeFileStageOptions ) : Promise < StageLinesResult > {
273+ try {
274+ const filePath = options . filePath . trim ( ) ;
275+ if ( ! filePath ) return { success : false , error : 'File path is required' } ;
276+
277+ const argv = options . stage
278+ ? [ 'git' , 'add' , '--all' , '--' , filePath ]
279+ : [ 'git' , 'reset' , '--' , filePath ] ;
280+
281+ const result = await this . gitExecutor . run ( {
282+ sessionId : options . sessionId ,
283+ cwd : options . worktreePath ,
284+ argv,
285+ op : 'write' ,
286+ recordTimeline : true ,
287+ meta : { source : 'gitStaging' , operation : options . stage ? 'stage-file' : 'unstage-file' , filePath } ,
288+ } ) ;
289+
290+ if ( result . exitCode !== 0 ) {
291+ return { success : false , error : result . stderr || 'git command failed' } ;
292+ }
293+
294+ this . statusManager . clearSessionCache ( options . sessionId ) ;
295+ return { success : true } ;
296+ } catch ( error ) {
297+ return {
298+ success : false ,
299+ error : error instanceof Error ? error . message : 'Unknown error occurred' ,
300+ } ;
301+ }
302+ }
303+
119304 /**
120305 * Get diff for a specific file and scope
121306 */
@@ -127,8 +312,8 @@ export class GitStagingManager {
127312 ) : Promise < string > {
128313 const argv =
129314 scope === 'staged'
130- ? [ 'git' , 'diff' , '--cached' , '--color=never' , '--src-prefix=a/' , '--dst-prefix=b/' , 'HEAD' , '--' , filePath ]
131- : [ 'git' , 'diff' , '--color=never' , '--src-prefix=a/' , '--dst-prefix=b/' , '--' , filePath ] ;
315+ ? [ 'git' , 'diff' , '--cached' , '--color=never' , '--unified=0' , '-- src-prefix=a/', '--dst-prefix=b/' , 'HEAD' , '--' , filePath ]
316+ : [ 'git' , 'diff' , '--color=never' , '--unified=0' , '-- src-prefix=a/', '--dst-prefix=b/' , '--' , filePath ] ;
132317
133318 const result = await this . gitExecutor . run ( {
134319 sessionId,
@@ -336,14 +521,72 @@ export class GitStagingManager {
336521 return patch ;
337522 }
338523
524+ private generateHunkPatch ( hunk : Hunk , filePath : string ) : string {
525+ const patch = [
526+ `diff --git a/${ filePath } b/${ filePath } ` ,
527+ `--- a/${ filePath } ` ,
528+ `+++ b/${ filePath } ` ,
529+ hunk . header ,
530+ ...hunk . lines . map ( ( l ) => l . text ) ,
531+ '' ,
532+ ] . join ( '\n' ) ;
533+
534+ return patch ;
535+ }
536+
537+ /**
538+ * Apply patch to working tree using git apply (not --cached).
539+ */
540+ private async applyWorktreePatch (
541+ worktreePath : string ,
542+ patch : string ,
543+ reverse : boolean ,
544+ sessionId : string ,
545+ meta ?: { operation : string }
546+ ) : Promise < StageLinesResult > {
547+ const tempFile = path . join ( os . tmpdir ( ) , `snowtree-worktree-patch-${ Date . now ( ) } .patch` ) ;
548+
549+ try {
550+ await fs . writeFile ( tempFile , patch , 'utf8' ) ;
551+
552+ const argv = [
553+ 'git' ,
554+ 'apply' ,
555+ '--unidiff-zero' ,
556+ '--whitespace=nowarn' ,
557+ ...( reverse ? [ '-R' ] : [ ] ) ,
558+ tempFile ,
559+ ] ;
560+
561+ const result = await this . gitExecutor . run ( {
562+ sessionId,
563+ cwd : worktreePath ,
564+ argv,
565+ op : 'write' ,
566+ recordTimeline : true ,
567+ meta : { source : 'gitStaging' , operation : meta ?. operation ?? ( reverse ? 'apply-reverse' : 'apply' ) } ,
568+ } ) ;
569+
570+ if ( result . exitCode !== 0 ) {
571+ return { success : false , error : result . stderr || 'git apply failed' } ;
572+ }
573+
574+ this . statusManager . clearSessionCache ( sessionId ) ;
575+ return { success : true } ;
576+ } finally {
577+ await fs . unlink ( tempFile ) . catch ( ( ) => { } ) ;
578+ }
579+ }
580+
339581 /**
340582 * Apply patch using git apply --cached
341583 */
342584 private async applyPatch (
343585 worktreePath : string ,
344586 patch : string ,
345587 isStaging : boolean ,
346- sessionId : string
588+ sessionId : string ,
589+ meta ?: { operation : string }
347590 ) : Promise < StageLinesResult > {
348591 // Write patch to temp file
349592 const tempFile = path . join ( os . tmpdir ( ) , `snowtree-patch-${ Date . now ( ) } .patch` ) ;
@@ -370,7 +613,7 @@ export class GitStagingManager {
370613 recordTimeline : true ,
371614 meta : {
372615 source : 'gitStaging' ,
373- operation : isStaging ? 'stage-line' : 'unstage-line' ,
616+ operation : meta ?. operation ?? ( isStaging ? 'stage-line' : 'unstage-line' ) ,
374617 } ,
375618 } ) ;
376619
0 commit comments