use std::{ fs, io::Write, path::Path, process::{Command, Stdio}, }; use crate::error::{AppError, AppResult}; #[derive(Debug, Clone)] pub struct CompareCommit { pub id: String, pub summary: String, pub author_name: String, pub author_email: String, } #[derive(Debug, Clone)] pub struct CompareFile { pub path: String, pub additions: i64, pub deletions: i64, } #[derive(Debug, Clone)] pub struct CompareResult { pub merge_base: String, pub head_commit_id: String, pub commits: Vec, pub files: Vec, } pub fn init_bare_repo_with_binary( git_binary: &str, repo_path: &Path, default_branch: &str, ) -> AppResult<()> { if let Some(parent) = repo_path.parent() { fs::create_dir_all(parent)?; } run(Command::new(git_binary) .arg("init") .arg("--bare") .arg(repo_path))?; run(Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("symbolic-ref") .arg("HEAD") .arg(format!("refs/heads/{default_branch}")))?; run(Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("update-server-info"))?; Ok(()) } pub fn create_initial_commit_with_binary( git_binary: &str, repo_path: &Path, default_branch: &str, readme: &str, author_name: &str, author_email: &str, ) -> AppResult<()> { let worktree = repo_path.with_extension("work"); if worktree.exists() { fs::remove_dir_all(&worktree)?; } fs::create_dir_all(&worktree)?; fs::write(worktree.join("README.md"), readme)?; run(Command::new(git_binary) .arg("init") .arg("--initial-branch") .arg(default_branch) .arg(&worktree))?; run(Command::new(git_binary) .current_dir(&worktree) .env("GIT_AUTHOR_NAME", author_name) .env("GIT_AUTHOR_EMAIL", author_email) .env("GIT_COMMITTER_NAME", author_name) .env("GIT_COMMITTER_EMAIL", author_email) .arg("add") .arg("README.md"))?; run(Command::new(git_binary) .current_dir(&worktree) .env("GIT_AUTHOR_NAME", author_name) .env("GIT_AUTHOR_EMAIL", author_email) .env("GIT_COMMITTER_NAME", author_name) .env("GIT_COMMITTER_EMAIL", author_email) .arg("commit") .arg("-m") .arg("Initial commit"))?; run(Command::new(git_binary) .current_dir(&worktree) .arg("remote") .arg("add") .arg("origin") .arg(repo_path))?; run(Command::new(git_binary) .current_dir(&worktree) .arg("push") .arg("origin") .arg(default_branch))?; fs::remove_dir_all(&worktree)?; Ok(()) } pub fn clone_bare_repo_with_binary( git_binary: &str, source_repo_path: &Path, target_repo_path: &Path, ) -> AppResult<()> { if let Some(parent) = target_repo_path.parent() { fs::create_dir_all(parent)?; } run(Command::new(git_binary) .arg("clone") .arg("--bare") .arg(source_repo_path) .arg(target_repo_path))?; run(Command::new(git_binary) .arg("--git-dir") .arg(target_repo_path) .arg("update-server-info"))?; Ok(()) } pub fn list_branches_with_binary(git_binary: &str, repo_path: &Path) -> AppResult> { let output = Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("for-each-ref") .arg("--format=%(refname:short)") .arg("refs/heads") .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToOwned::to_owned) .collect()) } pub fn branch_exists_with_binary( git_binary: &str, repo_path: &Path, branch: &str, ) -> AppResult { let branches = list_branches_with_binary(git_binary, repo_path)?; Ok(branches.iter().any(|name| name == branch)) } pub fn merge_base_with_binary( git_binary: &str, base_repo_path: &Path, base_branch: &str, head_repo_path: &Path, head_branch: &str, ) -> AppResult { let temp = std::env::temp_dir().join(format!( "gitr-merge-base-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); run(Command::new(git_binary) .arg("clone") .arg("--bare") .arg(base_repo_path) .arg(&temp))?; let result = (|| -> AppResult { run(Command::new(git_binary) .arg("--git-dir") .arg(&temp) .arg("remote") .arg("add") .arg("head_repo") .arg(head_repo_path))?; run(Command::new(git_binary) .arg("--git-dir") .arg(&temp) .arg("fetch") .arg("head_repo") .arg(head_branch))?; let output = Command::new(git_binary) .arg("--git-dir") .arg(&temp) .arg("merge-base") .arg(format!("refs/heads/{base_branch}")) .arg("FETCH_HEAD") .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) })(); let _ = fs::remove_dir_all(&temp); result } pub fn test_merge_with_binary( git_binary: &str, base_repo_path: &Path, base_branch: &str, head_repo_path: &Path, head_branch: &str, ) -> AppResult { let temp = temp_git_path("gitr-test-merge"); run(Command::new(git_binary) .arg("clone") .arg("-b") .arg(base_branch) .arg(base_repo_path) .arg(&temp))?; let result = (|| -> AppResult { run(Command::new(git_binary) .current_dir(&temp) .arg("remote") .arg("add") .arg("head_repo") .arg(head_repo_path))?; run(Command::new(git_binary) .current_dir(&temp) .arg("fetch") .arg("head_repo") .arg(head_branch))?; let output = Command::new(git_binary) .current_dir(&temp) .arg("merge") .arg("--no-ff") .arg("--no-commit") .arg("FETCH_HEAD") .output() .map_err(|err| AppError::Git(err.to_string()))?; Ok(output.status.success()) })(); let _ = fs::remove_dir_all(&temp); result } pub fn merge_pull_request_with_binary( git_binary: &str, base_repo_path: &Path, base_branch: &str, head_repo_path: &Path, head_branch: &str, author_name: &str, author_email: &str, message: &str, ) -> AppResult { let temp = temp_git_path("gitr-merge-pr"); run(Command::new(git_binary) .arg("clone") .arg("-b") .arg(base_branch) .arg(base_repo_path) .arg(&temp))?; let result = (|| -> AppResult { run(Command::new(git_binary) .current_dir(&temp) .arg("remote") .arg("add") .arg("head_repo") .arg(head_repo_path))?; run(Command::new(git_binary) .current_dir(&temp) .arg("fetch") .arg("head_repo") .arg(head_branch))?; let head_commit_id = rev_parse_with_binary(git_binary, &temp.join(".git"), "FETCH_HEAD")?; let merge = Command::new(git_binary) .current_dir(&temp) .arg("merge") .arg("--no-ff") .arg("--no-commit") .arg("FETCH_HEAD") .output() .map_err(|err| AppError::Git(err.to_string()))?; if !merge.status.success() { let stderr = String::from_utf8_lossy(&merge.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&merge.stdout).trim().to_string(); let detail = if !stderr.is_empty() { stderr } else { stdout }; return Err(AppError::Conflict(format!( "pull request cannot be merged automatically: {detail}" ))); } run(Command::new(git_binary) .current_dir(&temp) .env("GIT_AUTHOR_NAME", author_name) .env("GIT_AUTHOR_EMAIL", author_email) .env("GIT_COMMITTER_NAME", author_name) .env("GIT_COMMITTER_EMAIL", author_email) .arg("commit") .arg("-m") .arg(message))?; run(Command::new(git_binary) .current_dir(&temp) .arg("push") .arg("origin") .arg(base_branch))?; run(Command::new(git_binary) .arg("--git-dir") .arg(base_repo_path) .arg("update-server-info"))?; Ok(head_commit_id) })(); let _ = fs::remove_dir_all(&temp); result } pub fn compare_refs_with_binary( git_binary: &str, base_repo_path: &Path, base_branch: &str, head_repo_path: &Path, head_branch: &str, ) -> AppResult { let temp = temp_git_path("gitr-compare-refs"); run(Command::new(git_binary) .arg("clone") .arg("--bare") .arg(base_repo_path) .arg(&temp))?; let result = (|| -> AppResult { run(Command::new(git_binary) .arg("--git-dir") .arg(&temp) .arg("remote") .arg("add") .arg("head_repo") .arg(head_repo_path))?; run(Command::new(git_binary) .arg("--git-dir") .arg(&temp) .arg("fetch") .arg("head_repo") .arg(head_branch))?; let merge_base = merge_base_for_repo(git_binary, &temp, base_branch, "FETCH_HEAD")?; let head_commit_id = rev_parse_with_binary(git_binary, &temp, "FETCH_HEAD")?; compare_rev_range_with_binary(git_binary, &temp, &merge_base, &head_commit_id) })(); let _ = fs::remove_dir_all(&temp); result } pub fn compare_rev_range_with_binary( git_binary: &str, repo_path: &Path, merge_base: &str, head_commit_id: &str, ) -> AppResult { let commits = list_commits_between_with_binary(git_binary, repo_path, merge_base, head_commit_id)?; let files = if merge_base == head_commit_id { Vec::new() } else { diff_numstat_with_binary(git_binary, repo_path, merge_base, head_commit_id)? }; Ok(CompareResult { merge_base: merge_base.to_string(), head_commit_id: head_commit_id.to_string(), commits, files, }) } pub fn rev_parse_with_binary(git_binary: &str, repo_path: &Path, rev: &str) -> AppResult { let output = Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("rev-parse") .arg(rev) .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn run(command: &mut Command) -> AppResult<()> { let output = command .output() .map_err(|err| AppError::Git(err.to_string()))?; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let message = if !stderr.is_empty() { stderr } else { stdout }; Err(AppError::Git(message)) } fn temp_git_path(prefix: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( "{prefix}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )) } fn merge_base_for_repo( git_binary: &str, repo_path: &Path, base_branch: &str, head_rev: &str, ) -> AppResult { let output = Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("merge-base") .arg(format!("refs/heads/{base_branch}")) .arg(head_rev) .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn list_commits_between_with_binary( git_binary: &str, repo_path: &Path, merge_base: &str, head_commit_id: &str, ) -> AppResult> { if merge_base == head_commit_id { return Ok(Vec::new()); } let output = Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("log") .arg("--reverse") .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1e") .arg(format!("{merge_base}..{head_commit_id}")) .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let mut commits = Vec::new(); for record in stdout.split('\x1e') { let record = record.trim(); if record.is_empty() { continue; } let mut fields = record.split('\x1f'); let id = fields.next().unwrap_or_default().to_string(); let summary = fields.next().unwrap_or_default().to_string(); let author_name = fields.next().unwrap_or_default().to_string(); let author_email = fields.next().unwrap_or_default().to_string(); commits.push(CompareCommit { id, summary, author_name, author_email, }); } Ok(commits) } fn diff_numstat_with_binary( git_binary: &str, repo_path: &Path, merge_base: &str, head_commit_id: &str, ) -> AppResult> { let output = Command::new(git_binary) .arg("--git-dir") .arg(repo_path) .arg("diff") .arg("--numstat") .arg(merge_base) .arg(head_commit_id) .output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); let mut files = Vec::new(); for line in stdout.lines() { if line.trim().is_empty() { continue; } let mut parts = line.splitn(3, '\t'); let additions = parse_numstat_value(parts.next().unwrap_or_default()); let deletions = parse_numstat_value(parts.next().unwrap_or_default()); let path = parts.next().unwrap_or_default().to_string(); files.push(CompareFile { path, additions, deletions, }); } Ok(files) } fn parse_numstat_value(value: &str) -> i64 { value.parse::().unwrap_or(0) } pub struct GitHttpBackendRequest<'a> { pub git_binary: &'a str, pub project_root: &'a Path, pub path_info: &'a str, pub method: &'a str, pub query_string: &'a str, pub content_type: Option<&'a str>, pub remote_user: Option<&'a str>, pub body: &'a [u8], } pub struct GitHttpBackendResponse { pub status_code: u16, pub headers: Vec<(String, String)>, pub body: Vec, } pub fn run_git_http_backend(req: GitHttpBackendRequest<'_>) -> AppResult { let mut command = Command::new(req.git_binary); command .arg("http-backend") .env("GIT_PROJECT_ROOT", req.project_root) .env("GIT_HTTP_EXPORT_ALL", "1") .env("PATH_INFO", req.path_info) .env("REQUEST_METHOD", req.method) .env("QUERY_STRING", req.query_string) .env("CONTENT_LENGTH", req.body.len().to_string()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(content_type) = req.content_type { command.env("CONTENT_TYPE", content_type); } if let Some(remote_user) = req.remote_user { command.env("REMOTE_USER", remote_user); } let mut child = command .spawn() .map_err(|err| AppError::Git(err.to_string()))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(req.body)?; } let output = child .wait_with_output() .map_err(|err| AppError::Git(err.to_string()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(AppError::Git(stderr)); } parse_git_http_backend_output(&output.stdout) } fn parse_git_http_backend_output(stdout: &[u8]) -> AppResult { let split = stdout .windows(4) .position(|w| w == b"\r\n\r\n") .map(|i| (i, 4)) .or_else(|| stdout.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2))) .ok_or_else(|| AppError::Git("invalid git-http-backend response".to_string()))?; let header_bytes = &stdout[..split.0]; let body = stdout[split.0 + split.1..].to_vec(); let header_text = String::from_utf8_lossy(header_bytes); let mut status_code = 200; let mut headers = Vec::new(); for line in header_text.lines() { if line.is_empty() { continue; } if let Some(value) = line.strip_prefix("Status:") { let code = value .split_whitespace() .next() .and_then(|v| v.parse::().ok()) .ok_or_else(|| AppError::Git(format!("invalid status line: {line}")))?; status_code = code; continue; } let (name, value) = line .split_once(':') .ok_or_else(|| AppError::Git(format!("invalid header line: {line}")))?; headers.push((name.trim().to_string(), value.trim().to_string())); } Ok(GitHttpBackendResponse { status_code, headers, body, }) }