@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
22use git2:: { Repository , Status , StatusOptions } ;
33use serde:: { Deserialize , Serialize } ;
44use serde_json:: { Value , json} ;
5+ use std:: collections:: HashMap ;
56use std:: path:: { Path , PathBuf } ;
67use tracing:: info;
78
@@ -15,10 +16,13 @@ pub enum FileStatus {
1516 Conflict ,
1617}
1718
19+
1820#[ derive( Debug , Serialize , Deserialize , Clone , PartialEq , Eq ) ]
1921pub struct GitFileStatus {
2022 pub path : String ,
2123 pub status : FileStatus ,
24+ pub added : usize ,
25+ pub removed : usize ,
2226}
2327
2428#[ derive( Debug , Clone , PartialEq , Eq , Default ) ]
@@ -30,12 +34,43 @@ pub struct GitStatus {
3034impl GitStatus {
3135 pub fn to_json ( & self ) -> Value {
3236 json ! ( {
37+ "kind" : "full" ,
3338 "files" : self . files,
3439 "branch" : self . branch
3540 } )
3641 }
3742}
3843
44+ #[ derive( Debug , Serialize , Deserialize , Clone , PartialEq , Eq ) ]
45+ pub struct GitStatusPatchFile {
46+ pub path : String ,
47+ pub status : String ,
48+ pub added : usize ,
49+ pub removed : usize ,
50+ }
51+
52+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
53+ pub enum GitStatusUpdate {
54+ Full ( GitStatus ) ,
55+ Patch {
56+ branch : String ,
57+ files : Vec < GitStatusPatchFile > ,
58+ } ,
59+ }
60+
61+ impl GitStatusUpdate {
62+ pub fn to_json ( & self ) -> Value {
63+ match self {
64+ Self :: Full ( status) => status. to_json ( ) ,
65+ Self :: Patch { branch, files } => json ! ( {
66+ "kind" : "patch" ,
67+ "branch" : branch,
68+ "files" : files,
69+ } ) ,
70+ }
71+ }
72+ }
73+
3974#[ derive( Debug , Clone ) ]
4075pub struct FileOriginal {
4176 pub content : String ,
@@ -79,7 +114,10 @@ impl GitManager {
79114 }
80115
81116 /// Check if a path should be ignored (in .git or gitignored)
82- pub fn should_ignore ( & self , path_str : & str ) -> bool {
117+ pub fn should_ignore ( & self , path : & Path ) -> bool {
118+ let path_str = path. to_string_lossy ( ) ;
119+ let path_str = path_str. as_ref ( ) ;
120+
83121 // Skip .git directory
84122 if path_str. contains ( "/.git/" ) || path_str. ends_with ( "/.git" ) {
85123 return true ;
@@ -107,15 +145,55 @@ impl GitManager {
107145 false
108146 }
109147
148+ fn collect_numstat ( repo : & Repository ) -> Result < HashMap < String , ( usize , usize ) > > {
149+ let mut opts = git2:: DiffOptions :: new ( ) ;
150+ opts. include_untracked ( true )
151+ . recurse_untracked_dirs ( true )
152+ . include_typechange ( true ) ;
153+
154+ let head_tree = repo
155+ . head ( )
156+ . ok ( )
157+ . and_then ( |head| head. peel_to_tree ( ) . ok ( ) ) ;
158+
159+ let diff = repo. diff_tree_to_workdir_with_index ( head_tree. as_ref ( ) , Some ( & mut opts) ) ?;
160+
161+ let mut numstat_by_path: HashMap < String , ( usize , usize ) > = HashMap :: new ( ) ;
162+
163+ diff. foreach (
164+ & mut |_delta, _progress| true ,
165+ None ,
166+ None ,
167+ Some ( & mut |delta, _hunk, line| {
168+ let Some ( path) = delta
169+ . new_file ( )
170+ . path ( )
171+ . or_else ( || delta. old_file ( ) . path ( ) )
172+ . map ( |p| p. to_string_lossy ( ) . to_string ( ) )
173+ else {
174+ return true ;
175+ } ;
176+
177+ let entry = numstat_by_path. entry ( path) . or_insert ( ( 0 , 0 ) ) ;
178+ match line. origin ( ) {
179+ '+' => entry. 0 += 1 ,
180+ '-' => entry. 1 += 1 ,
181+ _ => { }
182+ }
183+ true
184+ } ) ,
185+ ) ?;
186+
187+ Ok ( numstat_by_path)
188+ }
189+
110190 /// Get current git status
111191 pub fn status ( & self ) -> Result < GitStatus > {
112192 let repo = self . repo ( ) ?;
113193 let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
194+ let numstat_by_path = Self :: collect_numstat ( & repo) ?;
114195
115- let branch = repo
116- . head ( )
117- . map ( |h| h. shorthand ( ) . unwrap_or ( "HEAD" ) . to_string ( ) )
118- . unwrap_or_else ( |_| "HEAD" . to_string ( ) ) ;
196+ let branch = Self :: branch_name ( & repo) ;
119197
120198 let mut opts = StatusOptions :: new ( ) ;
121199 opts. include_untracked ( true )
@@ -128,30 +206,21 @@ impl GitManager {
128206
129207 for entry in statuses. iter ( ) {
130208 let relative_path = entry. path ( ) . unwrap_or ( "" ) ;
131- let status = entry. status ( ) ;
132-
133- let file_status = if status. contains ( Status :: WT_NEW )
134- || status. contains ( Status :: INDEX_NEW )
135- {
136- FileStatus :: Added
137- } else if status. contains ( Status :: WT_DELETED ) || status. contains ( Status :: INDEX_DELETED )
138- {
139- FileStatus :: Deleted
140- } else if status. contains ( Status :: WT_MODIFIED )
141- || status. contains ( Status :: INDEX_MODIFIED )
142- {
143- FileStatus :: Modified
144- } else if status. contains ( Status :: WT_RENAMED ) || status. contains ( Status :: INDEX_RENAMED )
145- {
146- FileStatus :: Renamed
147- } else {
148- continue ;
149- } ;
150-
151- files. push ( GitFileStatus {
152- path : repo_root. join ( relative_path) . to_string_lossy ( ) . to_string ( ) ,
153- status : file_status,
154- } ) ;
209+ if let Some ( file_status) = Self :: status_from_entry (
210+ repo_root,
211+ relative_path,
212+ entry. status ( ) ,
213+ numstat_by_path
214+ . get ( relative_path)
215+ . map ( |( added, _) | * added)
216+ . unwrap_or ( 0 ) ,
217+ numstat_by_path
218+ . get ( relative_path)
219+ . map ( |( _, removed) | * removed)
220+ . unwrap_or ( 0 ) ,
221+ ) {
222+ files. push ( file_status) ;
223+ }
155224 }
156225
157226 info ! (
@@ -183,6 +252,78 @@ impl GitManager {
183252 }
184253 }
185254
255+ pub fn check_status_changed_for_paths ( & mut self , paths : & [ PathBuf ] ) -> Option < GitStatusUpdate > {
256+ let repo = self . repo ( ) . ok ( ) ?;
257+ let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
258+ let branch = Self :: branch_name ( & repo) ;
259+
260+ if self . status_cache . branch != branch {
261+ let full = self . status ( ) . ok ( ) ?;
262+ if self . status_cache != full {
263+ self . status_cache = full. clone ( ) ;
264+ return Some ( GitStatusUpdate :: Full ( full) ) ;
265+ }
266+ return None ;
267+ }
268+
269+ let mut patch_files: Vec < GitStatusPatchFile > = Vec :: new ( ) ;
270+
271+ for path in paths {
272+ let Some ( relative_path) = self . to_repo_relative_path ( path, repo_root) else {
273+ let full = self . status ( ) . ok ( ) ?;
274+ if self . status_cache != full {
275+ self . status_cache = full. clone ( ) ;
276+ return Some ( GitStatusUpdate :: Full ( full) ) ;
277+ }
278+ return None ;
279+ } ;
280+ if relative_path. is_empty ( ) {
281+ continue ;
282+ }
283+
284+ let abs_path = repo_root. join ( & relative_path) . to_string_lossy ( ) . to_string ( ) ;
285+ let next_file = self . status_for_relative_path ( & repo, & relative_path) . ok ( ) ?;
286+ let prev_index = self . status_cache . files . iter ( ) . position ( |f| f. path == abs_path) ;
287+ let prev_file = prev_index. and_then ( |idx| self . status_cache . files . get ( idx) . cloned ( ) ) ;
288+
289+ if prev_file == next_file {
290+ continue ;
291+ }
292+
293+ if let Some ( idx) = prev_index {
294+ self . status_cache . files . remove ( idx) ;
295+ }
296+ if let Some ( file) = next_file. clone ( ) {
297+ self . status_cache . files . push ( file) ;
298+ }
299+
300+ match next_file {
301+ Some ( file) => patch_files. push ( GitStatusPatchFile {
302+ path : file. path ,
303+ status : Self :: status_to_str ( file. status ) . to_string ( ) ,
304+ added : file. added ,
305+ removed : file. removed ,
306+ } ) ,
307+ None => patch_files. push ( GitStatusPatchFile {
308+ path : abs_path,
309+ status : "removed" . to_string ( ) ,
310+ added : 0 ,
311+ removed : 0 ,
312+ } ) ,
313+ }
314+ }
315+
316+ if patch_files. is_empty ( ) {
317+ return None ;
318+ }
319+
320+ self . status_cache . branch = branch. clone ( ) ;
321+ Some ( GitStatusUpdate :: Patch {
322+ branch,
323+ files : patch_files,
324+ } )
325+ }
326+
186327 /// Get original file content from HEAD
187328 pub fn file_original ( & self , path : & str ) -> Result < FileOriginal > {
188329 let repo = self . repo ( ) ?;
@@ -439,4 +580,112 @@ impl GitManager {
439580
440581 Ok ( ( ) )
441582 }
583+ fn branch_name ( repo : & Repository ) -> String {
584+ repo. head ( )
585+ . map ( |h| h. shorthand ( ) . unwrap_or ( "HEAD" ) . to_string ( ) )
586+ . unwrap_or_else ( |_| "HEAD" . to_string ( ) )
587+ }
588+
589+ fn status_from_entry (
590+ repo_root : & Path ,
591+ relative_path : & str ,
592+ status : Status ,
593+ added : usize ,
594+ removed : usize ,
595+ ) -> Option < GitFileStatus > {
596+ let file_status = if status. contains ( Status :: WT_NEW ) || status. contains ( Status :: INDEX_NEW ) {
597+ FileStatus :: Added
598+ } else if status. contains ( Status :: WT_DELETED ) || status. contains ( Status :: INDEX_DELETED ) {
599+ FileStatus :: Deleted
600+ } else if status. contains ( Status :: WT_MODIFIED ) || status. contains ( Status :: INDEX_MODIFIED ) {
601+ FileStatus :: Modified
602+ } else if status. contains ( Status :: WT_RENAMED ) || status. contains ( Status :: INDEX_RENAMED ) {
603+ FileStatus :: Renamed
604+ } else if status. contains ( Status :: CONFLICTED ) {
605+ FileStatus :: Conflict
606+ } else {
607+ return None ;
608+ } ;
609+
610+ Some ( GitFileStatus {
611+ path : repo_root. join ( relative_path) . to_string_lossy ( ) . to_string ( ) ,
612+ status : file_status,
613+ added,
614+ removed,
615+ } )
616+ }
617+
618+ fn status_to_str ( status : FileStatus ) -> & ' static str {
619+ match status {
620+ FileStatus :: Modified => "modified" ,
621+ FileStatus :: Added => "added" ,
622+ FileStatus :: Deleted => "deleted" ,
623+ FileStatus :: Renamed => "renamed" ,
624+ FileStatus :: Conflict => "conflict" ,
625+ }
626+ }
627+
628+ fn to_repo_relative_path ( & self , path : & Path , repo_root : & Path ) -> Option < String > {
629+ let abs = if path. is_absolute ( ) {
630+ path. to_path_buf ( )
631+ } else {
632+ self . workdir . join ( path)
633+ } ;
634+ abs. strip_prefix ( repo_root)
635+ . ok ( )
636+ . map ( |p| p. to_string_lossy ( ) . replace ( '\\' , "/" ) )
637+ }
638+
639+ fn numstat_for_path ( repo : & Repository , relative_path : & str ) -> Result < ( usize , usize ) > {
640+ let mut opts = git2:: DiffOptions :: new ( ) ;
641+ opts. include_untracked ( true )
642+ . recurse_untracked_dirs ( true )
643+ . include_typechange ( true )
644+ . pathspec ( relative_path) ;
645+
646+ let head_tree = repo. head ( ) . ok ( ) . and_then ( |head| head. peel_to_tree ( ) . ok ( ) ) ;
647+ let diff = repo. diff_tree_to_workdir_with_index ( head_tree. as_ref ( ) , Some ( & mut opts) ) ?;
648+
649+ let mut added = 0usize ;
650+ let mut removed = 0usize ;
651+ diff. foreach (
652+ & mut |_delta, _progress| true ,
653+ None ,
654+ None ,
655+ Some ( & mut |_delta, _hunk, line| {
656+ match line. origin ( ) {
657+ '+' => added += 1 ,
658+ '-' => removed += 1 ,
659+ _ => { }
660+ }
661+ true
662+ } ) ,
663+ ) ?;
664+ Ok ( ( added, removed) )
665+ }
666+
667+ fn status_for_relative_path ( & self , repo : & Repository , relative_path : & str ) -> Result < Option < GitFileStatus > > {
668+ let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
669+ let mut opts = StatusOptions :: new ( ) ;
670+ opts. include_untracked ( true )
671+ . recurse_untracked_dirs ( true )
672+ . include_ignored ( false )
673+ . pathspec ( relative_path) ;
674+
675+ let statuses = repo. statuses ( Some ( & mut opts) ) ?;
676+ let ( added, removed) = Self :: numstat_for_path ( repo, relative_path) . unwrap_or ( ( 0 , 0 ) ) ;
677+
678+ for entry in statuses. iter ( ) {
679+ let Some ( entry_path) = entry. path ( ) else {
680+ continue ;
681+ } ;
682+ if entry_path != relative_path {
683+ continue ;
684+ }
685+ if let Some ( file) = Self :: status_from_entry ( repo_root, entry_path, entry. status ( ) , added, removed) {
686+ return Ok ( Some ( file) ) ;
687+ }
688+ }
689+ Ok ( None )
690+ }
442691}
0 commit comments