use crate::vcs::commands::{check_vcs_available, run_vcs_command, run_vcs_command_ignore_error};
use crate::vcs::{VcsBackend, VcsError, VcsResult};
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use tracing::debug;
pub async fn run_git<P: AsRef<Path>>(args: &[&str], cwd: P) -> VcsResult<String> {
run_vcs_command("git", args, cwd, VcsBackend::Git).await
}
#[allow(dead_code)]
pub async fn run_git_silent<P: AsRef<Path>>(args: &[&str], cwd: P) -> VcsResult<()> {
crate::vcs::commands::run_vcs_command_silent("git", args, cwd, VcsBackend::Git).await
}
#[allow(dead_code)]
pub async fn run_git_ignore_error<P: AsRef<Path>>(args: &[&str], cwd: P) {
run_vcs_command_ignore_error("git", args, cwd).await;
}
#[allow(dead_code)]
pub async fn check_git_repo<P: AsRef<Path>>(cwd: P) -> VcsResult<bool> {
if !check_vcs_available("git", cwd.as_ref()).await? {
return Ok(false);
}
debug!(
module = module_path!(),
"Executing git command: git rev-parse --git-dir (cwd: {:?})",
cwd.as_ref()
);
let root_result = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(cwd.as_ref())
.stdin(Stdio::null())
.output()
.await;
match root_result {
Ok(out) if out.status.success() => Ok(true),
_ => Ok(false),
}
}
pub async fn has_uncommitted_changes<P: AsRef<Path>>(cwd: P) -> VcsResult<(bool, String)> {
let output = run_git(&["status", "--porcelain"], cwd).await?;
let has_changes = !output.is_empty();
Ok((has_changes, output))
}
pub async fn push_same_named_branch<P: AsRef<Path>>(
remote: &str,
branch: &str,
cwd: P,
) -> VcsResult<String> {
if remote.contains(':') || branch.contains(':') {
return Err(VcsError::git_command(
"branch selection is not supported for push mode",
));
}
let refspec = format!("{branch}:{branch}");
run_git(&["push", remote, &refspec], cwd).await
}
pub async fn get_current_commit<P: AsRef<Path>>(cwd: P) -> VcsResult<String> {
run_git(&["rev-parse", "HEAD"], cwd).await
}
pub async fn get_changed_files<P: AsRef<Path>>(
cwd: P,
from_commit: Option<&str>,
to_commit: &str,
) -> VcsResult<Vec<String>> {
let output = if let Some(from) = from_commit {
run_git(
&["diff", "--name-only", &format!("{}..{}", from, to_commit)],
cwd,
)
.await?
} else {
run_git(&["ls-tree", "--name-only", "-r", to_commit], cwd).await?
};
Ok(output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect())
}
pub async fn is_head_empty_commit<P: AsRef<Path>>(cwd: P) -> VcsResult<bool> {
let output = run_git(
&[
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"--root",
"HEAD",
],
cwd,
)
.await?;
Ok(output.trim().is_empty())
}
pub async fn get_current_branch<P: AsRef<Path>>(cwd: P) -> VcsResult<Option<String>> {
let branch = run_git(&["rev-parse", "--abbrev-ref", "HEAD"], cwd).await?;
if branch == "HEAD" {
Ok(None)
} else {
Ok(Some(branch))
}
}
pub async fn get_status<P: AsRef<Path>>(cwd: P) -> VcsResult<String> {
run_git(&["status"], cwd).await
}
pub async fn get_conflict_files<P: AsRef<Path>>(cwd: P) -> VcsResult<Vec<String>> {
let output = run_git(&["diff", "--name-only", "--diff-filter=U"], cwd).await?;
Ok(output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect())
}
pub async fn is_working_directory_clean<P: AsRef<Path>>(cwd: P) -> VcsResult<bool> {
let output = run_git(&["status", "--porcelain"], cwd).await?;
Ok(output.trim().is_empty())
}
pub async fn checkout<P: AsRef<Path>>(cwd: P, branch_or_commit: &str) -> VcsResult<()> {
run_git(&["checkout", branch_or_commit], cwd).await?;
Ok(())
}
pub async fn branch_delete<P: AsRef<Path>>(cwd: P, branch_name: &str) -> VcsResult<()> {
debug!("Deleting branch {}", branch_name);
run_git(&["branch", "-D", branch_name], cwd).await?;
Ok(())
}
pub async fn branch_exists<P: AsRef<Path>>(cwd: P, branch_name: &str) -> VcsResult<bool> {
let output = Command::new("git")
.args([
"show-ref",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.current_dir(cwd.as_ref())
.stdin(Stdio::null())
.output()
.await
.map_err(|e| VcsError::git_command(format!("Failed to check branch existence: {}", e)))?;
Ok(output.status.success())
}
pub async fn generate_unique_branch_name<P: AsRef<Path>>(
cwd: P,
prefix: &str,
max_attempts: u32,
) -> VcsResult<String> {
use rand::Rng;
for _ in 0..max_attempts {
let random_suffix: String = (0..6)
.map(|_| format!("{:x}", rand::thread_rng().gen_range(0..16)))
.collect();
let branch_name = format!("{}-{}", prefix, random_suffix);
if !branch_exists(&cwd, &branch_name).await? {
return Ok(branch_name);
}
debug!("Branch '{}' already exists, retrying...", branch_name);
}
Err(VcsError::git_command(format!(
"Failed to generate unique branch name after {} attempts",
max_attempts
)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::TempDir;
#[tokio::test]
async fn test_check_git_repo_non_repo() {
let temp_dir = TempDir::new().unwrap();
let result = check_git_repo(temp_dir.path()).await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_check_git_repo_initialized() {
let temp_dir = TempDir::new().unwrap();
debug!(
module = module_path!(),
"Executing git command: git init (cwd: {:?})",
temp_dir.path()
);
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return;
}
let result = check_git_repo(temp_dir.path()).await;
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn test_has_uncommitted_changes_clean() {
let temp_dir = TempDir::new().unwrap();
debug!(
module = module_path!(),
"Executing git command: git init (cwd: {:?})",
temp_dir.path()
);
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return;
}
debug!(
module = module_path!(),
"Executing git command: git config user.email test@example.com (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
debug!(
module = module_path!(),
"Executing git command: git config user.name Test User (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
debug!(
module = module_path!(),
"Executing git command: git add . (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
debug!(
module = module_path!(),
"Executing git command: git commit -m initial (cwd: {:?})",
temp_dir.path()
);
let _ = Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(temp_dir.path())
.output()
.await;
let result = has_uncommitted_changes(temp_dir.path()).await;
assert!(result.is_ok());
let (has_changes, _) = result.unwrap();
assert!(!has_changes);
}
#[tokio::test]
async fn test_has_uncommitted_changes_dirty() {
let temp_dir = TempDir::new().unwrap();
debug!(
module = module_path!(),
"Executing git command: git init (cwd: {:?})",
temp_dir.path()
);
let init_result = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.await;
if init_result.is_err() {
return;
}
std::fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let result = has_uncommitted_changes(temp_dir.path()).await;
assert!(result.is_ok());
let (has_changes, output) = result.unwrap();
assert!(has_changes);
assert!(output.contains("test.txt"));
}
async fn run_git_ok(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.await
.expect("git command should start");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn push_post_archive_push_same_named_branch_to_bare_remote() {
let repo = TempDir::new().unwrap();
let remote = TempDir::new().unwrap();
run_git_ok(remote.path(), &["init", "--bare"]).await;
run_git_ok(repo.path(), &["init", "-b", "main"]).await;
run_git_ok(repo.path(), &["config", "user.email", "test@example.com"]).await;
run_git_ok(repo.path(), &["config", "user.name", "Test User"]).await;
std::fs::write(repo.path().join("README.md"), "base\n").unwrap();
run_git_ok(repo.path(), &["add", "."]).await;
run_git_ok(repo.path(), &["commit", "-m", "base"]).await;
run_git_ok(repo.path(), &["checkout", "-b", "change-a"]).await;
std::fs::write(repo.path().join("feature.txt"), "feature\n").unwrap();
run_git_ok(repo.path(), &["add", "."]).await;
run_git_ok(repo.path(), &["commit", "-m", "feature"]).await;
run_git_ok(
repo.path(),
&[
"remote",
"add",
"origin",
remote.path().to_str().expect("remote path utf8"),
],
)
.await;
push_same_named_branch("origin", "change-a", repo.path())
.await
.expect("push same named branch");
let remote_head = run_git(&["rev-parse", "refs/heads/change-a"], remote.path())
.await
.expect("remote branch head");
let local_head = run_git(&["rev-parse", "change-a"], repo.path())
.await
.expect("local branch head");
assert_eq!(remote_head, local_head);
}
#[tokio::test]
async fn push_post_archive_rejects_branch_selection_syntax() {
let repo = TempDir::new().unwrap();
let error = push_same_named_branch("origin:main", "change-a", repo.path())
.await
.expect_err("remote override must fail");
assert!(error
.to_string()
.contains("branch selection is not supported"));
let error = push_same_named_branch("origin", "change-a:main", repo.path())
.await
.expect_err("branch override must fail");
assert!(error
.to_string()
.contains("branch selection is not supported"));
}
#[tokio::test]
async fn test_generate_unique_branch_name_oso_session() {
let temp_dir = TempDir::new().unwrap();
let init = Command::new("git")
.args(["init", "-b", "main"])
.current_dir(temp_dir.path())
.output()
.await;
if init.is_err() {
return; }
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.await;
std::fs::write(temp_dir.path().join("README.md"), "test").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await;
let _ = Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.await;
let branch_name = generate_unique_branch_name(temp_dir.path(), "oso-session", 10)
.await
.unwrap();
assert!(branch_name.starts_with("oso-session-"));
let suffix = &branch_name["oso-session-".len()..];
assert_eq!(suffix.len(), 6);
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
let exists = branch_exists(temp_dir.path(), &branch_name).await.unwrap();
assert!(!exists);
let _ = Command::new("git")
.args(["branch", &branch_name])
.current_dir(temp_dir.path())
.output()
.await;
let branch_name2 = generate_unique_branch_name(temp_dir.path(), "oso-session", 10)
.await
.unwrap();
assert_ne!(branch_name, branch_name2);
}
}