@@ -20,6 +20,9 @@ pub enum FileStatus {
2020pub struct GitFileStatus {
2121 pub path : String ,
2222 pub status : FileStatus ,
23+ pub staged : bool ,
24+ pub unstaged : bool ,
25+ pub conflicted : bool ,
2326 pub added : usize ,
2427 pub removed : usize ,
2528}
@@ -44,6 +47,9 @@ impl GitStatus {
4447pub struct GitStatusPatchFile {
4548 pub path : String ,
4649 pub status : String ,
50+ pub staged : bool ,
51+ pub unstaged : bool ,
52+ pub conflicted : bool ,
4753 pub added : usize ,
4854 pub removed : usize ,
4955}
@@ -118,6 +124,19 @@ impl GitManager {
118124 Repository :: discover ( & self . workdir ) . context ( "Failed to discover git repository" )
119125 }
120126
127+ fn sort_files ( files : & mut [ GitFileStatus ] ) {
128+ files. sort_by ( |a, b| {
129+ a. path
130+ . cmp ( & b. path )
131+ . then_with ( || Self :: status_to_str ( a. status ) . cmp ( Self :: status_to_str ( b. status ) ) )
132+ . then_with ( || a. staged . cmp ( & b. staged ) )
133+ . then_with ( || a. unstaged . cmp ( & b. unstaged ) )
134+ . then_with ( || a. conflicted . cmp ( & b. conflicted ) )
135+ . then_with ( || a. added . cmp ( & b. added ) )
136+ . then_with ( || a. removed . cmp ( & b. removed ) )
137+ } ) ;
138+ }
139+
121140 /// Check if a path should be ignored (in .git or gitignored)
122141 pub fn should_ignore ( & self , path : & Path ) -> bool {
123142 let path_str = path. to_string_lossy ( ) ;
@@ -225,11 +244,7 @@ impl GitManager {
225244 }
226245 }
227246
228- info ! (
229- "Git status: {} files changed on branch {}" ,
230- files. len( ) ,
231- branch
232- ) ;
247+ Self :: sort_files ( & mut files) ;
233248
234249 Ok ( GitStatus { files, branch } )
235250 }
@@ -261,6 +276,12 @@ impl GitManager {
261276 }
262277
263278 pub fn check_status_changed_for_paths ( & mut self , paths : & [ PathBuf ] ) -> Option < GitStatusUpdate > {
279+ if self . status_cache . files . is_empty ( ) && self . status_cache . branch . is_empty ( ) {
280+ let full = self . status ( ) . ok ( ) ?;
281+ self . status_cache = full. clone ( ) ;
282+ return Some ( GitStatusUpdate :: Full ( full) ) ;
283+ }
284+
264285 let repo = self . repo ( ) . ok ( ) ?;
265286 let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
266287 let branch = Self :: branch_name ( & repo) ;
@@ -290,7 +311,13 @@ impl GitManager {
290311 }
291312
292313 let abs_path = repo_root. join ( & relative_path) . to_string_lossy ( ) . to_string ( ) ;
293- let next_file = self . status_for_relative_path ( & repo, & relative_path) . ok ( ) ?;
314+ let next_file = match self . status_for_relative_path ( & repo, & relative_path) {
315+ Ok ( file) => file,
316+ Err ( e) => {
317+ tracing:: warn!( "Failed to get status for {}: {}" , abs_path, e) ;
318+ continue ;
319+ }
320+ } ;
294321 let prev_index = self
295322 . status_cache
296323 . files
@@ -309,17 +336,24 @@ impl GitManager {
309336 if let Some ( file) = next_file. clone ( ) {
310337 self . status_cache . files . push ( file) ;
311338 }
339+ Self :: sort_files ( & mut self . status_cache . files ) ;
312340
313341 match next_file {
314342 Some ( file) => patch_files. push ( GitStatusPatchFile {
315343 path : file. path ,
316344 status : Self :: status_to_str ( file. status ) . to_string ( ) ,
345+ staged : file. staged ,
346+ unstaged : file. unstaged ,
347+ conflicted : file. conflicted ,
317348 added : file. added ,
318349 removed : file. removed ,
319350 } ) ,
320351 None => patch_files. push ( GitStatusPatchFile {
321352 path : abs_path,
322353 status : "removed" . to_string ( ) ,
354+ staged : false ,
355+ unstaged : false ,
356+ conflicted : false ,
323357 added : 0 ,
324358 removed : 0 ,
325359 } ) ,
@@ -382,38 +416,12 @@ impl GitManager {
382416 } )
383417 }
384418
385- /// Commit files
386- pub fn commit ( & self , files : & [ String ] , message : & str ) -> Result < ( ) > {
419+ /// Commit currently staged index entries (like `git commit`)
420+ pub fn commit ( & self , message : & str ) -> Result < ( ) > {
387421 let repo = self . repo ( ) ?;
388422 let mut index = repo. index ( ) ?;
389423 let head_commit = repo. head ( ) . ok ( ) . and_then ( |h| h. peel_to_commit ( ) . ok ( ) ) ;
390424
391- // Build commit index from HEAD tree so the commit contains only explicitly
392- // selected paths from the UI, not previously staged leftovers.
393- if let Some ( head) = & head_commit {
394- let head_tree = head. tree ( ) ?;
395- index. read_tree ( & head_tree) ?;
396- } else {
397- index. clear ( ) ?;
398- }
399-
400- let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
401- for file_path in files {
402- let path = Path :: new ( file_path) ;
403- let relative_path = if path. is_absolute ( ) {
404- path. strip_prefix ( repo_root) . unwrap_or ( path)
405- } else {
406- path
407- } ;
408-
409- let full_path = repo_root. join ( relative_path) ;
410- if full_path. exists ( ) {
411- index. add_path ( relative_path) ?;
412- } else {
413- index. remove_path ( relative_path) ?;
414- }
415- }
416-
417425 index. write ( ) ?;
418426
419427 let tree_id = index. write_tree ( ) ?;
@@ -426,7 +434,50 @@ impl GitManager {
426434 repo. commit ( Some ( "HEAD" ) , & sig, & sig, message, & tree, & parents_refs)
427435 . context ( "Failed to commit" ) ?;
428436
429- info ! ( "Committed {} files: {}" , files. len( ) , message) ;
437+ info ! ( "Committed staged index: {}" , message) ;
438+ Ok ( ( ) )
439+ }
440+
441+ /// Stage file in index (like `git add <path>`)
442+ pub fn stage ( & self , path : & str ) -> Result < ( ) > {
443+ let repo = self . repo ( ) ?;
444+ let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
445+ let file_path = Path :: new ( path) ;
446+ let relative_path = if file_path. is_absolute ( ) {
447+ file_path. strip_prefix ( repo_root) . unwrap_or ( file_path)
448+ } else {
449+ file_path
450+ } ;
451+
452+ let mut index = repo. index ( ) ?;
453+ if repo_root. join ( relative_path) . exists ( ) {
454+ index. add_path ( relative_path) ?;
455+ } else {
456+ let _ = index. remove_path ( relative_path) ;
457+ }
458+ index. write ( ) ?;
459+ Ok ( ( ) )
460+ }
461+
462+ /// Unstage file from index (like `git restore --staged <path>`)
463+ pub fn unstage ( & self , path : & str ) -> Result < ( ) > {
464+ let repo = self . repo ( ) ?;
465+ let repo_root = repo. workdir ( ) . unwrap_or ( Path :: new ( "." ) ) ;
466+ let file_path = Path :: new ( path) ;
467+ let relative_path = if file_path. is_absolute ( ) {
468+ file_path. strip_prefix ( repo_root) . unwrap_or ( file_path)
469+ } else {
470+ file_path
471+ } ;
472+
473+ let head = repo. head ( ) . ok ( ) . and_then ( |h| h. peel_to_commit ( ) . ok ( ) ) ;
474+ if let Some ( commit) = head {
475+ repo. reset_default ( Some ( commit. as_object ( ) ) , [ relative_path] ) ?;
476+ } else {
477+ let mut index = repo. index ( ) ?;
478+ let _ = index. remove_path ( relative_path) ;
479+ index. write ( ) ?;
480+ }
430481 Ok ( ( ) )
431482 }
432483
@@ -667,16 +718,32 @@ impl GitManager {
667718 added : usize ,
668719 removed : usize ,
669720 ) -> Option < GitFileStatus > {
670- let file_status = if status. contains ( Status :: WT_NEW ) || status. contains ( Status :: INDEX_NEW ) {
721+ let conflicted = status. contains ( Status :: CONFLICTED ) ;
722+ let staged = status. intersects (
723+ Status :: INDEX_NEW
724+ | Status :: INDEX_MODIFIED
725+ | Status :: INDEX_DELETED
726+ | Status :: INDEX_RENAMED
727+ | Status :: INDEX_TYPECHANGE ,
728+ ) ;
729+ let unstaged = status. intersects (
730+ Status :: WT_NEW
731+ | Status :: WT_MODIFIED
732+ | Status :: WT_DELETED
733+ | Status :: WT_RENAMED
734+ | Status :: WT_TYPECHANGE ,
735+ ) ;
736+
737+ let file_status = if conflicted {
738+ FileStatus :: Conflict
739+ } else if status. contains ( Status :: WT_NEW ) || status. contains ( Status :: INDEX_NEW ) {
671740 FileStatus :: Added
672741 } else if status. contains ( Status :: WT_DELETED ) || status. contains ( Status :: INDEX_DELETED ) {
673742 FileStatus :: Deleted
674743 } else if status. contains ( Status :: WT_MODIFIED ) || status. contains ( Status :: INDEX_MODIFIED ) {
675744 FileStatus :: Modified
676745 } else if status. contains ( Status :: WT_RENAMED ) || status. contains ( Status :: INDEX_RENAMED ) {
677746 FileStatus :: Renamed
678- } else if status. contains ( Status :: CONFLICTED ) {
679- FileStatus :: Conflict
680747 } else {
681748 return None ;
682749 } ;
@@ -694,6 +761,9 @@ impl GitManager {
694761 Some ( GitFileStatus {
695762 path : repo_root. join ( relative_path) . to_string_lossy ( ) . to_string ( ) ,
696763 status : file_status,
764+ staged,
765+ unstaged,
766+ conflicted,
697767 added,
698768 removed,
699769 } )
0 commit comments