tenseleyflow/fackr / d55b099

Browse files

feat(fuss): add git integration to file tree

- Add GitStatus struct tracking staged/unstaged/untracked/incoming/gitignored
- Parse git status --porcelain output for file status
- Implement git operations: stage, unstage, commit, push, pull, fetch, tag
- Add get_diff_for_selected to show file diffs
- Smart collapse: only expand directories containing dirty files
- Detect gitignored files and incoming changes after fetch
Authored by espadonne
SHA
d55b0991ccacc51e693725ec5376bce26cecaa95
Parents
a2d708b
Tree
bdc753c

3 changed files

StatusFile+-
M src/fuss/mod.rs 1 1
M src/fuss/state.rs 273 1
M src/fuss/tree.rs 230 0
src/fuss/mod.rsmodified
@@ -8,4 +8,4 @@ mod state;
88
 
99
 pub use state::FussMode;
1010
 #[allow(unused_imports)]
11
-pub use tree::{FileTree, TreeNode, VisibleItem};
11
+pub use tree::{FileTree, GitStatus, TreeNode, VisibleItem};
src/fuss/state.rsmodified
@@ -3,6 +3,7 @@
33
 #![allow(dead_code)]
44
 
55
 use std::path::{Path, PathBuf};
6
+use std::process::Command;
67
 use super::tree::FileTree;
78
 
89
 /// Fuss mode state
