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
58                // Skip ignored files - they should not affect clean status
59                if status_flags.contains(Status::IGNORED) {
60                    continue;
61                }
62
63                let status_str = format_status_flags(status_flags);
64
65                untracked_changes.push(FileStatus {
66                    status: status_str,
67                    file: path.to_string(),
68                });
69            }
70        }
71
72        let clean = untracked_changes.is_empty();
73
74        Ok(WorkingDirectoryStatus {
75            clean,
76            untracked_changes,
77        })
78    }
79
80    /// Check if working directory is clean
81    pub fn is_working_directory_clean(&self) -> Result<bool> {
82        let status = self.get_working_directory_status()?;
83        Ok(status.clean)
84    }
85
86    /// Get repository path
87    pub fn path(&self) -> &std::path::Path {
88        self.repo.path()
89    }
90
91    /// Get workdir path
92    pub fn workdir(&self) -> Option<&std::path::Path> {
93        self.repo.workdir()
94    }
95
96    /// Get access to the underlying git2::Repository
97    pub fn repository(&self) -> &Repository {
98        &self.repo
99    }
100
101    /// Get current branch name
102    pub fn get_current_branch(&self) -> Result<String> {
103        let head = self.repo.head().context("Failed to get HEAD reference")?;
104
105        if let Some(name) = head.shorthand() {
106            if name != "HEAD" {
107                return Ok(name.to_string());
108            }
109        }
110
111        anyhow::bail!("Repository is in detached HEAD state")
112    }
113
114    /// Check if a branch exists
115    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
116        // Check if it exists as a local branch
117        if self
118            .repo
119            .find_branch(branch_name, git2::BranchType::Local)
120            .is_ok()
121        {
122            return Ok(true);
123        }
124
125        // Check if it exists as a remote branch
126        if self
127            .repo
128            .find_branch(branch_name, git2::BranchType::Remote)
129            .is_ok()
130        {
131            return Ok(true);
132        }
133
134        // Check if we can resolve it as a reference
135        if self.repo.revparse_single(branch_name).is_ok() {
136            return Ok(true);
137        }
138
139        Ok(false)
140    }
141
142    /// Parse commit range and get commits
143    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
144        let mut commits = Vec::new();
145
146        if range == "HEAD" {
147            // Single HEAD commit
148            let head = self.repo.head().context("Failed to get HEAD")?;
149            let commit = head
150                .peel_to_commit()
151                .context("Failed to peel HEAD to commit")?;
152            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
153        } else if range.contains("..") {
154            // Range format like HEAD~3..HEAD
155            let parts: Vec<&str> = range.split("..").collect();
156            if parts.len() != 2 {
157                anyhow::bail!("Invalid range format: {}", range);
158            }
159
160            let start_spec = parts[0];
161            let end_spec = parts[1];
162
163            // Parse start and end commits
164            let start_obj = self
165                .repo
166                .revparse_single(start_spec)
167                .with_context(|| format!("Failed to parse start commit: {}", start_spec))?;
168            let end_obj = self
169                .repo
170                .revparse_single(end_spec)
171                .with_context(|| format!("Failed to parse end commit: {}", end_spec))?;
172
173            let start_commit = start_obj
174                .peel_to_commit()
175                .context("Failed to peel start object to commit")?;
176            let end_commit = end_obj
177                .peel_to_commit()
178                .context("Failed to peel end object to commit")?;
179
180            // Walk from end_commit back to start_commit (exclusive)
181            let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
182            walker
183                .push(end_commit.id())
184                .context("Failed to push end commit")?;
185            walker
186                .hide(start_commit.id())
187                .context("Failed to hide start commit")?;
188
189            for oid in walker {
190                let oid = oid.context("Failed to get commit OID from walker")?;
191                let commit = self
192                    .repo
193                    .find_commit(oid)
194                    .context("Failed to find commit")?;
195
196                // Skip merge commits
197                if commit.parent_count() > 1 {
198                    continue;
199                }
200
201                commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
202            }
203
204            // Reverse to get chronological order (oldest first)
205            commits.reverse();
206        } else {
207            // Single commit by hash or reference
208            let obj = self
209                .repo
210                .revparse_single(range)
211                .with_context(|| format!("Failed to parse commit: {}", range))?;
212            let commit = obj
213                .peel_to_commit()
214                .context("Failed to peel object to commit")?;
215            commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
216        }
217
218        Ok(commits)
219    }
220}
221
222/// Format git status flags into string representation
223fn format_status_flags(flags: Status) -> String {
224    let mut status = String::new();
225
226    if flags.contains(Status::INDEX_NEW) {
227        status.push('A');
228    } else if flags.contains(Status::INDEX_MODIFIED) {
229        status.push('M');
230    } else if flags.contains(Status::INDEX_DELETED) {
231        status.push('D');
232    } else if flags.contains(Status::INDEX_RENAMED) {
233        status.push('R');
234    } else if flags.contains(Status::INDEX_TYPECHANGE) {
235        status.push('T');
236    } else {
237        status.push(' ');
238    }
239
240    if flags.contains(Status::WT_NEW) {
241        status.push('?');
242    } else if flags.contains(Status::WT_MODIFIED) {
243        status.push('M');
244    } else if flags.contains(Status::WT_DELETED) {
245        status.push('D');
246    } else if flags.contains(Status::WT_TYPECHANGE) {
247        status.push('T');
248    } else if flags.contains(Status::WT_RENAMED) {
249        status.push('R');
250    } else {
251        status.push(' ');
252    }
253
254    status
255}