gestura_core_tools/
git.rs

1//! Git repository operations tool
2//!
3//! Provides git operations with structured output.
4
5use crate::error::{AppError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10/// Git repository status
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GitStatus {
13    pub branch: String,
14    pub is_clean: bool,
15    pub staged: Vec<FileChange>,
16    pub unstaged: Vec<FileChange>,
17    pub untracked: Vec<PathBuf>,
18}
19
20/// A file change in git
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileChange {
23    pub path: PathBuf,
24    pub status: ChangeStatus,
25}
26
27/// Type of change
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub enum ChangeStatus {
30    Added,
31    Modified,
32    Deleted,
33    Renamed,
34    Copied,
35    Unknown,
36}
37
38/// Git diff result
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct GitDiff {
41    pub files: Vec<FileDiff>,
42    pub stats: DiffStats,
43}
44
45/// Diff for a single file
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FileDiff {
48    pub path: PathBuf,
49    pub hunks: Vec<DiffHunk>,
50}
51
52/// A hunk in a diff
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DiffHunk {
55    pub header: String,
56    pub lines: Vec<DiffLine>,
57}
58
59/// A line in a diff
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DiffLine {
62    pub line_type: DiffLineType,
63    pub content: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub enum DiffLineType {
68    Context,
69    Added,
70    Removed,
71}
72
73/// Diff statistics
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DiffStats {
76    pub files_changed: usize,
77    pub insertions: usize,
78    pub deletions: usize,
79}
80
81/// Git commit info
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct CommitInfo {
84    pub hash: String,
85    pub short_hash: String,
86    pub author: String,
87    pub date: String,
88    pub message: String,
89}
90
91/// Git branch info
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct BranchInfo {
94    pub name: String,
95    pub is_current: bool,
96    pub is_remote: bool,
97    pub upstream: Option<String>,
98}
99
100/// Information about a git worktree.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct GitWorktreeInfo {
103    /// Filesystem path of the worktree.
104    pub path: PathBuf,
105    /// Branch name associated with the worktree, when attached.
106    pub branch: Option<String>,
107    /// HEAD commit for the worktree.
108    pub head: Option<String>,
109    /// Whether the worktree is bare.
110    pub is_bare: bool,
111    /// Whether the worktree is detached.
112    pub is_detached: bool,
113}
114
115/// Git operations service
116pub struct GitTools {
117    work_dir: Option<PathBuf>,
118}
119
120impl Default for GitTools {
121    fn default() -> Self {
122        Self::new(None)
123    }
124}
125
126impl GitTools {
127    pub fn new(work_dir: Option<PathBuf>) -> Self {
128        Self { work_dir }
129    }
130
131    /// Set working directory
132    pub fn with_work_dir(mut self, dir: PathBuf) -> Self {
133        self.work_dir = Some(dir);
134        self
135    }
136
137    fn run_git(&self, args: &[&str]) -> Result<String> {
138        self.run_git_in(self.work_dir.as_deref(), args)
139    }
140
141    fn run_git_in(&self, dir: Option<&Path>, args: &[&str]) -> Result<String> {
142        let mut cmd = Command::new("git");
143        if let Some(dir) = dir {
144            cmd.current_dir(dir);
145        }
146        cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
147
148        let output = cmd.output().map_err(AppError::Io)?;
149
150        if output.status.success() {
151            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
152        } else {
153            Err(AppError::Io(std::io::Error::other(
154                String::from_utf8_lossy(&output.stderr).to_string(),
155            )))
156        }
157    }
158
159    fn run_git_status_only(&self, dir: Option<&Path>, args: &[&str]) -> Result<bool> {
160        let mut cmd = Command::new("git");
161        if let Some(dir) = dir {
162            cmd.current_dir(dir);
163        }
164        let status = cmd.args(args).status().map_err(AppError::Io)?;
165        Ok(status.success())
166    }
167
168    /// Resolve the repository top-level path.
169    pub fn rev_parse_toplevel(&self) -> Result<PathBuf> {
170        self.run_git(&["rev-parse", "--show-toplevel"])
171            .map(PathBuf::from)
172    }
173
174    /// Return the current branch name.
175    pub fn current_branch(&self) -> Result<String> {
176        self.run_git(&["branch", "--show-current"])
177    }
178
179    /// Check whether a local branch exists.
180    pub fn branch_exists(&self, branch: &str) -> Result<bool> {
181        self.run_git_status_only(
182            self.work_dir.as_deref(),
183            &[
184                "show-ref",
185                "--verify",
186                "--quiet",
187                &format!("refs/heads/{branch}"),
188            ],
189        )
190    }
191
192    /// Check whether the current working directory is inside a git repository.
193    pub fn path_is_git_repo(&self) -> Result<bool> {
194        self.run_git_status_only(
195            self.work_dir.as_deref(),
196            &["rev-parse", "--is-inside-work-tree"],
197        )
198    }
199
200    /// List all worktrees for the repository.
201    pub fn worktree_list(&self) -> Result<Vec<GitWorktreeInfo>> {
202        let output = self.run_git(&["worktree", "list", "--porcelain"])?;
203        parse_worktree_list(&output)
204    }
205
206    /// Create a worktree for an existing or new branch.
207    pub fn worktree_add(
208        &self,
209        path: &Path,
210        branch: &str,
211        base_branch: &str,
212        create_branch: bool,
213    ) -> Result<GitWorktreeInfo> {
214        let path_str = path.to_string_lossy().to_string();
215        let branch_exists = self.branch_exists(branch)?;
216
217        let mut args: Vec<&str> = vec!["worktree", "add"];
218        if create_branch && !branch_exists {
219            args.push("-b");
220            args.push(branch);
221            args.push(&path_str);
222            args.push(base_branch);
223        } else {
224            args.push(&path_str);
225            args.push(branch);
226        }
227
228        self.run_git(&args)?;
229        let desired_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
230        self.worktree_list()?
231            .into_iter()
232            .find(|info| {
233                info.path == desired_path
234                    || info.path.canonicalize().ok().as_ref() == Some(&desired_path)
235            })
236            .ok_or_else(|| {
237                AppError::Io(std::io::Error::other(
238                    "Created worktree was not discoverable",
239                ))
240            })
241    }
242
243    /// Remove a worktree path.
244    pub fn worktree_remove(&self, path: &Path, force: bool) -> Result<()> {
245        let path_str = path.to_string_lossy().to_string();
246        if force {
247            self.run_git(&["worktree", "remove", "--force", &path_str])?;
248        } else {
249            self.run_git(&["worktree", "remove", &path_str])?;
250        }
251        Ok(())
252    }
253
254    /// Prune stale worktree metadata.
255    pub fn worktree_prune(&self) -> Result<()> {
256        self.run_git(&["worktree", "prune"])?;
257        Ok(())
258    }
259
260    /// Check whether a worktree has uncommitted changes.
261    pub fn is_worktree_clean(&self, path: &Path) -> Result<bool> {
262        let status = self.run_git_in(Some(path), &["status", "--porcelain"])?;
263        Ok(status.trim().is_empty())
264    }
265
266    /// Get repository status
267    pub fn status(&self) -> Result<GitStatus> {
268        let branch = self.run_git(&["branch", "--show-current"])?;
269        let porcelain = self.run_git(&["status", "--porcelain"])?;
270
271        let mut staged = Vec::new();
272        let mut unstaged = Vec::new();
273        let mut untracked = Vec::new();
274
275        for line in porcelain.lines() {
276            if line.len() < 3 {
277                continue;
278            }
279            let index_status = line.chars().next().unwrap_or(' ');
280            let worktree_status = line.chars().nth(1).unwrap_or(' ');
281            let path = PathBuf::from(&line[3..]);
282
283            if index_status == '?' {
284                untracked.push(path);
285            } else {
286                if index_status != ' ' {
287                    staged.push(FileChange {
288                        path: path.clone(),
289                        status: parse_status(index_status),
290                    });
291                }
292                if worktree_status != ' ' {
293                    unstaged.push(FileChange {
294                        path,
295                        status: parse_status(worktree_status),
296                    });
297                }
298            }
299        }
300
301        Ok(GitStatus {
302            branch,
303            is_clean: staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
304            staged,
305            unstaged,
306            untracked,
307        })
308    }
309
310    /// Get diff (staged or unstaged)
311    pub fn diff(&self, staged: bool, path: Option<&Path>) -> Result<String> {
312        let mut args = vec!["diff"];
313        if staged {
314            args.push("--staged");
315        }
316        if let Some(p) = path {
317            args.push("--");
318            let path_str = p.to_string_lossy();
319            // We need to handle the lifetime properly
320            return self.run_git(
321                &[
322                    "diff",
323                    if staged { "--staged" } else { "" },
324                    "--",
325                    &path_str,
326                ]
327                .into_iter()
328                .filter(|s| !s.is_empty())
329                .collect::<Vec<_>>(),
330            );
331        }
332        self.run_git(&args)
333    }
334
335    /// Get commit log
336    pub fn log(&self, limit: Option<usize>, path: Option<&Path>) -> Result<Vec<CommitInfo>> {
337        let limit_str = limit.unwrap_or(10).to_string();
338        let format = "--format=%H|%h|%an|%ai|%s";
339
340        let output = if let Some(p) = path {
341            self.run_git(&["log", "-n", &limit_str, format, "--", &p.to_string_lossy()])?
342        } else {
343            self.run_git(&["log", "-n", &limit_str, format])?
344        };
345
346        let commits = output
347            .lines()
348            .filter_map(|line| {
349                let parts: Vec<&str> = line.splitn(5, '|').collect();
350                if parts.len() >= 5 {
351                    Some(CommitInfo {
352                        hash: parts[0].to_string(),
353                        short_hash: parts[1].to_string(),
354                        author: parts[2].to_string(),
355                        date: parts[3].to_string(),
356                        message: parts[4].to_string(),
357                    })
358                } else {
359                    None
360                }
361            })
362            .collect();
363
364        Ok(commits)
365    }
366
367    /// Create a commit
368    pub fn commit(&self, message: &str, all: bool) -> Result<CommitInfo> {
369        if all {
370            self.run_git(&["add", "-A"])?;
371        }
372
373        self.run_git(&["commit", "-m", message])?;
374
375        // Get the commit we just made
376        let commits = self.log(Some(1), None)?;
377        commits
378            .into_iter()
379            .next()
380            .ok_or_else(|| AppError::Io(std::io::Error::other("Failed to get commit info")))
381    }
382
383    /// Undo last commit (soft reset)
384    pub fn undo(&self, soft: bool) -> Result<String> {
385        let flag = if soft { "--soft" } else { "--mixed" };
386        self.run_git(&["reset", flag, "HEAD~1"])
387    }
388
389    /// List branches
390    pub fn branches(&self, all: bool) -> Result<Vec<BranchInfo>> {
391        let args = if all {
392            vec!["branch", "-a"]
393        } else {
394            vec!["branch"]
395        };
396        let output = self.run_git(&args)?;
397
398        let branches = output
399            .lines()
400            .map(|line| {
401                let is_current = line.starts_with('*');
402                let name = line.trim_start_matches(['*', ' ']).to_string();
403                let is_remote = name.starts_with("remotes/");
404                BranchInfo {
405                    name,
406                    is_current,
407                    is_remote,
408                    upstream: None,
409                }
410            })
411            .collect();
412
413        Ok(branches)
414    }
415
416    /// Checkout branch or file
417    pub fn checkout(&self, target: &str, create: bool) -> Result<String> {
418        if create {
419            self.run_git(&["checkout", "-b", target])
420        } else {
421            self.run_git(&["checkout", target])
422        }
423    }
424
425    /// Stash changes
426    pub fn stash(&self, pop: bool, message: Option<&str>) -> Result<String> {
427        if pop {
428            self.run_git(&["stash", "pop"])
429        } else if let Some(msg) = message {
430            self.run_git(&["stash", "push", "-m", msg])
431        } else {
432            self.run_git(&["stash"])
433        }
434    }
435
436    /// Get blame for file
437    pub fn blame(&self, path: &Path, line_range: Option<(usize, usize)>) -> Result<String> {
438        if let Some((start, end)) = line_range {
439            self.run_git(&[
440                "blame",
441                "-L",
442                &format!("{},{}", start, end),
443                &path.to_string_lossy(),
444            ])
445        } else {
446            self.run_git(&["blame", &path.to_string_lossy()])
447        }
448    }
449}
450
451fn parse_status(c: char) -> ChangeStatus {
452    match c {
453        'A' => ChangeStatus::Added,
454        'M' => ChangeStatus::Modified,
455        'D' => ChangeStatus::Deleted,
456        'R' => ChangeStatus::Renamed,
457        'C' => ChangeStatus::Copied,
458        _ => ChangeStatus::Unknown,
459    }
460}
461
462fn parse_worktree_list(output: &str) -> Result<Vec<GitWorktreeInfo>> {
463    let mut worktrees = Vec::new();
464    let mut current_path: Option<PathBuf> = None;
465    let mut current_branch: Option<String> = None;
466    let mut current_head: Option<String> = None;
467    let mut is_bare = false;
468    let mut is_detached = false;
469
470    let flush = |worktrees: &mut Vec<GitWorktreeInfo>,
471                 current_path: &mut Option<PathBuf>,
472                 current_branch: &mut Option<String>,
473                 current_head: &mut Option<String>,
474                 is_bare: &mut bool,
475                 is_detached: &mut bool| {
476        if let Some(path) = current_path.take() {
477            worktrees.push(GitWorktreeInfo {
478                path,
479                branch: current_branch.take(),
480                head: current_head.take(),
481                is_bare: *is_bare,
482                is_detached: *is_detached,
483            });
484        }
485        *is_bare = false;
486        *is_detached = false;
487    };
488
489    for line in output.lines() {
490        if line.trim().is_empty() {
491            flush(
492                &mut worktrees,
493                &mut current_path,
494                &mut current_branch,
495                &mut current_head,
496                &mut is_bare,
497                &mut is_detached,
498            );
499            continue;
500        }
501
502        if let Some(value) = line.strip_prefix("worktree ") {
503            flush(
504                &mut worktrees,
505                &mut current_path,
506                &mut current_branch,
507                &mut current_head,
508                &mut is_bare,
509                &mut is_detached,
510            );
511            current_path = Some(PathBuf::from(value.trim()));
512        } else if let Some(value) = line.strip_prefix("HEAD ") {
513            current_head = Some(value.trim().to_string());
514        } else if let Some(value) = line.strip_prefix("branch refs/heads/") {
515            current_branch = Some(value.trim().to_string());
516        } else if line.trim() == "bare" {
517            is_bare = true;
518        } else if line.trim() == "detached" {
519            is_detached = true;
520        }
521    }
522
523    flush(
524        &mut worktrees,
525        &mut current_path,
526        &mut current_branch,
527        &mut current_head,
528        &mut is_bare,
529        &mut is_detached,
530    );
531
532    if worktrees.is_empty() && !output.trim().is_empty() {
533        return Err(AppError::Io(std::io::Error::other(
534            "Failed to parse git worktree list output",
535        )));
536    }
537
538    Ok(worktrees)
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    #[cfg(not(target_os = "windows"))]
545    use std::fs;
546    #[cfg(not(target_os = "windows"))]
547    use tempfile::tempdir;
548
549    #[cfg(not(target_os = "windows"))]
550    fn init_test_repo() -> tempfile::TempDir {
551        let temp = tempdir().unwrap();
552        let repo = temp.path();
553
554        let git = GitTools::new(Some(repo.to_path_buf()));
555        git.run_git(&["init", "-b", "main"]).unwrap();
556        git.run_git(&["config", "user.email", "test@example.com"])
557            .unwrap();
558        git.run_git(&["config", "user.name", "Test User"]).unwrap();
559        fs::write(repo.join("README.md"), "hello\n").unwrap();
560        git.run_git(&["add", "README.md"]).unwrap();
561        git.run_git(&["commit", "-m", "initial"]).unwrap();
562        temp
563    }
564
565    #[test]
566    fn test_parse_status() {
567        assert!(matches!(parse_status('A'), ChangeStatus::Added));
568        assert!(matches!(parse_status('M'), ChangeStatus::Modified));
569        assert!(matches!(parse_status('D'), ChangeStatus::Deleted));
570        assert!(matches!(parse_status('R'), ChangeStatus::Renamed));
571        assert!(matches!(parse_status('C'), ChangeStatus::Copied));
572        assert!(matches!(parse_status('X'), ChangeStatus::Unknown));
573    }
574
575    #[test]
576    fn test_git_tools_new() {
577        let tools = GitTools::new(None);
578        assert!(tools.work_dir.is_none());
579    }
580
581    #[test]
582    fn test_git_tools_with_work_dir() {
583        let tools = GitTools::new(Some(PathBuf::from("/tmp/test")));
584        assert_eq!(tools.work_dir, Some(PathBuf::from("/tmp/test")));
585    }
586
587    #[test]
588    fn test_status_in_git_repo() {
589        // This test runs in the gestura-app repo
590        let tools = GitTools::new(None);
591        let status = tools.status();
592        // Should succeed since we're in a git repo
593        assert!(status.is_ok());
594        // Branch may be empty in detached HEAD state (e.g. CI shallow clones)
595    }
596
597    #[test]
598    fn test_log_in_git_repo() {
599        let tools = GitTools::new(None);
600        let log = tools.log(Some(5), None);
601        assert!(log.is_ok());
602        let log = log.unwrap();
603        assert!(!log.is_empty());
604    }
605
606    #[test]
607    fn test_branches() {
608        let tools = GitTools::new(None);
609        let branches = tools.branches(false);
610        assert!(branches.is_ok());
611        let branches = branches.unwrap();
612        // Should have at least one branch
613        assert!(!branches.is_empty());
614    }
615
616    #[test]
617    fn test_parse_worktree_list() {
618        let parsed = parse_worktree_list(
619            "worktree /tmp/repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /tmp/repo-feature\nHEAD def456\nbranch refs/heads/feature\n\n",
620        )
621        .unwrap();
622        assert_eq!(parsed.len(), 2);
623        assert_eq!(parsed[0].branch.as_deref(), Some("main"));
624        assert_eq!(parsed[1].branch.as_deref(), Some("feature"));
625    }
626
627    #[test]
628    #[cfg(not(target_os = "windows"))]
629    fn test_worktree_lifecycle() {
630        let temp = init_test_repo();
631        let worktree_parent = tempdir().unwrap();
632        let repo = temp.path();
633        let tools = GitTools::new(Some(repo.to_path_buf()));
634        let worktree_path = worktree_parent.path().join("feature-worktree");
635
636        let info = tools
637            .worktree_add(&worktree_path, "gestura/test-feature", "main", true)
638            .unwrap();
639        let normalized_worktree_path = worktree_path.canonicalize().unwrap();
640        assert_eq!(info.path, normalized_worktree_path);
641        assert_eq!(info.branch.as_deref(), Some("gestura/test-feature"));
642        assert!(
643            tools
644                .worktree_list()
645                .unwrap()
646                .iter()
647                .any(|entry| entry.path == normalized_worktree_path)
648        );
649        assert!(tools.is_worktree_clean(&worktree_path).unwrap());
650
651        fs::write(worktree_path.join("README.md"), "changed\n").unwrap();
652        assert!(!tools.is_worktree_clean(&worktree_path).unwrap());
653
654        tools.worktree_remove(&worktree_path, true).unwrap();
655        tools.worktree_prune().unwrap();
656        assert!(!worktree_path.exists());
657    }
658}