@@ -47,7 +48,9 @@ impl FussMode {
4748
     /// Initialize with a root path
4849
     pub fn init(&mut self, root_path: &Path) {
4950
         self.root_path = Some(root_path.to_path_buf());
50
-        self.tree = Some(FileTree::new(root_path));
51
+        let mut tree = FileTree::new(root_path);
52
+        tree.update_git_status();
53
+        self.tree = Some(tree);
5154
         self.selected = 0;
5255
         self.scroll = 0;
5356
     }
@@ -160,6 +163,275 @@ impl FussMode {
160163
     pub fn reload(&mut self) {
161164
         if let Some(ref mut tree) = self.tree {
162165
             tree.reload();
166
+            tree.update_git_status();
167
+        }
168
+    }
169
+
170
+    /// Refresh git status without reloading file tree
171
+    pub fn refresh_git_status(&mut self) {
172
+        if let Some(ref mut tree) = self.tree {
173
+            tree.update_git_status();
174
+        }
175
+    }
176
+
177
+    /// Stage the currently selected file
178
+    /// Returns true on success, false on failure
179
+    pub fn stage_selected(&mut self) -> bool {
180
+        let root = match &self.root_path {
181
+            Some(p) => p.clone(),
182
+            None => return false,
183
+        };
184
+
185
+        let path = match self.selected_path() {
186
+            Some(p) => p,
187
+            None => return false,
188
+        };
189
+
190
+        // Don't stage directories
191
+        if self.is_dir_selected() {
192
+            return false;
193
+        }
194
+
195
+        let output = Command::new("git")
196
+            .arg("-C")
197
+            .arg(&root)
198
+            .arg("add")
199
+            .arg(&path)
200
+            .output();
201
+
202
+        if let Ok(output) = output {
203
+            if output.status.success() {
204
+                self.refresh_git_status();
205
+                return true;
206
+            }
207
+        }
208
+        false
209
+    }
210
+
211
+    /// Unstage the currently selected file
212
+    /// Returns true on success, false on failure
213
+    pub fn unstage_selected(&mut self) -> bool {
214
+        let root = match &self.root_path {
215
+            Some(p) => p.clone(),
216
+            None => return false,
217
+        };
218
+
219
+        let path = match self.selected_path() {
220
+            Some(p) => p,
221
+            None => return false,
222
+        };
223
+
224
+        // Don't unstage directories
225
+        if self.is_dir_selected() {
226
+            return false;
227
+        }
228
+
229
+        let output = Command::new("git")
230
+            .arg("-C")
231
+            .arg(&root)
232
+            .arg("restore")
233
+            .arg("--staged")
234
+            .arg(&path)
235
+            .output();
236
+
237
+        if let Ok(output) = output {
238
+            if output.status.success() {
239
+                self.refresh_git_status();
240
+                return true;
241
+            }
242
+        }
243
+        false
244
+    }
245
+
246
+    /// Get the root path
247
+    pub fn root_path(&self) -> Option<&Path> {
248
+        self.root_path.as_deref()
249
+    }
250
+
251
+    /// Push to remote
252
+    /// Returns (success, message)
253
+    pub fn git_push(&mut self) -> (bool, String) {
254
+        let root = match &self.root_path {
255
+            Some(p) => p.clone(),
256
+            None => return (false, "No workspace".to_string()),
257
+        };
258
+
259
+        let output = Command::new("git")
260
+            .arg("-C")
261
+            .arg(&root)
262
+            .arg("push")
263
+            .output();
264
+
265
+        match output {
266
+            Ok(out) if out.status.success() => {
267
+                self.refresh_git_status();
268
+                (true, "Pushed".to_string())
269
+            }
270
+            Ok(out) => {
271
+                let stderr = String::from_utf8_lossy(&out.stderr);
272
+                (false, format!("Push failed: {}", stderr.lines().next().unwrap_or("unknown error")))
273
+            }
274
+            Err(e) => (false, format!("Failed to run git: {}", e)),
275
+        }
276
+    }
277
+
278
+    /// Pull from remote
279
+    /// Returns (success, message)
280
+    pub fn git_pull(&mut self) -> (bool, String) {
281
+        let root = match &self.root_path {
282
+            Some(p) => p.clone(),
283
+            None => return (false, "No workspace".to_string()),
284
+        };
285
+
286
+        let output = Command::new("git")
287
+            .arg("-C")
288
+            .arg(&root)
289
+            .arg("pull")
290
+            .output();
291
+
292
+        match output {
293
+            Ok(out) if out.status.success() => {
294
+                self.refresh_git_status();
295
+                (true, "Pulled".to_string())
296
+            }
297
+            Ok(out) => {
298
+                let stderr = String::from_utf8_lossy(&out.stderr);
299
+                (false, format!("Pull failed: {}", stderr.lines().next().unwrap_or("unknown error")))
300
+            }
301
+            Err(e) => (false, format!("Failed to run git: {}", e)),
302
+        }
303
+    }
304
+
305
+    /// Create a git tag
306
+    /// Returns (success, message)
307
+    pub fn git_tag(&mut self, tag_name: &str) -> (bool, String) {
308
+        let root = match &self.root_path {
309
+            Some(p) => p.clone(),
310
+            None => return (false, "No workspace".to_string()),
311
+        };
312
+
313
+        if tag_name.trim().is_empty() {
314
+            return (false, "Empty tag name".to_string());
315
+        }
316
+
317
+        let output = Command::new("git")
318
+            .arg("-C")
319
+            .arg(&root)
320
+            .arg("tag")
321
+            .arg(tag_name.trim())
322
+            .output();
323
+
324
+        match output {
325
+            Ok(out) if out.status.success() => {
326
+                (true, format!("Created tag: {}", tag_name.trim()))
327
+            }
328
+            Ok(out) => {
329
+                let stderr = String::from_utf8_lossy(&out.stderr);
330
+                (false, format!("Tag failed: {}", stderr.lines().next().unwrap_or("unknown error")))
331
+            }
332
+            Err(e) => (false, format!("Failed to run git: {}", e)),
333
+        }
334
+    }
335
+
336
+    /// Fetch from remote
337
+    /// Returns (success, message)
338
+    pub fn git_fetch(&mut self) -> (bool, String) {
339
+        let root = match &self.root_path {
340
+            Some(p) => p.clone(),
341
+            None => return (false, "No workspace".to_string()),
342
+        };
343
+
344
+        let output = Command::new("git")
345
+            .arg("-C")
346
+            .arg(&root)
347
+            .arg("fetch")
348
+            .output();
349
+
350
+        match output {
351
+            Ok(out) if out.status.success() => {
352
+                self.refresh_git_status();
353
+                (true, "Fetched".to_string())
354
+            }
355
+            Ok(out) => {
356
+                let stderr = String::from_utf8_lossy(&out.stderr);
357
+                (false, format!("Fetch failed: {}", stderr.lines().next().unwrap_or("unknown error")))
358
+            }
359
+            Err(e) => (false, format!("Failed to run git: {}", e)),
360
+        }
361
+    }
362
+
363
+    /// Commit staged changes with the given message
364
+    /// Returns (success, message)
365
+    pub fn git_commit(&mut self, message: &str) -> (bool, String) {
366
+        let root = match &self.root_path {
367
+            Some(p) => p.clone(),
368
+            None => return (false, "No workspace".to_string()),
369
+        };
370
+
371
+        if message.trim().is_empty() {
372
+            return (false, "Empty commit message".to_string());
373
+        }
374
+
375
+        let output = Command::new("git")
376
+            .arg("-C")
377
+            .arg(&root)
378
+            .arg("commit")
379
+            .arg("-m")
380
+            .arg(message)
381
+            .output();
382
+
383
+        match output {
384
+            Ok(out) if out.status.success() => {
385
+                self.refresh_git_status();
386
+                (true, "Committed".to_string())
387
+            }
388
+            Ok(out) => {
389
+                let stderr = String::from_utf8_lossy(&out.stderr);
390
+                if stderr.contains("nothing to commit") {
391
+                    (false, "Nothing to commit".to_string())
392
+                } else {
393
+                    (false, format!("Commit failed: {}", stderr.lines().next().unwrap_or("unknown error")))
394
+                }
395
+            }
396
+            Err(e) => (false, format!("Failed to run git: {}", e)),
397
+        }
398
+    }
399
+
400
+    /// Get git diff for the currently selected file
401
+    /// Returns (filename, diff_content) or None if no diff
402
+    pub fn get_diff_for_selected(&self) -> Option<(String, String)> {
403
+        let root = self.root_path.as_ref()?;
404
+        let path = self.selected_path()?;
405
+
406
+        // Don't diff directories
407
+        if self.is_dir_selected() {
408
+            return None;
409
+        }
410
+
411
+        // Get relative path for display
412
+        let rel_path = path.strip_prefix(root).unwrap_or(&path);
413
+        let filename = rel_path.to_string_lossy().to_string();
414
+
415
+        // Run git diff
416
+        let output = Command::new("git")
417
+            .arg("-C")
418
+            .arg(root)
419
+            .arg("diff")
420
+            .arg("HEAD")
421
+            .arg("--")
422
+            .arg(&path)
423
+            .output()
424
+            .ok()?;
425
+
426
+        if output.status.success() {
427
+            let diff = String::from_utf8_lossy(&output.stdout).to_string();
428
+            if diff.is_empty() {
429
+                Some((filename, "(no changes)".to_string()))
430
+            } else {
431
+                Some((filename, diff))
432
+            }
433
+        } else {
434
+            None
163435
         }
164436
     }
165437
 }
src/fuss/tree.rsmodified
@@ -4,6 +4,23 @@
44
 
55
 use std::path::{Path, PathBuf};
66
 use std::fs;
7
+use std::process::Command;
8
+use std::collections::HashMap;
9
+
10
+/// Git status for a file
11
+#[derive(Debug, Clone, Default)]
12
+pub struct GitStatus {
13
+    /// File is staged (added to index)
14
+    pub staged: bool,
15
+    /// File has unstaged changes
16
+    pub unstaged: bool,
17
+    /// File is untracked
18
+    pub untracked: bool,
19
+    /// File has incoming changes (after fetch)
20
+    pub incoming: bool,
21
+    /// File is gitignored
22
+    pub gitignored: bool,
23
+}
724
 
825
 /// A node in the file tree
926
 #[derive(Debug, Clone)]
@@ -20,6 +37,8 @@ pub struct TreeNode {
2037
     pub children: Vec<TreeNode>,
2138
     /// Depth in tree (for indentation)
2239
     pub depth: usize,
40
+    /// Git status for this file
41
+    pub git_status: GitStatus,
2342
 }
2443
 
2544
 impl TreeNode {
@@ -39,6 +58,7 @@ impl TreeNode {
3958
             expanded: depth == 0, // Root is expanded by default
4059
             children: Vec::new(),
4160
             depth,
61
+            git_status: GitStatus::default(),
4262
         }
4363
     }
4464
 
@@ -114,6 +134,8 @@ pub struct VisibleItem {
114134
     pub expanded: bool,
115135
     /// Indentation depth
116136
     pub depth: usize,
137
+    /// Git status
138
+    pub git_status: GitStatus,
117139
 }
118140
 
119141
 impl FileTree {
@@ -146,6 +168,7 @@ impl FileTree {
146168
                 is_dir: node.is_dir,
147169
                 expanded: node.expanded,
148170
                 depth: node.depth,
171
+                git_status: node.git_status.clone(),
149172
             });
150173
         }
