vibe_workspace/workspace/
operations.rs1use anyhow::{Context, Result};
2use colored::*;
3use git2::{Repository, StatusOptions};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6use tokio::process::Command as AsyncCommand;
7use tracing::{debug, warn};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GitStatus {
11 pub repository_name: String,
12 pub path: String,
13 pub branch: Option<String>,
14 pub clean: bool,
15 pub ahead: usize,
16 pub behind: usize,
17 pub staged: usize,
18 pub unstaged: usize,
19 pub untracked: usize,
20 pub remote_url: Option<String>,
21}
22
23impl GitStatus {
24 pub fn format_status_line(&self) -> String {
25 let mut parts = Vec::new();
26
27 let name = format!("{}", self.repository_name.cyan().bold());
29 let path = format!("({})", self.path.dimmed());
30 parts.push(format!("{name} {path}"));
31
32 if let Some(ref branch) = self.branch {
34 let branch_display = if self.ahead > 0 || self.behind > 0 {
35 format!("{} [↑{} ↓{}]", branch, self.ahead, self.behind)
36 } else {
37 branch.clone()
38 };
39 parts.push(format!("on {}", branch_display.yellow()));
40 }
41
42 let mut status_parts = Vec::new();
44
45 if self.clean {
46 status_parts.push("✓".green().to_string());
47 } else {
48 if self.staged > 0 {
49 status_parts.push(format!("{}S", self.staged).green().to_string());
50 }
51 if self.unstaged > 0 {
52 status_parts.push(format!("{}M", self.unstaged).red().to_string());
53 }
54 if self.untracked > 0 {
55 status_parts.push(format!("{}?", self.untracked).yellow().to_string());
56 }
57 }
58
59 if !status_parts.is_empty() {
60 parts.push(format!("[{}]", status_parts.join(" ")));
61 }
62
63 parts.join(" ")
64 }
65
66 pub fn is_dirty(&self) -> bool {
67 !self.clean
68 }
69}
70
71#[derive(Debug, Clone)]
72pub enum GitOperation {
73 Status,
74 Pull,
75 Push,
76 Fetch,
77 Custom(String),
78}
79
80impl GitOperation {
81 pub async fn execute<P: AsRef<Path>>(&self, repo_path: P) -> Result<String> {
82 let repo_path = repo_path.as_ref();
83
84 match self {
85 GitOperation::Status => get_git_status(repo_path)
86 .await
87 .map(|_| "Status checked".to_string()),
88 GitOperation::Pull => execute_git_command(repo_path, &["pull"]).await,
89 GitOperation::Push => execute_git_command(repo_path, &["push"]).await,
90 GitOperation::Fetch => execute_git_command(repo_path, &["fetch"]).await,
91 GitOperation::Custom(command) => {
92 let args: Vec<&str> = command.split_whitespace().collect();
93 execute_git_command(repo_path, &args).await
94 }
95 }
96 }
97}
98
99pub async fn get_git_status<P: AsRef<Path>>(repo_path: P) -> Result<GitStatus> {
101 let repo_path = repo_path.as_ref();
102 let repo_name = repo_path
103 .file_name()
104 .and_then(|n| n.to_str())
105 .unwrap_or("unknown")
106 .to_string();
107
108 debug!("Getting git status for repository: {}", repo_path.display());
109
110 let repo = Repository::open(repo_path)
112 .with_context(|| format!("Failed to open git repository: {}", repo_path.display()))?;
113
114 let branch = get_current_branch_name(&repo)?;
116
117 let remote_url = get_remote_url(&repo)?;
119
120 let mut status_opts = StatusOptions::new();
122 status_opts.include_untracked(true);
123 status_opts.include_ignored(false);
124
125 let statuses = repo
126 .statuses(Some(&mut status_opts))
127 .context("Failed to get repository status")?;
128
129 let mut staged = 0;
130 let mut unstaged = 0;
131 let mut untracked = 0;
132
133 for entry in statuses.iter() {
134 let flags = entry.status();
135
136 if flags.intersects(
137 git2::Status::INDEX_NEW
138 | git2::Status::INDEX_MODIFIED
139 | git2::Status::INDEX_DELETED
140 | git2::Status::INDEX_RENAMED
141 | git2::Status::INDEX_TYPECHANGE,
142 ) {
143 staged += 1;
144 }
145
146 if flags.intersects(
147 git2::Status::WT_MODIFIED
148 | git2::Status::WT_DELETED
149 | git2::Status::WT_RENAMED
150 | git2::Status::WT_TYPECHANGE,
151 ) {
152 unstaged += 1;
153 }
154
155 if flags.contains(git2::Status::WT_NEW) {
156 untracked += 1;
157 }
158 }
159
160 let (ahead, behind) = get_ahead_behind_counts(&repo, branch.as_deref())?;
162
163 let clean = staged == 0 && unstaged == 0 && untracked == 0;
164
165 Ok(GitStatus {
166 repository_name: repo_name,
167 path: repo_path.display().to_string(),
168 branch,
169 clean,
170 ahead,
171 behind,
172 staged,
173 unstaged,
174 untracked,
175 remote_url,
176 })
177}
178
179pub async fn execute_git_command<P: AsRef<Path>>(repo_path: P, args: &[&str]) -> Result<String> {
181 let repo_path = repo_path.as_ref();
182
183 debug!(
184 "Executing git command in {}: git {}",
185 repo_path.display(),
186 args.join(" ")
187 );
188
189 let output = AsyncCommand::new("git")
190 .args(args)
191 .current_dir(repo_path)
192 .output()
193 .await
194 .with_context(|| format!("Failed to execute git command: git {}", args.join(" ")))?;
195
196 if !output.status.success() {
197 let stderr = String::from_utf8_lossy(&output.stderr);
198 return Err(anyhow::anyhow!(
199 "Git command failed: git {} (exit code: {})\n{}",
200 args.join(" "),
201 output.status.code().unwrap_or(-1),
202 stderr
203 ));
204 }
205
206 let stdout = String::from_utf8_lossy(&output.stdout);
207 Ok(stdout.trim().to_string())
208}
209
210fn get_current_branch_name(repo: &Repository) -> Result<Option<String>> {
212 let head = match repo.head() {
213 Ok(head) => head,
214 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
215 debug!("Repository has no commits yet");
216 return Ok(None);
217 }
218 Err(e) => return Err(e.into()),
219 };
220
221 Ok(head.shorthand().map(|s| s.to_string()))
222}
223
224fn get_remote_url(repo: &Repository) -> Result<Option<String>> {
226 let remote_name = match repo.find_remote("origin") {
227 Ok(_) => "origin".to_string(),
228 Err(_) => {
229 let remotes = repo.remotes()?;
230 match remotes.get(0) {
231 Some(name) => name.to_string(),
232 None => return Ok(None),
233 }
234 }
235 };
236
237 let remote = repo.find_remote(&remote_name)?;
238 Ok(remote.url().map(|url| url.to_string()))
239}
240
241fn get_ahead_behind_counts(repo: &Repository, branch_name: Option<&str>) -> Result<(usize, usize)> {
243 let branch_name = match branch_name {
244 Some(name) => name,
245 None => return Ok((0, 0)),
246 };
247
248 let local_ref = format!("refs/heads/{branch_name}");
250 let local_oid = match repo.resolve_reference_from_short_name(&local_ref) {
251 Ok(reference) => reference.target().unwrap_or_else(git2::Oid::zero),
252 Err(_) => return Ok((0, 0)),
253 };
254
255 let upstream_ref = format!("refs/remotes/origin/{branch_name}");
257 let upstream_oid = match repo.resolve_reference_from_short_name(&upstream_ref) {
258 Ok(reference) => reference.target().unwrap_or_else(git2::Oid::zero),
259 Err(_) => {
260 debug!("No upstream branch found for {}", branch_name);
261 return Ok((0, 0));
262 }
263 };
264
265 match repo.graph_ahead_behind(local_oid, upstream_oid) {
267 Ok((ahead, behind)) => Ok((ahead, behind)),
268 Err(e) => {
269 warn!(
270 "Failed to calculate ahead/behind for {}: {}",
271 branch_name, e
272 );
273 Ok((0, 0))
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::fs;
282 use std::process::Command;
283 use tempfile::TempDir;
284
285 fn init_test_repo(path: &Path) -> Result<()> {
286 Command::new("git")
287 .args(&["init"])
288 .current_dir(path)
289 .output()?;
290
291 Command::new("git")
292 .args(&["config", "user.name", "Test User"])
293 .current_dir(path)
294 .output()?;
295
296 Command::new("git")
297 .args(&["config", "user.email", "test@example.com"])
298 .current_dir(path)
299 .output()?;
300
301 Ok(())
302 }
303
304 #[tokio::test]
305 async fn test_git_status_empty_repo() {
306 let temp_dir = TempDir::new().unwrap();
307 init_test_repo(temp_dir.path()).unwrap();
308
309 let status = get_git_status(temp_dir.path()).await.unwrap();
310 assert!(status.clean);
311 assert_eq!(status.staged, 0);
312 assert_eq!(status.unstaged, 0);
313 assert_eq!(status.untracked, 0);
314 }
315
316 #[tokio::test]
317 async fn test_git_status_with_untracked_file() {
318 let temp_dir = TempDir::new().unwrap();
319 init_test_repo(temp_dir.path()).unwrap();
320
321 fs::write(temp_dir.path().join("test.txt"), "test content").unwrap();
323
324 let status = get_git_status(temp_dir.path()).await.unwrap();
325 assert!(!status.clean);
326 assert_eq!(status.untracked, 1);
327 }
328
329 #[tokio::test]
330 async fn test_execute_git_command() {
331 let temp_dir = TempDir::new().unwrap();
332 init_test_repo(temp_dir.path()).unwrap();
333
334 let result = execute_git_command(temp_dir.path(), &["status", "--porcelain"]).await;
335 assert!(result.is_ok());
336 }
337}