omni_dev/git/
repository.rs1use crate::git::CommitInfo;
4use anyhow::{Context, Result};
5use git2::{Repository, Status};
6
7pub struct GitRepository {
9 repo: Repository,
10}
11
12#[derive(Debug)]
14pub struct WorkingDirectoryStatus {
15 pub clean: bool,
17 pub untracked_changes: Vec<FileStatus>,
19}
20
21#[derive(Debug)]
23pub struct FileStatus {
24 pub status: String,
26 pub file: String,
28}
29
30impl GitRepository {
31 pub fn open() -> Result<Self> {
33 let repo = Repository::open(".").context("Not in a git repository")?;
34
35 Ok(Self { repo })
36 }
37
38 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 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 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 pub fn is_working_directory_clean(&self) -> Result<bool> {
82 let status = self.get_working_directory_status()?;
83 Ok(status.clean)
84 }
85
86 pub fn path(&self) -> &std::path::Path {
88 self.repo.path()
89 }
90
91 pub fn workdir(&self) -> Option<&std::path::Path> {
93 self.repo.workdir()
94 }
95
96 pub fn repository(&self) -> &Repository {
98 &self.repo
99 }
100
101 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 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
116 if self
118 .repo
119 .find_branch(branch_name, git2::BranchType::Local)
120 .is_ok()
121 {
122 return Ok(true);
123 }
124
125 if self
127 .repo
128 .find_branch(branch_name, git2::BranchType::Remote)
129 .is_ok()
130 {
131 return Ok(true);
132 }
133
134 if self.repo.revparse_single(branch_name).is_ok() {
136 return Ok(true);
137 }
138
139 Ok(false)
140 }
141
142 pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
144 let mut commits = Vec::new();
145
146 if range == "HEAD" {
147 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 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 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 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 if commit.parent_count() > 1 {
198 continue;
199 }
200
201 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
202 }
203
204 commits.reverse();
206 } else {
207 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
222fn 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}