Rust · 21679 bytes Raw Blame History
1 use crate::error::{FussrError, Result};
2 use crate::types::{FileEntry, FileStatus};
3 use git2::{Repository, Status, StatusOptions};
4 use std::collections::HashSet;
5 use std::path::{Path, PathBuf};
6 use std::process::Command;
7
8 /// Git operations wrapper
9 pub struct GitRepo {
10 repo: Repository,
11 }
12
13 impl GitRepo {
14 /// Open the git repository in the current directory
15 pub fn open() -> Result<Self> {
16 let repo = Repository::discover(".")?;
17 Ok(Self { repo })
18 }
19
20 /// Get repository name (basename of repo root)
21 pub fn repo_name(&self) -> String {
22 self.repo
23 .workdir()
24 .and_then(|p| p.file_name())
25 .and_then(|n| n.to_str())
26 .map(|s| s.to_string())
27 .unwrap_or_else(|| "unknown".to_string())
28 }
29
30 /// Get current branch name
31 pub fn branch_name(&self) -> String {
32 self.repo
33 .head()
34 .ok()
35 .and_then(|head| head.shorthand().map(|s| s.to_string()))
36 .unwrap_or_else(|| "HEAD".to_string())
37 }
38
39 /// Get dirty files (staged, unstaged, untracked)
40 pub fn get_dirty_files(&self) -> Result<Vec<FileEntry>> {
41 let mut opts = StatusOptions::new();
42 opts.include_untracked(true)
43 .recurse_untracked_dirs(true)
44 .include_ignored(false);
45
46 let statuses = self.repo.statuses(Some(&mut opts))?;
47 let mut files = Vec::new();
48
49 for entry in statuses.iter() {
50 if let Some(path) = entry.path() {
51 let status = self.parse_status(entry.status());
52 files.push(FileEntry::new(PathBuf::from(path), status));
53 }
54 }
55
56 // Sort by path for consistent display
57 files.sort_by(|a, b| a.path.cmp(&b.path));
58 Ok(files)
59 }
60
61 /// Get all tracked files (optionally including dirty status)
62 pub fn get_all_files(&self) -> Result<Vec<FileEntry>> {
63 // First get dirty files for status lookup
64 let dirty_files = self.get_dirty_files()?;
65 let dirty_paths: std::collections::HashMap<_, _> = dirty_files
66 .iter()
67 .map(|f| (f.path.clone(), f.status.clone()))
68 .collect();
69
70 // Get all tracked files from index
71 let index = self.repo.index()?;
72 let mut files = Vec::new();
73 let mut seen = HashSet::new();
74
75 for entry in index.iter() {
76 let path = PathBuf::from(
77 std::str::from_utf8(&entry.path).unwrap_or_default()
78 );
79
80 if seen.insert(path.clone()) {
81 let status = dirty_paths
82 .get(&path)
83 .cloned()
84 .unwrap_or_default();
85 files.push(FileEntry::new(path, status));
86 }
87 }
88
89 // Add untracked files from dirty list
90 for file in dirty_files {
91 if file.status.is_untracked && !seen.contains(&file.path) {
92 files.push(file);
93 }
94 }
95
96 files.sort_by(|a, b| a.path.cmp(&b.path));
97 Ok(files)
98 }
99
100 /// Parse git2 Status flags into our FileStatus
101 fn parse_status(&self, status: Status) -> FileStatus {
102 let mut fs = FileStatus::new();
103
104 // Index (staged) changes
105 if status.intersects(
106 Status::INDEX_NEW
107 | Status::INDEX_MODIFIED
108 | Status::INDEX_DELETED
109 | Status::INDEX_RENAMED
110 | Status::INDEX_TYPECHANGE,
111 ) {
112 fs.is_staged = true;
113 }
114
115 // Worktree (unstaged) changes
116 if status.intersects(
117 Status::WT_MODIFIED
118 | Status::WT_DELETED
119 | Status::WT_RENAMED
120 | Status::WT_TYPECHANGE,
121 ) {
122 fs.is_unstaged = true;
123 }
124
125 // Untracked
126 if status.contains(Status::WT_NEW) {
127 fs.is_untracked = true;
128 }
129
130 // Ignored
131 if status.contains(Status::IGNORED) {
132 fs.is_gitignored = true;
133 }
134
135 fs
136 }
137
138 /// Mark files that have incoming changes from remote
139 pub fn mark_incoming_changes(&self, files: &mut [FileEntry]) -> Result<()> {
140 // Check if there's an upstream branch
141 let upstream = match self.get_upstream_name() {
142 Some(name) => name,
143 None => return Ok(()), // No upstream, nothing to do
144 };
145
146 // Get files changed between HEAD and upstream using git diff
147 let output = Command::new("git")
148 .args(["diff", "--name-only", &format!("HEAD...{}", upstream)])
149 .output()?;
150
151 if !output.status.success() {
152 return Ok(());
153 }
154
155 let changed: HashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
156 .lines()
157 .map(PathBuf::from)
158 .collect();
159
160 for file in files {
161 if changed.contains(&file.path) {
162 file.status.has_incoming = true;
163 }
164 }
165
166 Ok(())
167 }
168
169 /// Get upstream branch name if configured
170 fn get_upstream_name(&self) -> Option<String> {
171 let head = self.repo.head().ok()?;
172 let branch_name = head.shorthand()?;
173 let branch = self.repo.find_branch(branch_name, git2::BranchType::Local).ok()?;
174 let upstream = branch.upstream().ok()?;
175 upstream.name().ok().flatten().map(|s| s.to_string())
176 }
177
178 /// Stage a file
179 pub fn stage_file(&self, path: &Path) -> Result<()> {
180 let mut index = self.repo.index()?;
181 index.add_path(path)?;
182 index.write()?;
183 Ok(())
184 }
185
186 /// Stage all files in a directory
187 pub fn stage_directory(&self, path: &Path) -> Result<()> {
188 // Use git add with the directory path
189 let status = Command::new("git")
190 .args(["add", &path.to_string_lossy()])
191 .status()?;
192
193 if status.success() {
194 Ok(())
195 } else {
196 Err(FussrError::Git(git2::Error::from_str("Failed to stage directory")))
197 }
198 }
199
200 /// Stage all changes
201 pub fn stage_all(&self) -> Result<()> {
202 let status = Command::new("git")
203 .args(["add", "--all"])
204 .status()?;
205
206 if status.success() {
207 Ok(())
208 } else {
209 Err(FussrError::Git(git2::Error::from_str("Failed to stage all")))
210 }
211 }
212
213 /// Unstage a file
214 pub fn unstage_file(&self, path: &Path) -> Result<()> {
215 let status = Command::new("git")
216 .args(["restore", "--staged", &path.to_string_lossy()])
217 .status()?;
218
219 if status.success() {
220 Ok(())
221 } else {
222 Err(FussrError::Git(git2::Error::from_str("Failed to unstage file")))
223 }
224 }
225
226 /// Unstage all files
227 pub fn unstage_all(&self) -> Result<()> {
228 let status = Command::new("git")
229 .args(["restore", "--staged", "."])
230 .status()?;
231
232 if status.success() {
233 Ok(())
234 } else {
235 Err(FussrError::Git(git2::Error::from_str("Failed to unstage all")))
236 }
237 }
238
239 /// Discard changes to a file
240 pub fn discard_changes(&self, path: &Path, is_untracked: bool) -> Result<()> {
241 if is_untracked {
242 // Delete untracked file
243 std::fs::remove_file(path)?;
244 } else {
245 // Restore from HEAD
246 let status = Command::new("git")
247 .args([
248 "restore",
249 "--source=HEAD",
250 "--staged",
251 "--worktree",
252 &path.to_string_lossy(),
253 ])
254 .status()?;
255
256 if !status.success() {
257 return Err(FussrError::Git(git2::Error::from_str("Failed to discard changes")));
258 }
259 }
260 Ok(())
261 }
262
263 /// Delete a file (tracked or untracked)
264 pub fn delete_file(&self, path: &Path, is_untracked: bool) -> Result<()> {
265 if is_untracked {
266 std::fs::remove_file(path)?;
267 } else {
268 let status = Command::new("git")
269 .args(["rm", "-f", &path.to_string_lossy()])
270 .status()?;
271
272 if !status.success() {
273 return Err(FussrError::Git(git2::Error::from_str("Failed to delete file")));
274 }
275 }
276 Ok(())
277 }
278
279 /// Create a commit with the given message (captures output to not corrupt TUI)
280 pub fn commit(&self, message: &str) -> Result<()> {
281 let output = Command::new("git")
282 .args(["commit", "-m", message])
283 .stdout(std::process::Stdio::piped())
284 .stderr(std::process::Stdio::piped())
285 .output()?;
286
287 if output.status.success() {
288 Ok(())
289 } else {
290 Err(FussrError::Git(git2::Error::from_str("Failed to commit")))
291 }
292 }
293
294 /// Amend the last commit (captures output to not corrupt TUI)
295 pub fn commit_amend(&self, message: &str) -> Result<()> {
296 let output = Command::new("git")
297 .args(["commit", "--amend", "-m", message])
298 .stdout(std::process::Stdio::piped())
299 .stderr(std::process::Stdio::piped())
300 .output()?;
301
302 if output.status.success() {
303 Ok(())
304 } else {
305 Err(FussrError::Git(git2::Error::from_str("Failed to amend commit")))
306 }
307 }
308
309 /// Get last commit message
310 pub fn last_commit_message(&self) -> Option<String> {
311 let output = Command::new("git")
312 .args(["log", "-1", "--pretty=%B"])
313 .output()
314 .ok()?;
315
316 if output.status.success() {
317 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
318 } else {
319 None
320 }
321 }
322
323 /// Fetch from remote (captures output to not corrupt TUI)
324 pub fn fetch(&self) -> Result<()> {
325 let output = Command::new("git")
326 .args(["fetch"])
327 .stdout(std::process::Stdio::piped())
328 .stderr(std::process::Stdio::piped())
329 .output()?;
330
331 if output.status.success() {
332 Ok(())
333 } else {
334 let stderr = String::from_utf8_lossy(&output.stderr);
335 let msg = if stderr.contains("Could not read from remote") {
336 "Cannot reach remote - check connection/auth".to_string()
337 } else if stderr.contains("does not appear to be a git repository") {
338 "Remote not found. Add with: git remote add origin <url>".to_string()
339 } else {
340 "Fetch failed".to_string()
341 };
342 Err(FussrError::Git(git2::Error::from_str(&msg)))
343 }
344 }
345
346 /// Fetch from a specific remote
347 pub fn fetch_from_remote(&self, remote: &str) -> Result<()> {
348 let output = Command::new("git")
349 .args(["fetch", remote])
350 .stdout(std::process::Stdio::piped())
351 .stderr(std::process::Stdio::piped())
352 .output()?;
353
354 if output.status.success() {
355 Ok(())
356 } else {
357 let stderr = String::from_utf8_lossy(&output.stderr);
358 let msg = if stderr.contains("Could not read from remote") {
359 format!("Cannot reach '{}' - check connection/auth", remote)
360 } else if stderr.contains("does not appear to be a git repository") {
361 format!("Remote '{}' not found", remote)
362 } else {
363 format!("Fetch from '{}' failed", remote)
364 };
365 Err(FussrError::Git(git2::Error::from_str(&msg)))
366 }
367 }
368
369 /// Pull from remote (captures output to not corrupt TUI)
370 pub fn pull(&self) -> Result<()> {
371 let output = Command::new("git")
372 .args(["pull"])
373 .stdout(std::process::Stdio::piped())
374 .stderr(std::process::Stdio::piped())
375 .output()?;
376
377 if output.status.success() {
378 Ok(())
379 } else {
380 let stderr = String::from_utf8_lossy(&output.stderr);
381 let msg = if stderr.contains("no tracking information") || stderr.contains("no upstream") {
382 format!("No upstream set. Run: git branch --set-upstream-to=origin/{}", self.branch_name())
383 } else if stderr.contains("Could not read from remote") {
384 "Cannot reach remote - check connection/auth".to_string()
385 } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") {
386 "Pull has conflicts - resolve manually".to_string()
387 } else if stderr.contains("not a git repository") {
388 "Not in a git repository".to_string()
389 } else {
390 "Pull failed".to_string()
391 };
392 Err(FussrError::Git(git2::Error::from_str(&msg)))
393 }
394 }
395
396 /// Push to remote (captures output to not corrupt TUI)
397 pub fn push(&self) -> Result<()> {
398 let output = Command::new("git")
399 .args(["push"])
400 .stdout(std::process::Stdio::piped())
401 .stderr(std::process::Stdio::piped())
402 .output()?;
403
404 if output.status.success() {
405 Ok(())
406 } else {
407 let stderr = String::from_utf8_lossy(&output.stderr);
408 // Detect common push errors and provide helpful messages
409 let msg = if stderr.contains("no upstream branch") || stderr.contains("has no upstream") {
410 format!("No upstream set. Run: git push -u origin {}", self.branch_name())
411 } else if stderr.contains("does not appear to be a git repository") {
412 "Remote not found. Check your remote config".to_string()
413 } else if stderr.contains("rejected") {
414 "Push rejected - pull first or force push".to_string()
415 } else if stderr.contains("Could not read from remote") {
416 "Cannot reach remote - check connection/auth".to_string()
417 } else {
418 "Push failed".to_string()
419 };
420 Err(FussrError::Git(git2::Error::from_str(&msg)))
421 }
422 }
423
424 /// Check if current branch has an upstream configured
425 pub fn has_upstream(&self) -> bool {
426 let output = Command::new("git")
427 .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
428 .stdout(std::process::Stdio::piped())
429 .stderr(std::process::Stdio::piped())
430 .output();
431
432 output.map(|o| o.status.success()).unwrap_or(false)
433 }
434
435 /// Get list of remote names
436 pub fn get_remotes(&self) -> Vec<String> {
437 let output = Command::new("git")
438 .args(["remote"])
439 .stdout(std::process::Stdio::piped())
440 .stderr(std::process::Stdio::piped())
441 .output();
442
443 match output {
444 Ok(o) if o.status.success() => {
445 String::from_utf8_lossy(&o.stdout)
446 .lines()
447 .map(|s| s.trim().to_string())
448 .filter(|s| !s.is_empty())
449 .collect()
450 }
451 _ => Vec::new(),
452 }
453 }
454
455 /// Push with upstream set (git push -u <remote> <branch>)
456 pub fn push_with_upstream(&self, remote: &str) -> Result<()> {
457 let branch = self.branch_name();
458 let output = Command::new("git")
459 .args(["push", "-u", remote, &branch])
460 .stdout(std::process::Stdio::piped())
461 .stderr(std::process::Stdio::piped())
462 .output()?;
463
464 if output.status.success() {
465 Ok(())
466 } else {
467 let stderr = String::from_utf8_lossy(&output.stderr);
468 let msg = if stderr.contains("Could not read from remote") {
469 format!("Cannot reach '{}' - check connection/auth", remote)
470 } else if stderr.contains("does not appear to be a git repository") {
471 format!("Remote '{}' not found", remote)
472 } else if stderr.contains("rejected") {
473 "Push rejected - pull first".to_string()
474 } else {
475 format!("Push to '{}' failed", remote)
476 };
477 Err(FussrError::Git(git2::Error::from_str(&msg)))
478 }
479 }
480
481 /// Pull from a specific remote/branch (and set upstream)
482 pub fn pull_from_remote(&self, remote: &str) -> Result<()> {
483 let branch = self.branch_name();
484
485 // First set upstream tracking
486 let _ = Command::new("git")
487 .args(["branch", "--set-upstream-to", &format!("{}/{}", remote, branch)])
488 .stdout(std::process::Stdio::piped())
489 .stderr(std::process::Stdio::piped())
490 .output();
491
492 // Then pull
493 let output = Command::new("git")
494 .args(["pull", remote, &branch])
495 .stdout(std::process::Stdio::piped())
496 .stderr(std::process::Stdio::piped())
497 .output()?;
498
499 if output.status.success() {
500 Ok(())
501 } else {
502 let stderr = String::from_utf8_lossy(&output.stderr);
503 let msg = if stderr.contains("Could not read from remote") {
504 format!("Cannot reach '{}' - check connection/auth", remote)
505 } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") {
506 "Pull has conflicts - resolve manually".to_string()
507 } else if stderr.contains("does not appear to be a git repository") {
508 format!("Remote '{}' not found", remote)
509 } else {
510 format!("Pull from '{}' failed", remote)
511 };
512 Err(FussrError::Git(git2::Error::from_str(&msg)))
513 }
514 }
515
516 /// Get diff for a file
517 pub fn diff_file(&self, path: &Path, has_incoming: bool) -> Result<String> {
518 let path_str = path.to_string_lossy();
519 let output = if has_incoming {
520 Command::new("git")
521 .args(["diff", "HEAD...@{upstream}", "--", &path_str])
522 .output()?
523 } else {
524 Command::new("git")
525 .args(["diff", "HEAD", "--", &path_str])
526 .output()?
527 };
528
529 Ok(String::from_utf8_lossy(&output.stdout).to_string())
530 }
531
532 /// Rename a file
533 pub fn rename_file(&self, old_path: &Path, new_path: &Path) -> Result<()> {
534 let status = Command::new("mv")
535 .args(["-f", &old_path.to_string_lossy(), &new_path.to_string_lossy()])
536 .status()?;
537
538 if status.success() {
539 Ok(())
540 } else {
541 Err(FussrError::Git(git2::Error::from_str("Failed to rename file")))
542 }
543 }
544
545 /// Fetch tags from remote
546 pub fn fetch_tags(&self) -> Result<()> {
547 let output = Command::new("git")
548 .args(["fetch", "--tags", "--quiet"])
549 .stdout(std::process::Stdio::piped())
550 .stderr(std::process::Stdio::piped())
551 .output()?;
552
553 if output.status.success() {
554 Ok(())
555 } else {
556 // Silently ignore fetch errors - tags list will still work
557 Ok(())
558 }
559 }
560
561 /// Get list of existing tags (sorted by version, newest first)
562 pub fn get_tags(&self) -> Vec<String> {
563 let output = Command::new("git")
564 .args(["tag", "--sort=-version:refname"])
565 .stdout(std::process::Stdio::piped())
566 .stderr(std::process::Stdio::piped())
567 .output();
568
569 match output {
570 Ok(o) if o.status.success() => {
571 String::from_utf8_lossy(&o.stdout)
572 .lines()
573 .take(10) // Only show last 10 tags
574 .map(|s| s.trim().to_string())
575 .filter(|s| !s.is_empty())
576 .collect()
577 }
578 _ => Vec::new(),
579 }
580 }
581
582 /// Create a new tag
583 pub fn create_tag(&self, name: &str, message: &str) -> Result<()> {
584 let output = if message.is_empty() {
585 // Lightweight tag
586 Command::new("git")
587 .args(["tag", name])
588 .stdout(std::process::Stdio::piped())
589 .stderr(std::process::Stdio::piped())
590 .output()?
591 } else {
592 // Annotated tag with message
593 Command::new("git")
594 .args(["tag", "-a", name, "-m", message])
595 .stdout(std::process::Stdio::piped())
596 .stderr(std::process::Stdio::piped())
597 .output()?
598 };
599
600 if output.status.success() {
601 Ok(())
602 } else {
603 let stderr = String::from_utf8_lossy(&output.stderr);
604 let msg = if stderr.contains("already exists") {
605 format!("Tag '{}' already exists", name)
606 } else {
607 format!("Failed to create tag '{}'", name)
608 };
609 Err(FussrError::Git(git2::Error::from_str(&msg)))
610 }
611 }
612
613 /// Push a tag to origin
614 pub fn push_tag(&self, name: &str) -> Result<()> {
615 let output = Command::new("git")
616 .args(["push", "origin", name])
617 .stdout(std::process::Stdio::piped())
618 .stderr(std::process::Stdio::piped())
619 .output()?;
620
621 if output.status.success() {
622 Ok(())
623 } else {
624 let stderr = String::from_utf8_lossy(&output.stderr);
625 let msg = if stderr.contains("Could not read from remote") {
626 "Cannot reach origin - check connection/auth".to_string()
627 } else {
628 format!("Failed to push tag '{}'", name)
629 };
630 Err(FussrError::Git(git2::Error::from_str(&msg)))
631 }
632 }
633 }
634