Skip to content

Commit 7e563de

Browse files
committed
feat: add GitIntegration module for repository operations
1 parent 4bad05e commit 7e563de

File tree

1 file changed

+263
-0
lines changed

1 file changed

+263
-0
lines changed

crates/cli/src/git_integration.rs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
use anyhow::{anyhow, Result};
2+
use std::path::{Path, PathBuf};
3+
use std::process::Command;
4+
5+
/// Git integration utilities for Code-Guardian
6+
pub struct GitIntegration;
7+
8+
impl GitIntegration {
9+
/// Get list of staged files (files in git index)
10+
pub fn get_staged_files(repo_path: &Path) -> Result<Vec<PathBuf>> {
11+
let output = Command::new("git")
12+
.args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
13+
.current_dir(repo_path)
14+
.output()?;
15+
16+
if !output.status.success() {
17+
let stderr = String::from_utf8_lossy(&output.stderr);
18+
return Err(anyhow!("Git command failed: {}", stderr));
19+
}
20+
21+
let stdout = String::from_utf8_lossy(&output.stdout);
22+
let files: Vec<PathBuf> = stdout
23+
.lines()
24+
.filter(|line| !line.trim().is_empty())
25+
.map(|line| repo_path.join(line.trim()))
26+
.filter(|path| path.exists()) // Only include files that still exist
27+
.collect();
28+
29+
Ok(files)
30+
}
31+
32+
/// Get the root directory of the git repository
33+
pub fn get_repo_root(start_path: &Path) -> Result<PathBuf> {
34+
let output = Command::new("git")
35+
.args(["rev-parse", "--show-toplevel"])
36+
.current_dir(start_path)
37+
.output()?;
38+
39+
if !output.status.success() {
40+
return Err(anyhow!("Not in a git repository or git command failed"));
41+
}
42+
43+
let stdout = String::from_utf8_lossy(&output.stdout);
44+
let repo_root = stdout.trim();
45+
Ok(PathBuf::from(repo_root))
46+
}
47+
48+
/// Check if the current directory is in a git repository
49+
pub fn is_git_repo(path: &Path) -> bool {
50+
Command::new("git")
51+
.args(["rev-parse", "--git-dir"])
52+
.current_dir(path)
53+
.output()
54+
.map(|output| output.status.success())
55+
.unwrap_or(false)
56+
}
57+
58+
/// Get modified lines for staged files (useful for line-specific scanning)
59+
#[allow(dead_code)]
60+
pub fn get_staged_lines(repo_path: &Path) -> Result<Vec<StagedChange>> {
61+
let output = Command::new("git")
62+
.args(["diff", "--cached", "--unified=0"])
63+
.current_dir(repo_path)
64+
.output()?;
65+
66+
if !output.status.success() {
67+
let stderr = String::from_utf8_lossy(&output.stderr);
68+
return Err(anyhow!("Git diff command failed: {}", stderr));
69+
}
70+
71+
let stdout = String::from_utf8_lossy(&output.stdout);
72+
Ok(parse_git_diff(&stdout, repo_path))
73+
}
74+
75+
/// Install pre-commit hook for Code-Guardian
76+
pub fn install_pre_commit_hook(repo_path: &Path) -> Result<()> {
77+
let hooks_dir = repo_path.join(".git").join("hooks");
78+
let hook_path = hooks_dir.join("pre-commit");
79+
80+
// Create hooks directory if it doesn't exist
81+
std::fs::create_dir_all(&hooks_dir)?;
82+
83+
// Pre-commit hook script
84+
let hook_script = r#"#!/bin/sh
85+
# Code-Guardian pre-commit hook
86+
# This hook runs Code-Guardian on staged files before commit
87+
88+
# Check if code-guardian is available
89+
if ! command -v code-guardian >/dev/null 2>&1; then
90+
echo "Error: code-guardian not found in PATH"
91+
echo "Please install code-guardian or add it to your PATH"
92+
exit 1
93+
fi
94+
95+
# Run Code-Guardian pre-commit check
96+
exec code-guardian pre-commit --staged-only --fast
97+
"#;
98+
99+
std::fs::write(&hook_path, hook_script)?;
100+
101+
// Make the hook executable (Unix-like systems)
102+
#[cfg(unix)]
103+
{
104+
use std::os::unix::fs::PermissionsExt;
105+
let mut perms = std::fs::metadata(&hook_path)?.permissions();
106+
perms.set_mode(0o755);
107+
std::fs::set_permissions(&hook_path, perms)?;
108+
}
109+
110+
println!("✅ Pre-commit hook installed at: {}", hook_path.display());
111+
println!("🔧 The hook will run 'code-guardian pre-commit --staged-only --fast' before each commit");
112+
113+
Ok(())
114+
}
115+
116+
/// Uninstall pre-commit hook
117+
pub fn uninstall_pre_commit_hook(repo_path: &Path) -> Result<()> {
118+
let hook_path = repo_path.join(".git").join("hooks").join("pre-commit");
119+
120+
if hook_path.exists() {
121+
// Check if it's our hook before removing
122+
let content = std::fs::read_to_string(&hook_path)?;
123+
if content.contains("Code-Guardian pre-commit hook") {
124+
std::fs::remove_file(&hook_path)?;
125+
println!("✅ Code-Guardian pre-commit hook removed");
126+
} else {
127+
println!(
128+
"⚠️ Pre-commit hook exists but doesn't appear to be Code-Guardian's hook"
129+
);
130+
println!(" Manual removal required: {}", hook_path.display());
131+
}
132+
} else {
133+
println!("ℹ️ No pre-commit hook found");
134+
}
135+
136+
Ok(())
137+
}
138+
}
139+
140+
/// Represents a staged change in git
141+
#[allow(dead_code)]
142+
#[derive(Debug, Clone)]
143+
pub struct StagedChange {
144+
pub file_path: PathBuf,
145+
pub added_lines: Vec<LineRange>,
146+
pub removed_lines: Vec<LineRange>,
147+
}
148+
149+
/// Represents a range of lines
150+
#[allow(dead_code)]
151+
#[derive(Debug, Clone)]
152+
pub struct LineRange {
153+
pub start: usize,
154+
pub count: usize,
155+
}
156+
157+
/// Parse git diff output to extract staged changes
158+
#[allow(dead_code)]
159+
fn parse_git_diff(diff_output: &str, repo_path: &Path) -> Vec<StagedChange> {
160+
let mut changes = Vec::new();
161+
let mut current_file: Option<PathBuf> = None;
162+
let mut added_lines = Vec::new();
163+
let mut removed_lines = Vec::new();
164+
165+
for line in diff_output.lines() {
166+
if line.starts_with("diff --git") {
167+
// Save previous file's changes
168+
if let Some(file_path) = current_file.take() {
169+
changes.push(StagedChange {
170+
file_path,
171+
added_lines: std::mem::take(&mut added_lines),
172+
removed_lines: std::mem::take(&mut removed_lines),
173+
});
174+
}
175+
} else if line.starts_with("+++") {
176+
// Extract new file path
177+
if let Some(path_part) = line.strip_prefix("+++ b/") {
178+
current_file = Some(repo_path.join(path_part));
179+
}
180+
} else if line.starts_with("@@") {
181+
// Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
182+
if let Some(hunk_info) = line.strip_prefix("@@").and_then(|s| s.strip_suffix("@@")) {
183+
let parts: Vec<&str> = hunk_info.split_whitespace().collect();
184+
if parts.len() >= 2 {
185+
// Parse removed lines (-old_start,old_count)
186+
if let Some(removed_part) = parts[0].strip_prefix('-') {
187+
if let Some((start_str, count_str)) = removed_part.split_once(',') {
188+
if let (Ok(start), Ok(count)) =
189+
(start_str.parse::<usize>(), count_str.parse::<usize>())
190+
{
191+
if count > 0 {
192+
removed_lines.push(LineRange { start, count });
193+
}
194+
}
195+
}
196+
}
197+
198+
// Parse added lines (+new_start,new_count)
199+
if let Some(added_part) = parts[1].strip_prefix('+') {
200+
if let Some((start_str, count_str)) = added_part.split_once(',') {
201+
if let (Ok(start), Ok(count)) =
202+
(start_str.parse::<usize>(), count_str.parse::<usize>())
203+
{
204+
if count > 0 {
205+
added_lines.push(LineRange { start, count });
206+
}
207+
}
208+
}
209+
}
210+
}
211+
}
212+
}
213+
}
214+
215+
// Don't forget the last file
216+
if let Some(file_path) = current_file {
217+
changes.push(StagedChange {
218+
file_path,
219+
added_lines,
220+
removed_lines,
221+
});
222+
}
223+
224+
changes
225+
}
226+
227+
#[cfg(test)]
228+
mod tests {
229+
use super::*;
230+
use tempfile::TempDir;
231+
232+
#[test]
233+
fn test_git_integration_basic() {
234+
// Test that basic structures work
235+
let range = LineRange { start: 5, count: 3 };
236+
assert_eq!(range.start, 5);
237+
assert_eq!(range.count, 3);
238+
239+
// Test that we can create staged change
240+
let temp_dir = TempDir::new().unwrap();
241+
let change = StagedChange {
242+
file_path: temp_dir.path().join("test.rs"),
243+
added_lines: vec![range],
244+
removed_lines: vec![],
245+
};
246+
247+
assert_eq!(change.added_lines.len(), 1);
248+
assert_eq!(change.removed_lines.len(), 0);
249+
}
250+
251+
#[test]
252+
fn test_is_git_repo() {
253+
let temp_dir = TempDir::new().unwrap();
254+
assert!(!GitIntegration::is_git_repo(temp_dir.path()));
255+
}
256+
257+
#[test]
258+
fn test_line_range() {
259+
let range = LineRange { start: 5, count: 3 };
260+
assert_eq!(range.start, 5);
261+
assert_eq!(range.count, 3);
262+
}
263+
}

0 commit comments

Comments
 (0)