omni_dev/git/
repository.rs

1//! Git repository operations
2
3use crate::git::CommitInfo;
4use anyhow::{Context, Result};
5use git2::{Repository, Status};
6
7/// Git repository wrapper
8pub struct GitRepository {
9    repo: Repository,
10}
11
12/// Working directory status
13#[derive(Debug)]
14pub struct WorkingDirectoryStatus {
15    /// Whether the working directory has no changes
16    pub clean: bool,
17    /// List of files with uncommitted changes
18    pub untracked_changes: Vec<FileStatus>,
19}
20
21/// File status information
22#[derive(Debug)]
23pub struct FileStatus {
24    /// Git status flags (e.g., "AM", "??", "M ")
25    pub status: String,
26    /// Path to the file relative to repository root
27    pub file: String,
28}
29
30impl GitRepository {
31    /// Open repository at current directory
32    pub fn open() -> Result<Self> {
33        let repo = Repository::open(".").context("Not in a git repository")?;
34
35        Ok(Self { repo })
36    }
37
38    /// Open repository at specified path
39    pub fn open_at<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
40        let repo = Repository::open(path).context("Failed to open git repository")?;
41
42        Ok(Self { repo })
43    }
44
45    /// Get working directory status
46    pub fn get_working_directory_status(&self) -> Result<WorkingDirectoryStatus> {
47        let statuses = self
48            .repo
49            .statuses(None)
50            .context("Failed to get repository status")?;
51
52        let mut untracked_changes = Vec::new();
53
54        for entry in statuses.iter() {
55            if let Some(path) = entry.path() {
56                let status_flags = entry.status();
57                let status_str = format_status_flags(status_flags);
58
59                untracked_changes.push(FileStatus {
60                    status: status_str,
61                    file: path.to_string(),
62                });
63            }
64        }
65
66        let clean = untracked_changes.is_empty();
67
68        Ok(WorkingDirectoryStatus {
69            clean,
70            untracked_changes,
71        })
72    }
73
74    /// Check if working directory is clean
75    pub fn is_working_directory_clean(&self) -> Result<bool> {
76        let status = self.get_working_directory_status()?;
77        Ok(status.clean)
78    }
79
80    /// Get repository path
81    pub fn path(&self) -> &std::path::Path {
82        self.repo.path()
83    }
84
85    /// Get workdir path
86    pub fn workdir(&self) -> Option<&std::path::Path> {
87        self.repo.workdir()
88    }
89
90    /// Get access to the underlying git2::Repository
91    pub fn repository(&self) -> &Repository {
92        &self.repo
93    }
94
95    /// Parse commit range and get commits
96    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
97        let mut commits = Vec::new();
98
99        if range == "HEAD" {
100            // Single HEAD commit
101            let head = self.repo.head().context("Failed to get HEAD")?;
102            let commit = head
103                .peel_to_commit()
104                .context("Failed to peel HEAD to commit")?;
105            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
106        } else if range.contains("..") {
107            // Range format like HEAD~3..HEAD
108            let parts: Vec<&str> = range.split("..").collect();
109            if parts.len() != 2 {
110                anyhow::bail!("Invalid range format: {}", range);
111            }
112
113            let start_spec = parts[0];
114            let end_spec = parts[1];
115
116            // Parse start and end commits
117            let start_obj = self
118                .repo
119                .revparse_single(start_spec)
120                .with_context(|| format!("Failed to parse start commit: {}", start_spec))?;
121            let end_obj = self
122                .repo
123                .revparse_single(end_spec)
124                .with_context(|| format!("Failed to parse end commit: {}", end_spec))?;
125
126            let start_commit = start_obj
127                .peel_to_commit()
128                .context("Failed to peel start object to commit")?;
129            let end_commit = end_obj
130                .peel_to_commit()
131                .context("Failed to peel end object to commit")?;
132
133            // Walk from end_commit back to start_commit (exclusive)
134            let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
135            walker
136                .push(end_commit.id())
137                .context("Failed to push end commit")?;
138            walker
139                .hide(start_commit.id())
140                .context("Failed to hide start commit")?;
141
142            for oid in walker {
143                let oid = oid.context("Failed to get commit OID from walker")?;
144                let commit = self
145                    .repo
146                    .find_commit(oid)
147                    .context("Failed to find commit")?;
148
149                // Skip merge commits
150                if commit.parent_count() > 1 {
151                    continue;
152                }
153
154                commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
155            }
156
157            // Reverse to get chronological order (oldest first)
158            commits.reverse();
159        } else {
160            // Single commit by hash or reference
161            let obj = self
162                .repo
163                .revparse_single(range)
164                .with_context(|| format!("Failed to parse commit: {}", range))?;
165            let commit = obj
166                .peel_to_commit()
167                .context("Failed to peel object to commit")?;
168            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
169        }
170
171        Ok(commits)
172    }
173}
174
175/// Format git status flags into string representation
176fn format_status_flags(flags: Status) -> String {
177    let mut status = String::new();
178
179    if flags.contains(Status::INDEX_NEW) {
180        status.push('A');
181    } else if flags.contains(Status::INDEX_MODIFIED) {
182        status.push('M');
183    } else if flags.contains(Status::INDEX_DELETED) {
184        status.push('D');
185    } else if flags.contains(Status::INDEX_RENAMED) {
186        status.push('R');
187    } else if flags.contains(Status::INDEX_TYPECHANGE) {
188        status.push('T');
189    } else {
190        status.push(' ');
191    }
192
193    if flags.contains(Status::WT_NEW) {
194        status.push('?');
195    } else if flags.contains(Status::WT_MODIFIED) {
196        status.push('M');
197    } else if flags.contains(Status::WT_DELETED) {
198        status.push('D');
199    } else if flags.contains(Status::WT_TYPECHANGE) {
200        status.push('T');
201    } else if flags.contains(Status::WT_RENAMED) {
202        status.push('R');
203    } else {
204        status.push(' ');
205    }
206
207    status
208}