Skip to main content

vibe_workspace/workspace/
operations.rs

1use 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        // Repository name and path
28        let name = format!("{}", self.repository_name.cyan().bold());
29        let path = format!("({})", self.path.dimmed());
30        parts.push(format!("{name} {path}"));
31
32        // Branch information
33        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        // Status indicators
43        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
99/// Get comprehensive git status for a repository
100pub 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    // Open repository using git2
111    let repo = Repository::open(repo_path)
112        .with_context(|| format!("Failed to open git repository: {}", repo_path.display()))?;
113
114    // Get current branch
115    let branch = get_current_branch_name(&repo)?;
116
117    // Get remote URL
118    let remote_url = get_remote_url(&repo)?;
119
120    // Get working directory status
121    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    // Get ahead/behind information
161    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
179/// Execute a git command in the specified repository
180pub 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
210/// Get the current branch name
211fn 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
224/// Get remote URL for the repository
225fn 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
241/// Get ahead/behind counts compared to upstream
242fn 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    // Get local branch reference
249    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    // Try to find upstream branch
256    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    // Calculate ahead/behind
266    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        // Create an untracked file
322        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}