151174
 
@@ -226,4 +249,211 @@ impl FileTree {
226249
             }
227250
         }
228251
     }
252
+
253
+    /// Update git status for all files in the tree
254
+    pub fn update_git_status(&mut self) {
255
+        let root_path = self.root.path.clone();
256
+        let status_map = get_git_status(&root_path);
257
+        Self::apply_git_status(&mut self.root, &status_map, &root_path);
258
+        // Smart collapse: only expand directories with dirty files
259
+        Self::smart_collapse_node(&mut self.root, true);
260
+        self.rebuild_visible();
261
+    }
262
+
263
+    fn apply_git_status(node: &mut TreeNode, status_map: &HashMap<PathBuf, GitStatus>, root: &Path) {
264
+        // Get relative path from root
265
+        if let Ok(rel_path) = node.path.strip_prefix(root) {
266
+            if let Some(status) = status_map.get(rel_path) {
267
+                node.git_status = status.clone();
268
+            } else {
269
+                node.git_status = GitStatus::default();
270
+            }
271
+        }
272
+
273
+        // Recurse into children
274
+        for child in &mut node.children {
275
+            Self::apply_git_status(child, status_map, root);
276
+        }
277
+    }
278
+
279
+    /// Check if tree has any dirty files (staged, unstaged, or untracked)
280
+    pub fn has_dirty_files(&self) -> bool {
281
+        Self::node_has_dirty(&self.root)
282
+    }
283
+
284
+    fn node_has_dirty(node: &TreeNode) -> bool {
285
+        if node.git_status.staged || node.git_status.unstaged || node.git_status.untracked {
286
+            return true;
287
+        }
288
+        for child in &node.children {
289
+            if Self::node_has_dirty(child) {
290
+                return true;
291
+            }
292
+        }
293
+        false
294
+    }
295
+
296
+    /// Smart collapse: Only expand directories that contain dirty files
297
+    /// Root is always expanded
298
+    pub fn smart_collapse(&mut self) {
299
+        Self::smart_collapse_node(&mut self.root, true);
300
+        self.rebuild_visible();
301
+    }
302
+
303
+    /// Returns true if node or any descendant has dirty files
304
+    fn smart_collapse_node(node: &mut TreeNode, is_root: bool) -> bool {
305
+        if !node.is_dir {
306
+            // Files: return whether they're dirty
307
+            return node.git_status.staged || node.git_status.unstaged || node.git_status.untracked;
308
+        }
309
+
310
+        // Directory: check all children first
311
+        let mut has_dirty_descendant = false;
312
+        for child in &mut node.children {
313
+            if Self::smart_collapse_node(child, false) {
314
+                has_dirty_descendant = true;
315
+            }
316
+        }
317
+
318
+        // Root stays expanded, others only expand if they have dirty descendants
319
+        if is_root {
320
+            node.expanded = true;
321
+        } else {
322
+            node.expanded = has_dirty_descendant;
323
+        }
324
+
325
+        has_dirty_descendant
326
+    }
327
+}
328
+
329
+/// Parse git status --porcelain output and return a map of file paths to git status
330
+fn get_git_status(root: &Path) -> HashMap<PathBuf, GitStatus> {
331
+    let mut status_map = HashMap::new();
332
+
333
+    // Run git status --porcelain
334
+    let output = Command::new("git")
335
+        .arg("-C")
336
+        .arg(root)
337
+        .arg("status")
338
+        .arg("--porcelain")
339
+        .output();
340
+
341
+    if let Ok(output) = output {
342
+        if output.status.success() {
343
+            let stdout = String::from_utf8_lossy(&output.stdout);
344
+            for line in stdout.lines() {
345
+                if line.len() < 4 {
346
+                    continue;
347
+                }
348
+
349
+                // Format: XY filename
350
+                // X = index status, Y = worktree status
351
+                let index_status = line.chars().next().unwrap_or(' ');
352
+                let worktree_status = line.chars().nth(1).unwrap_or(' ');
353
+                let filename = line[3..].trim();
354
+
355
+                // Handle renamed files (format: "R  old -> new")
356
+                let filename = if filename.contains(" -> ") {
357
+                    filename.split(" -> ").last().unwrap_or(filename)
358
+                } else {
359
+                    filename
360
+                };
361
+
362
+                let path = PathBuf::from(filename);
363
+                let mut status = GitStatus::default();
364
+
365
+                // Check for ignored (!! status)
366
+                if index_status == '!' && worktree_status == '!' {
367
+                    status.gitignored = true;
368
+                }
369
+                // Check for untracked
370
+                else if index_status == '?' && worktree_status == '?' {
371
+                    status.untracked = true;
372
+                } else {
373
+                    // Staged: any non-space, non-? in index position
374
+                    if index_status != ' ' && index_status != '?' {
375
+                        status.staged = true;
376
+                    }
377
+                    // Unstaged: any non-space, non-? in worktree position
378
+                    if worktree_status != ' ' && worktree_status != '?' {
379
+                        status.unstaged = true;
380
+                    }
381
+                }
382
+
383
+                status_map.insert(path, status);
384
+            }
385
+        }
386
+    }
387
+
388
+    // Also get ignored files using --ignored flag
389
+    let output = Command::new("git")
390
+        .arg("-C")
391
+        .arg(root)
392
+        .arg("status")
393
+        .arg("--porcelain")
394
+        .arg("--ignored")
395
+        .output();
396
+
397
+    if let Ok(output) = output {
398
+        if output.status.success() {
399
+            let stdout = String::from_utf8_lossy(&output.stdout);
400
+            for line in stdout.lines() {
401
+                if line.len() < 4 {
402
+                    continue;
403
+                }
404
+
405
+                let index_status = line.chars().next().unwrap_or(' ');
406
+                let worktree_status = line.chars().nth(1).unwrap_or(' ');
407
+
408
+                // !! means ignored
409
+                if index_status == '!' && worktree_status == '!' {
410
+                    let filename = line[3..].trim();
411
+                    let path = PathBuf::from(filename);
412
+
413
+                    // Only add if not already in map with other status
414
+                    status_map.entry(path).or_insert_with(|| {
415
+                        let mut s = GitStatus::default();
416
+                        s.gitignored = true;
417
+                        s
418
+                    });
419
+                }
420
+            }
421
+        }
422
+    }
423
+
424
+    // Get files with incoming changes (differ from upstream)
425
+    // Use git diff --name-only @{u}...HEAD to see files changed in upstream but not in local
426
+    let output = Command::new("git")
427
+        .arg("-C")
428
+        .arg(root)
429
+        .arg("diff")
430
+        .arg("--name-only")
431
+        .arg("HEAD...@{u}")
432
+        .output();
433
+
434
+    if let Ok(output) = output {
435
+        if output.status.success() {
436
+            let stdout = String::from_utf8_lossy(&output.stdout);
437
+            for line in stdout.lines() {
438
+                let filename = line.trim();
439
+                if filename.is_empty() {
440
+                    continue;
441
+                }
442
+                let path = PathBuf::from(filename);
443
+
444
+                // Mark as having incoming changes
445
+                status_map
446
+                    .entry(path)
447
+                    .and_modify(|s| s.incoming = true)
448
+                    .or_insert_with(|| {
449
+                        let mut s = GitStatus::default();
450
+                        s.incoming = true;
451
+                        s
452
+                    });
453
+            }
454
+        }
455
+        // If command fails (no upstream), that's fine - just no incoming indicators
456
+    }
457
+
458
+    status_map
229459
 }