22//
33// SPDX-License-Identifier: GPL-3.0-only
44
5+ use std:: collections:: HashMap ;
56use std:: path:: { Path , PathBuf } ;
67
8+ use tokio:: process:: Command ;
9+
710use crate :: domain:: { ChangeStatus , DiffStats , FileCategory , FileChange , StagedChanges } ;
811use crate :: error:: { Error , Result } ;
912
@@ -25,33 +28,52 @@ impl GitService {
2528 }
2629
2730 pub fn check_state ( & self ) -> Result < ( ) > {
28- // Check for merge/rebase in progress
2931 let state = self . repo . state ( ) ;
3032 if matches ! ( state, Some ( gix:: state:: InProgress :: Merge ) ) {
3133 return Err ( Error :: MergeInProgress ) ;
3234 }
3335 Ok ( ( ) )
3436 }
3537
36- pub fn get_staged_changes ( & self , max_file_lines : usize ) -> Result < StagedChanges > {
37- self . check_state ( ) ?;
38+ // ─── Async Git Helpers ───
3839
39- // Use git diff --cached --name-status to get list of staged files
40- let output = std :: process :: Command :: new ( "git" )
41- . args ( [ "diff" , "--cached" , "--name-status" , "--no-renames" ] )
40+ async fn run_git ( & self , args : & [ & str ] ) -> Result < String > {
41+ let output = Command :: new ( "git" )
42+ . args ( args )
4243 . current_dir ( & self . work_dir )
43- . output ( ) ?;
44+ . output ( )
45+ . await ?;
4446
4547 if !output. status . success ( ) {
4648 let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
4749 return Err ( Error :: Git ( stderr. to_string ( ) ) ) ;
4850 }
4951
52+ Ok ( String :: from_utf8_lossy ( & output. stdout ) . into_owned ( ) )
53+ }
54+
55+ // ─── Staged Changes (Single-Pass Diff) ───
56+
57+ pub async fn get_staged_changes ( & self , max_file_lines : usize ) -> Result < StagedChanges > {
58+ self . check_state ( ) ?;
59+
60+ // Two calls total (down from N+1): name-status + unified diff
61+ let ( status_output, diff_output) = tokio:: try_join!(
62+ self . run_git( & [ "diff" , "--cached" , "--name-status" , "--no-renames" ] ) ,
63+ self . run_git( & [
64+ "diff" ,
65+ "--cached" ,
66+ "--no-ext-diff" ,
67+ "--unified=3" ,
68+ "--no-renames"
69+ ] ) ,
70+ ) ?;
71+
72+ let file_diffs = Self :: split_unified_diff ( & diff_output) ;
73+
5074 let mut files = Vec :: new ( ) ;
5175 let mut stats = DiffStats :: default ( ) ;
5276
53- let status_output = String :: from_utf8_lossy ( & output. stdout ) ;
54-
5577 for line in status_output. lines ( ) {
5678 if line. is_empty ( ) {
5779 continue ;
@@ -74,11 +96,14 @@ impl GitService {
7496 let is_binary = Self :: is_binary_path ( & file_path) ;
7597
7698 if is_binary {
77- continue ; // Skip binary files
99+ continue ;
78100 }
79101
80- // Get diff content
81- let diff = self . get_file_diff ( & file_path, max_file_lines) ?;
102+ let diff = file_diffs
103+ . get ( parts[ 1 ] )
104+ . map ( |d| Self :: truncate_diff ( d, max_file_lines) )
105+ . unwrap_or_default ( ) ;
106+
82107 let ( additions, deletions) = Self :: count_changes ( & diff) ;
83108
84109 files. push ( FileChange {
@@ -103,33 +128,61 @@ impl GitService {
103128 Ok ( StagedChanges { files, stats } )
104129 }
105130
106- fn get_file_diff ( & self , path : & Path , max_lines : usize ) -> Result < String > {
107- // Use git command for reliable diff output
108- // --no-ext-diff: don't use external diff tools
109- // --unified=3: standard 3 lines of context
110- let output = std:: process:: Command :: new ( "git" )
111- . args ( [ "diff" , "--cached" , "--no-ext-diff" , "--unified=3" , "--" ] )
112- . arg ( path)
113- . current_dir ( & self . work_dir )
114- . output ( ) ?;
131+ /// Split a unified diff into per-file sections keyed by file path.
132+ fn split_unified_diff ( diff : & str ) -> HashMap < String , String > {
133+ let mut result = HashMap :: new ( ) ;
134+ let mut current_path: Option < String > = None ;
135+ let mut current_lines: Vec < & str > = Vec :: new ( ) ;
115136
116- if !output. status . success ( ) {
117- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
118- return Err ( Error :: Git ( stderr. to_string ( ) ) ) ;
137+ for line in diff. lines ( ) {
138+ if line. starts_with ( "diff --git " ) {
139+ // Save previous file's accumulated diff
140+ if let Some ( path) = current_path. take ( ) {
141+ result. insert ( path, current_lines. join ( "\n " ) ) ;
142+ }
143+ current_lines. clear ( ) ;
144+ }
145+
146+ // Extract path from +++ header (reliable for added/modified files)
147+ if let Some ( path) = line. strip_prefix ( "+++ b/" ) {
148+ current_path = Some ( path. to_string ( ) ) ;
149+ }
150+ // For deleted files, +++ is /dev/null — use --- header instead
151+ if line == "+++ /dev/null" {
152+ if let Some ( last_minus) =
153+ current_lines. iter ( ) . rev ( ) . find ( |l| l. starts_with ( "--- a/" ) )
154+ {
155+ if let Some ( path) = last_minus. strip_prefix ( "--- a/" ) {
156+ current_path = Some ( path. to_string ( ) ) ;
157+ }
158+ }
159+ }
160+
161+ current_lines. push ( line) ;
119162 }
120163
121- let diff = String :: from_utf8_lossy ( & output. stdout ) ;
122- let lines: Vec < & str > = diff. lines ( ) . take ( max_lines) . collect ( ) ;
164+ // Don't forget the last file
165+ if let Some ( path) = current_path {
166+ result. insert ( path, current_lines. join ( "\n " ) ) ;
167+ }
168+
169+ result
170+ }
123171
124- Ok ( lines. join ( "\n " ) )
172+ fn truncate_diff ( diff : & str , max_lines : usize ) -> String {
173+ let lines: Vec < & str > = diff. lines ( ) . take ( max_lines) . collect ( ) ;
174+ lines. join ( "\n " )
125175 }
126176
177+ // ─── File Content ───
178+
127179 /// Get staged file content (from index)
128- pub fn get_staged_content ( & self , path : & Path ) -> Option < String > {
129- let output = std:: process:: Command :: new ( "git" )
180+ pub async fn get_staged_content ( & self , path : & Path ) -> Option < String > {
181+ let output: std:: process:: Output = Command :: new ( "git" )
130182 . args ( [ "show" , & format ! ( ":0:{}" , path. display( ) ) ] )
131183 . current_dir ( & self . work_dir )
132184 . output ( )
185+ . await
133186 . ok ( ) ?;
134187
135188 if output. status . success ( ) {
@@ -140,11 +193,12 @@ impl GitService {
140193 }
141194
142195 /// Get HEAD file content
143- pub fn get_head_content ( & self , path : & Path ) -> Option < String > {
144- let output = std:: process:: Command :: new ( "git" )
196+ pub async fn get_head_content ( & self , path : & Path ) -> Option < String > {
197+ let output: std:: process:: Output = Command :: new ( "git" )
145198 . args ( [ "show" , & format ! ( "HEAD:{}" , path. display( ) ) ] )
146199 . current_dir ( & self . work_dir )
147200 . output ( )
201+ . await
148202 . ok ( ) ?;
149203
150204 if output. status . success ( ) {
@@ -154,6 +208,8 @@ impl GitService {
154208 }
155209 }
156210
211+ // ─── Diff Parsing ───
212+
157213 fn count_changes ( diff : & str ) -> ( usize , usize ) {
158214 let mut additions = 0 ;
159215 let mut deletions = 0 ;
@@ -199,11 +255,14 @@ impl GitService {
199255 )
200256 }
201257
202- pub fn commit ( & self , message : & str ) -> Result < ( ) > {
203- let output = std:: process:: Command :: new ( "git" )
258+ // ─── Commit ───
259+
260+ pub async fn commit ( & self , message : & str ) -> Result < ( ) > {
261+ let output = Command :: new ( "git" )
204262 . args ( [ "commit" , "-m" , message] )
205263 . current_dir ( & self . work_dir )
206- . output ( ) ?;
264+ . output ( )
265+ . await ?;
207266
208267 if !output. status . success ( ) {
209268 let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
0 commit comments