generated from nhcarrigan/template
b3d79a82ef
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
879 lines
28 KiB
Rust
879 lines
28 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::process::Command;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitStatus {
|
|
pub is_repo: bool,
|
|
pub branch: Option<String>,
|
|
pub upstream: Option<String>,
|
|
pub ahead: u32,
|
|
pub behind: u32,
|
|
pub staged: Vec<GitFileChange>,
|
|
pub unstaged: Vec<GitFileChange>,
|
|
pub untracked: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitFileChange {
|
|
pub path: String,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitBranch {
|
|
pub name: String,
|
|
pub is_current: bool,
|
|
pub is_remote: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GitLogEntry {
|
|
pub hash: String,
|
|
pub short_hash: String,
|
|
pub author: String,
|
|
pub date: String,
|
|
pub message: String,
|
|
}
|
|
|
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(working_dir)
|
|
.output()
|
|
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
|
// Check if it's a git repo
|
|
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
|
|
|
if !is_repo {
|
|
return Ok(GitStatus {
|
|
is_repo: false,
|
|
branch: None,
|
|
upstream: None,
|
|
ahead: 0,
|
|
behind: 0,
|
|
staged: vec![],
|
|
unstaged: vec![],
|
|
untracked: vec![],
|
|
});
|
|
}
|
|
|
|
// Get current branch
|
|
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.ok()
|
|
.map(|s| s.trim().to_string());
|
|
|
|
// Get upstream branch
|
|
let upstream = run_git_command(
|
|
&working_dir,
|
|
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
)
|
|
.ok()
|
|
.map(|s| s.trim().to_string());
|
|
|
|
// Get ahead/behind counts
|
|
let (ahead, behind) = if upstream.is_some() {
|
|
let rev_list =
|
|
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
|
.unwrap_or_default();
|
|
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
|
if parts.len() == 2 {
|
|
(
|
|
parts[1].parse().unwrap_or(0),
|
|
parts[0].parse().unwrap_or(0),
|
|
)
|
|
} else {
|
|
(0, 0)
|
|
}
|
|
} else {
|
|
(0, 0)
|
|
};
|
|
|
|
// Get status with porcelain format
|
|
let status_output =
|
|
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
|
|
|
let mut staged = vec![];
|
|
let mut unstaged = vec![];
|
|
let mut untracked = vec![];
|
|
|
|
for line in status_output.lines() {
|
|
if line.len() < 3 {
|
|
continue;
|
|
}
|
|
|
|
let index_status = line.chars().next().unwrap_or(' ');
|
|
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
|
let path = line[3..].to_string();
|
|
|
|
// Untracked files
|
|
if index_status == '?' && worktree_status == '?' {
|
|
untracked.push(path);
|
|
continue;
|
|
}
|
|
|
|
// Staged changes (index status)
|
|
if index_status != ' ' && index_status != '?' {
|
|
staged.push(GitFileChange {
|
|
path: path.clone(),
|
|
status: match index_status {
|
|
'M' => "modified".to_string(),
|
|
'A' => "added".to_string(),
|
|
'D' => "deleted".to_string(),
|
|
'R' => "renamed".to_string(),
|
|
'C' => "copied".to_string(),
|
|
_ => "unknown".to_string(),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Unstaged changes (worktree status)
|
|
if worktree_status != ' ' && worktree_status != '?' {
|
|
unstaged.push(GitFileChange {
|
|
path,
|
|
status: match worktree_status {
|
|
'M' => "modified".to_string(),
|
|
'D' => "deleted".to_string(),
|
|
_ => "unknown".to_string(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(GitStatus {
|
|
is_repo: true,
|
|
branch,
|
|
upstream,
|
|
ahead,
|
|
behind,
|
|
staged,
|
|
unstaged,
|
|
untracked,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
|
let mut args = vec!["diff"];
|
|
|
|
if staged {
|
|
args.push("--cached");
|
|
}
|
|
|
|
if let Some(ref path) = file_path {
|
|
args.push("--");
|
|
args.push(path);
|
|
}
|
|
|
|
run_git_command(&working_dir, &args)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
|
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
|
|
|
let branches: Vec<GitBranch> = output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let name = parts[0].to_string();
|
|
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
|
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
|
|
|
Some(GitBranch {
|
|
name,
|
|
is_current,
|
|
is_remote,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(branches)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", &branch])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["add", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["add", "-A"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["commit", "-m", &message])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_push(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["push"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["pull"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["fetch", "--all"])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
|
let limit_str = limit.unwrap_or(10).to_string();
|
|
let output = run_git_command(
|
|
&working_dir,
|
|
&[
|
|
"log",
|
|
&format!("-{}", limit_str),
|
|
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
|
],
|
|
)?;
|
|
|
|
let entries: Vec<GitLogEntry> = output
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let parts: Vec<&str> = line.split('\t').collect();
|
|
if parts.len() < 5 {
|
|
return None;
|
|
}
|
|
|
|
Some(GitLogEntry {
|
|
hash: parts[0].to_string(),
|
|
short_hash: parts[1].to_string(),
|
|
author: parts[2].to_string(),
|
|
date: parts[3].to_string(),
|
|
message: parts[4..].join("\t"),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
|
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs::{self, File};
|
|
use std::io::Write;
|
|
use tempfile::TempDir;
|
|
|
|
// Helper to create a git repository in a temp directory
|
|
fn create_test_repo() -> TempDir {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initialize git repo
|
|
run_git_command(&working_dir, &["init"]).unwrap();
|
|
|
|
// Configure git user for commits
|
|
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
|
|
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
|
|
|
|
// Disable GPG signing for tests (user may have it enabled globally)
|
|
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
|
|
|
|
temp_dir
|
|
}
|
|
|
|
// Helper to create a file in the test repo
|
|
fn create_file(dir: &TempDir, name: &str, content: &str) {
|
|
let file_path = dir.path().join(name);
|
|
let mut file = File::create(file_path).unwrap();
|
|
file.write_all(content.as_bytes()).unwrap();
|
|
}
|
|
|
|
// ==================== GitStatus struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_status_serialization() {
|
|
let status = GitStatus {
|
|
is_repo: true,
|
|
branch: Some("main".to_string()),
|
|
upstream: Some("origin/main".to_string()),
|
|
ahead: 2,
|
|
behind: 1,
|
|
staged: vec![GitFileChange {
|
|
path: "file.txt".to_string(),
|
|
status: "modified".to_string(),
|
|
}],
|
|
unstaged: vec![],
|
|
untracked: vec!["new_file.txt".to_string()],
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(json.contains("\"is_repo\":true"));
|
|
assert!(json.contains("\"branch\":\"main\""));
|
|
assert!(json.contains("\"ahead\":2"));
|
|
assert!(json.contains("\"behind\":1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_not_a_repo() {
|
|
let status = GitStatus {
|
|
is_repo: false,
|
|
branch: None,
|
|
upstream: None,
|
|
ahead: 0,
|
|
behind: 0,
|
|
staged: vec![],
|
|
unstaged: vec![],
|
|
untracked: vec![],
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
|
|
assert!(!deserialized.is_repo);
|
|
assert!(deserialized.branch.is_none());
|
|
}
|
|
|
|
// ==================== GitFileChange struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_file_change_serialization() {
|
|
let change = GitFileChange {
|
|
path: "src/main.rs".to_string(),
|
|
status: "added".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&change).unwrap();
|
|
assert!(json.contains("src/main.rs"));
|
|
assert!(json.contains("added"));
|
|
|
|
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.path, "src/main.rs");
|
|
assert_eq!(deserialized.status, "added");
|
|
}
|
|
|
|
// ==================== GitBranch struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_branch_serialization() {
|
|
let branch = GitBranch {
|
|
name: "feature/new-feature".to_string(),
|
|
is_current: true,
|
|
is_remote: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&branch).unwrap();
|
|
assert!(json.contains("feature/new-feature"));
|
|
assert!(json.contains("\"is_current\":true"));
|
|
assert!(json.contains("\"is_remote\":false"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_branch_remote() {
|
|
let branch = GitBranch {
|
|
name: "origin/main".to_string(),
|
|
is_current: false,
|
|
is_remote: true,
|
|
};
|
|
|
|
let json = serde_json::to_string(&branch).unwrap();
|
|
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
|
|
assert!(deserialized.is_remote);
|
|
assert!(!deserialized.is_current);
|
|
}
|
|
|
|
// ==================== GitLogEntry struct tests ====================
|
|
|
|
#[test]
|
|
fn test_git_log_entry_serialization() {
|
|
let entry = GitLogEntry {
|
|
hash: "abc123def456".to_string(),
|
|
short_hash: "abc123d".to_string(),
|
|
author: "Hikari".to_string(),
|
|
date: "2 hours ago".to_string(),
|
|
message: "feat: add new feature".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("abc123def456"));
|
|
assert!(json.contains("Hikari"));
|
|
assert!(json.contains("feat: add new feature"));
|
|
}
|
|
|
|
// ==================== git_status integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_status_not_a_git_repo() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(!status.is_repo);
|
|
assert!(status.branch.is_none());
|
|
assert!(status.staged.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_empty_repo() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(status.staged.is_empty());
|
|
assert!(status.unstaged.is_empty());
|
|
assert!(status.untracked.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_untracked_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create an untracked file
|
|
create_file(&temp_dir, "untracked.txt", "hello");
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(status.untracked.contains(&"untracked.txt".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_staged_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and stage a file
|
|
create_file(&temp_dir, "staged.txt", "hello");
|
|
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(!status.staged.is_empty());
|
|
assert_eq!(status.staged[0].path, "staged.txt");
|
|
assert_eq!(status.staged[0].status, "added");
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_status_with_modified_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create, stage, and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
let result = git_status(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let status = result.unwrap();
|
|
assert!(status.is_repo);
|
|
assert!(!status.unstaged.is_empty());
|
|
assert_eq!(status.unstaged[0].path, "file.txt");
|
|
assert_eq!(status.unstaged[0].status, "modified");
|
|
}
|
|
|
|
// ==================== git_diff integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_diff_no_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_diff(working_dir, None, false);
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_with_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
let result = git_diff(working_dir, None, false);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("diff"));
|
|
assert!(diff.contains("file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_staged() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify and stage the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_diff(working_dir, None, true);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("diff"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_diff_specific_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit files
|
|
create_file(&temp_dir, "file1.txt", "content1");
|
|
create_file(&temp_dir, "file2.txt", "content2");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify both files
|
|
create_file(&temp_dir, "file1.txt", "modified1");
|
|
create_file(&temp_dir, "file2.txt", "modified2");
|
|
|
|
// Get diff for only file1.txt
|
|
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
|
|
assert!(result.is_ok());
|
|
let diff = result.unwrap();
|
|
assert!(diff.contains("file1.txt"));
|
|
assert!(!diff.contains("file2.txt"));
|
|
}
|
|
|
|
// ==================== git_branches integration tests ====================
|
|
|
|
#[test]
|
|
fn test_git_branches_single_branch() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Need at least one commit for branches to show
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
let result = git_branches(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let branches = result.unwrap();
|
|
assert!(!branches.is_empty());
|
|
// Should have at least one branch (main or master)
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_branches_multiple_branches() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Create additional branch
|
|
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
|
|
|
|
let result = git_branches(working_dir);
|
|
assert!(result.is_ok());
|
|
|
|
let branches = result.unwrap();
|
|
assert!(branches.len() >= 2);
|
|
assert!(branches.iter().any(|b| b.name == "feature-branch"));
|
|
}
|
|
|
|
// ==================== git_stage and git_unstage tests ====================
|
|
|
|
#[test]
|
|
fn test_git_stage_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
|
|
let result = git_stage(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file is staged
|
|
let status = git_status(working_dir).unwrap();
|
|
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_unstage_file() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// First, commit a file so we have a HEAD to restore from
|
|
create_file(&temp_dir, "file.txt", "initial content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify and stage the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file is unstaged (should now be in unstaged/modified, not staged)
|
|
let status = git_status(working_dir).unwrap();
|
|
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
|
|
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_stage_all() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file1.txt", "content1");
|
|
create_file(&temp_dir, "file2.txt", "content2");
|
|
|
|
let result = git_stage_all(working_dir.clone());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify all files are staged
|
|
let status = git_status(working_dir).unwrap();
|
|
assert_eq!(status.staged.len(), 2);
|
|
}
|
|
|
|
// ==================== git_commit tests ====================
|
|
|
|
#[test]
|
|
fn test_git_commit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
|
|
let result = git_commit(working_dir.clone(), "test commit message".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify commit was made
|
|
let log = git_log(working_dir, Some(1)).unwrap();
|
|
assert!(!log.is_empty());
|
|
assert!(log[0].message.contains("test commit message"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_commit_nothing_to_commit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Need initial commit first
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Try to commit with nothing staged
|
|
let result = git_commit(working_dir, "empty commit".to_string());
|
|
assert!(result.is_err()); // Should fail because nothing to commit
|
|
}
|
|
|
|
// ==================== git_log tests ====================
|
|
|
|
#[test]
|
|
fn test_git_log_empty_repo() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = git_log(working_dir, Some(10));
|
|
// May fail on empty repo or return empty
|
|
if let Ok(commits) = result {
|
|
assert!(commits.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_log_with_commits() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Make multiple commits
|
|
for i in 1..=3 {
|
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
|
}
|
|
|
|
let result = git_log(working_dir, Some(10));
|
|
assert!(result.is_ok());
|
|
|
|
let log = result.unwrap();
|
|
assert_eq!(log.len(), 3);
|
|
assert!(log[0].message.contains("commit 3")); // Most recent first
|
|
assert!(log[2].message.contains("commit 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_git_log_limit() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Make 5 commits
|
|
for i in 1..=5 {
|
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
|
}
|
|
|
|
// Only get last 2
|
|
let result = git_log(working_dir, Some(2));
|
|
assert!(result.is_ok());
|
|
|
|
let log = result.unwrap();
|
|
assert_eq!(log.len(), 2);
|
|
}
|
|
|
|
// ==================== git_discard tests ====================
|
|
|
|
#[test]
|
|
fn test_git_discard_changes() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Create and commit a file
|
|
create_file(&temp_dir, "file.txt", "original content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Modify the file
|
|
create_file(&temp_dir, "file.txt", "modified content");
|
|
|
|
// Discard changes
|
|
let result = git_discard(working_dir.clone(), "file.txt".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify file contents are restored
|
|
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
|
|
assert_eq!(content, "original content");
|
|
}
|
|
|
|
// ==================== git_create_branch tests ====================
|
|
|
|
#[test]
|
|
fn test_git_create_branch() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit required
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify branch exists and is current
|
|
let branches = git_branches(working_dir).unwrap();
|
|
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
|
|
}
|
|
|
|
// ==================== git_checkout tests ====================
|
|
|
|
#[test]
|
|
fn test_git_checkout() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// Initial commit required
|
|
create_file(&temp_dir, "file.txt", "content");
|
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
|
|
|
// Create a branch
|
|
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
|
|
|
|
// Checkout the branch
|
|
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
|
|
assert!(result.is_ok());
|
|
|
|
// Verify current branch
|
|
let branches = git_branches(working_dir).unwrap();
|
|
let current = branches.iter().find(|b| b.is_current);
|
|
assert!(current.is_some());
|
|
assert_eq!(current.unwrap().name, "other-branch");
|
|
}
|
|
|
|
// ==================== run_git_command tests ====================
|
|
|
|
#[test]
|
|
fn test_run_git_command_success() {
|
|
let temp_dir = create_test_repo();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
let result = run_git_command(&working_dir, &["status"]);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_git_command_failure() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
|
|
|
// This should fail because it's not a git repo
|
|
let result = run_git_command(&working_dir, &["log"]);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_git_command_invalid_dir() {
|
|
let result = run_git_command("/nonexistent/path", &["status"]);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|