use super::SystemGit;
use super::system::CommitInfo;
use crate::error::{GitError, RailError, RailResult, ResultExt};
use crate::progress;
use crate::utils;
use std::path::{Path, PathBuf};
impl SystemGit {
fn normalize_path<'a>(&self, path: &'a Path) -> &'a Path {
if path.is_absolute() {
path.strip_prefix(&self.worktree_root).unwrap_or(path)
} else {
path
}
}
pub fn commit_history(&self, limit: Option<usize>) -> RailResult<Vec<CommitInfo>> {
let mut args = vec!["log", "--format=%H"];
let limit_str;
if let Some(max) = limit {
limit_str = format!("-{}", max);
args.push(&limit_str);
}
let output = self.run_git(&args)?;
let shas: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
self.get_commits_bulk(&shas)
}
pub fn get_changed_files(&self, commit_sha: &str) -> RailResult<Vec<(PathBuf, char)>> {
let output = self.run_git(&["diff-tree", "--no-commit-id", "--name-status", "-r", "-z", commit_sha])?;
parse_name_status_output_z(&output.stdout)
}
pub fn get_changed_files_between(&self, base_ref: &str, head_ref: Option<&str>) -> RailResult<Vec<(PathBuf, char)>> {
let mut args = vec!["diff", "--name-status", base_ref];
let head_owned;
if let Some(head) = head_ref {
head_owned = head.to_string();
args.push(&head_owned);
}
args.insert(2, "-z");
let output = self.run_git(&args)?;
parse_name_status_output_z(&output.stdout)
}
pub fn get_merge_base(&self, ref1: &str, ref2: &str) -> RailResult<String> {
let output = self.run_git(&["merge-base", ref1, ref2])?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(crate::error::RailError::message(format!(
"no common ancestor between {} and {}",
ref1, ref2
)));
}
Ok(sha)
}
pub fn get_commits_touching_path(
&self,
path: &Path,
since_sha: Option<&str>,
until_ref: &str,
) -> RailResult<Vec<CommitInfo>> {
let relative_path = self.normalize_path(path);
let git_path = relative_path.to_str().unwrap_or("");
let mut args = vec!["log", "--reverse", "--format=%H"];
let range_arg;
if let Some(since) = since_sha {
range_arg = format!("{}..{}", since, until_ref);
args.push(&range_arg);
} else {
args.push(until_ref);
}
args.push("--");
args.push(git_path);
let output = self.run_git(&args)?;
let shas: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let mut commits = Vec::with_capacity(shas.len());
for sha in shas {
commits.push(self.get_commit(&sha)?);
}
Ok(commits)
}
pub fn get_commits_touching_paths(
&self,
paths: &[PathBuf],
since_sha: Option<&str>,
until_ref: &str,
) -> RailResult<Vec<CommitInfo>> {
if paths.is_empty() {
return Ok(Vec::new());
}
let relative_paths: Vec<String> = paths
.iter()
.map(|path| self.normalize_path(path).to_str().unwrap_or("").to_string())
.collect();
let mut args = vec!["log", "--reverse", "--format=%H"];
let range_arg;
if let Some(since) = since_sha {
range_arg = format!("{}..{}", since, until_ref);
args.push(&range_arg);
} else {
args.push(until_ref);
}
args.push("--");
let path_refs: Vec<&str> = relative_paths.iter().map(|s| s.as_str()).collect();
args.extend(path_refs);
let output = self.run_git(&args)?;
let shas: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let mut commits = Vec::with_capacity(shas.len());
for sha in shas {
commits.push(self.get_commit(&sha)?);
}
Ok(commits)
}
pub fn get_commit(&self, sha: &str) -> RailResult<CommitInfo> {
let format = "%H%n%an%n%ae%n%at%n%cn%n%ce%n%ct%n%P%n%B";
let format_arg = format!("--format={}", format);
let output = self.run_git_with_error(&["log", "-1", &format_arg, sha], |_| {
RailError::Git(GitError::CommitNotFound { sha: sha.to_string() })
})?;
parse_commit_output(&output.stdout)
}
pub fn list_files_at_commit(&self, commit_sha: &str, path: &Path) -> RailResult<Vec<PathBuf>> {
let spec = if path.as_os_str().is_empty() {
commit_sha.to_string()
} else {
let git_path = utils::path_to_git_format(path);
format!("{}:{}", commit_sha, git_path)
};
if !self.run_git_check(&["ls-tree", "-r", "--name-only", &spec]) {
return Ok(vec![]);
}
let output = self.run_git(&["ls-tree", "-r", "--name-only", &spec])?;
let files = String::from_utf8_lossy(&output.stdout)
.lines()
.map(PathBuf::from)
.collect();
Ok(files)
}
pub fn collect_tree_files(&self, commit_sha: &str, path: &Path) -> RailResult<Vec<(PathBuf, Vec<u8>)>> {
let files = self.list_files_at_commit(commit_sha, path)?;
if files.is_empty() {
return Ok(vec![]);
}
let paths: Vec<PathBuf> = files.iter().map(|file| path.join(file)).collect();
let items: Vec<(&str, &Path)> = paths.iter().map(|p| (commit_sha, p.as_path())).collect();
let contents = self.read_files_bulk(&items)?;
let results: Vec<(PathBuf, Vec<u8>)> = paths.into_iter().zip(contents).collect();
Ok(results)
}
pub fn add_remote(&self, name: &str, url: &str) -> RailResult<()> {
match self.run_git(&["remote", "add", name, url]) {
Ok(_) => Ok(()),
Err(e) => {
if let RailError::Git(GitError::CommandFailed { stderr, .. }) = &e
&& stderr.contains("already exists")
{
return Ok(());
}
Err(e)
}
}
}
pub fn list_remotes(&self) -> RailResult<Vec<(String, String)>> {
if !self.run_git_check(&["remote", "-v"]) {
return Ok(vec![]);
}
let output = self.run_git(&["remote", "-v"])?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut remotes = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && line.contains("(fetch)") {
remotes.push((parts[0].to_string(), parts[1].to_string()));
}
}
Ok(remotes)
}
pub fn push_to_remote(&self, remote_name: &str, branch: &str) -> RailResult<()> {
progress!(" Pushing to remote '{}'...", remote_name);
self.run_git_with_error(&["push", "-u", remote_name, branch], |stderr| {
RailError::Git(GitError::PushFailed {
remote: remote_name.to_string(),
branch: branch.to_string(),
reason: stderr.to_string(),
})
})?;
progress!(" ✅ Pushed to {}/{}", remote_name, branch);
Ok(())
}
pub fn fetch_from_remote(&self, remote_name: &str) -> RailResult<()> {
progress!(" Fetching from remote '{}'...", remote_name);
self.run_git(&["fetch", remote_name])?;
progress!(" ✅ Fetched from {}", remote_name);
Ok(())
}
pub fn has_remote(&self, name: &str) -> RailResult<bool> {
let remotes = self.list_remotes()?;
Ok(remotes.iter().any(|(n, _)| n == name))
}
pub fn create_branch(&self, branch_name: &str) -> RailResult<()> {
self.run_git(&["branch", branch_name])?;
Ok(())
}
pub fn checkout_branch(&self, branch_name: &str) -> RailResult<()> {
self.run_git(&["checkout", branch_name])?;
Ok(())
}
pub fn branch_exists(&self, branch_name: &str) -> RailResult<bool> {
let ref_name = format!("refs/heads/{}", branch_name);
Ok(self.run_git_check(&["show-ref", "--verify", "--quiet", &ref_name]))
}
pub fn create_and_checkout_branch(&self, branch_name: &str) -> RailResult<()> {
self.create_branch(branch_name)?;
self.checkout_branch(branch_name)?;
Ok(())
}
pub fn create_commit_with_metadata(
&self,
message: &str,
author_name: &str,
author_email: &str,
timestamp: i64,
parent_shas: &[String],
) -> RailResult<String> {
self.run_git(&["add", "-A"])?;
let tree_output = self.run_git(&["write-tree"])?;
let tree_sha = String::from_utf8_lossy(&tree_output.stdout).trim().to_string();
let author_date = format!("{} +0000", timestamp);
let mut cmd = self.git_cmd();
cmd
.env("GIT_AUTHOR_NAME", author_name)
.env("GIT_AUTHOR_EMAIL", author_email)
.env("GIT_AUTHOR_DATE", &author_date)
.env("GIT_COMMITTER_NAME", author_name)
.env("GIT_COMMITTER_EMAIL", author_email)
.env("GIT_COMMITTER_DATE", &author_date)
.arg("commit-tree")
.arg(&tree_sha)
.arg("-m")
.arg(message);
for parent in parent_shas {
cmd.arg("-p").arg(parent);
}
let output = cmd.output().context("Failed to create commit")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: "git commit-tree".to_string(),
stderr: stderr.to_string(),
}));
}
let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
self.run_git(&["reset", "--soft", &commit_sha])?;
Ok(commit_sha)
}
pub fn read_files_bulk(&self, items: &[(&str, &Path)]) -> RailResult<Vec<Vec<u8>>> {
use std::io::Write;
use std::process::{Command, Stdio};
if items.is_empty() {
return Ok(vec![]);
}
let mut child = Command::new("git")
.arg("-C")
.arg(&self.repo_path)
.args(["cat-file", "--batch"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn git cat-file")?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| RailError::message("Failed to open stdin"))?;
for (commit_sha, path) in items {
let relative_path = self.normalize_path(path);
let git_path = utils::path_to_git_format(relative_path);
let spec = format!("{}:{}\n", commit_sha, git_path);
stdin
.write_all(spec.as_bytes())
.context("Failed to write to git cat-file stdin")?;
}
drop(stdin);
let output = child.wait_with_output().context("Failed to read git cat-file output")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::Git(GitError::CommandFailed {
command: "git cat-file --batch".to_string(),
stderr: stderr.to_string(),
}));
}
let mut results = Vec::with_capacity(items.len());
let stdout = &output.stdout[..];
let mut pos = 0;
for _ in 0..items.len() {
let line_end = stdout[pos..]
.iter()
.position(|&b| b == b'\n')
.ok_or_else(|| RailError::message("Invalid cat-file output: missing newline"))?;
let header = &stdout[pos..pos + line_end];
pos += line_end + 1;
if header.ends_with(b" missing") {
results.push(vec![]);
continue;
}
let parts: Vec<&[u8]> = header.split(|&b| b == b' ').collect();
if parts.len() < 3 {
return Err(RailError::message(format!(
"Invalid cat-file header: {}",
String::from_utf8_lossy(header)
)));
}
let size_str = String::from_utf8_lossy(parts[2]);
let size: usize = size_str
.parse()
.map_err(|_| RailError::message(format!("Invalid size in cat-file output: {}", size_str)))?;
if pos + size > stdout.len() {
return Err(RailError::message("Unexpected end of cat-file output"));
}
let content = stdout[pos..pos + size].to_vec();
pos += size;
if pos < stdout.len() && stdout[pos] == b'\n' {
pos += 1;
}
results.push(content);
}
Ok(results)
}
pub fn get_commits_bulk(&self, shas: &[String]) -> RailResult<Vec<CommitInfo>> {
use rayon::prelude::*;
let commits: Result<Vec<_>, _> = shas.par_iter().map(|sha| self.get_commit(sha)).collect();
commits
}
pub fn resolve_reference(&self, ref_name: &str) -> RailResult<String> {
self.run_git_stdout(&["rev-parse", ref_name])
}
}
fn parse_name_status_output_z(output: &[u8]) -> RailResult<Vec<(PathBuf, char)>> {
let mut files = Vec::new();
let mut parts = output.split(|&b| b == 0);
loop {
let Some(status_bytes) = parts.next() else {
break;
};
if status_bytes.is_empty() {
continue;
}
let status = String::from_utf8_lossy(status_bytes);
let change_type = status.chars().next().unwrap_or('M');
let mut next_path = || parts.next().filter(|p| !p.is_empty());
match change_type {
'R' => {
let Some(old_path) = next_path() else {
continue;
};
let Some(new_path) = next_path() else {
files.push((PathBuf::from(String::from_utf8_lossy(old_path).into_owned()), 'M'));
continue;
};
files.push((PathBuf::from(String::from_utf8_lossy(old_path).into_owned()), 'D'));
files.push((PathBuf::from(String::from_utf8_lossy(new_path).into_owned()), 'A'));
}
'C' => {
let Some(src_path) = next_path() else {
continue;
};
let Some(dest_path) = next_path() else {
files.push((PathBuf::from(String::from_utf8_lossy(src_path).into_owned()), 'A'));
continue;
};
files.push((PathBuf::from(String::from_utf8_lossy(src_path).into_owned()), 'M'));
files.push((PathBuf::from(String::from_utf8_lossy(dest_path).into_owned()), 'A'));
}
'A' | 'D' | 'M' | 'T' | 'U' => {
let Some(path) = next_path() else {
continue;
};
files.push((PathBuf::from(String::from_utf8_lossy(path).into_owned()), change_type));
}
_ => {
let Some(path) = next_path() else {
continue;
};
files.push((PathBuf::from(String::from_utf8_lossy(path).into_owned()), 'M'));
}
}
}
Ok(files)
}
fn parse_commit_output(data: &[u8]) -> RailResult<CommitInfo> {
let output = String::from_utf8_lossy(data);
let mut lines = output.lines();
let sha = lines
.next()
.ok_or_else(|| RailError::message("Missing commit SHA"))?
.to_string();
let author = lines
.next()
.ok_or_else(|| RailError::message("Missing author name"))?
.to_string();
let author_email = lines
.next()
.ok_or_else(|| RailError::message("Missing author email"))?
.to_string();
let timestamp = lines
.next()
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| RailError::message("Missing/invalid author timestamp"))?;
let committer = lines
.next()
.ok_or_else(|| RailError::message("Missing committer name"))?
.to_string();
let committer_email = lines
.next()
.ok_or_else(|| RailError::message("Missing committer email"))?
.to_string();
let _committer_timestamp = lines.next(); let parents_line = lines.next().unwrap_or("");
let parent_shas = if parents_line.is_empty() {
vec![]
} else {
parents_line.split_whitespace().map(|s| s.to_string()).collect()
};
let message: Vec<String> = lines.map(|s| s.to_string()).collect();
let message = message.join("\n").trim().to_string();
Ok(CommitInfo {
sha,
author,
author_email,
committer,
committer_email,
message,
timestamp,
parent_shas,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
fn find_git_root() -> PathBuf {
env::current_dir().unwrap()
}
#[test]
fn test_commit_history() {
let git = SystemGit::open(&find_git_root()).unwrap();
let commits = git.commit_history(Some(5)).unwrap();
assert!(!commits.is_empty());
assert!(commits.len() <= 5);
let first = &commits[0];
assert!(!first.sha.is_empty());
assert_eq!(first.sha.len(), 40); assert!(!first.author.is_empty());
assert!(first.timestamp > 0);
assert!(!first.message.is_empty());
}
#[test]
fn test_get_changed_files() {
let git = SystemGit::open(&find_git_root()).unwrap();
let head = git.head_commit().unwrap();
let changed = git.get_changed_files(&head).unwrap();
for (path, change_type) in changed {
assert!(!path.as_os_str().is_empty());
assert!(['A', 'M', 'D', 'R', 'C'].contains(&change_type));
}
}
#[test]
fn test_get_commits_bulk() {
let git = SystemGit::open(&find_git_root()).unwrap();
let history = git.commit_history(Some(5)).unwrap();
let shas: Vec<String> = history.iter().map(|c| c.sha.clone()).collect();
let commits = git.get_commits_bulk(&shas).unwrap();
assert_eq!(commits.len(), shas.len());
for (i, commit) in commits.iter().enumerate() {
assert_eq!(commit.sha, shas[i]);
}
}
#[test]
fn test_read_files_bulk() {
let git = SystemGit::open(&find_git_root()).unwrap();
let head = git.head_commit().unwrap();
let paths = [
PathBuf::from("Cargo.toml"),
PathBuf::from("README.md"),
PathBuf::from("this-does-not-exist.txt"),
];
let items: Vec<(&str, &Path)> = paths.iter().map(|p| (head.as_str(), p.as_path())).collect();
let results = git.read_files_bulk(&items).unwrap();
assert_eq!(results.len(), 3);
assert!(!results[0].is_empty(), "Cargo.toml should exist");
assert!(!results[1].is_empty(), "README.md should exist");
assert!(results[2].is_empty(), "Non-existent file should be empty");
let cargo_toml = String::from_utf8_lossy(&results[0]);
assert!(cargo_toml.contains("package") || cargo_toml.contains("dependencies"));
}
#[test]
fn test_read_files_bulk_empty() {
let git = SystemGit::open(&find_git_root()).unwrap();
let results = git.read_files_bulk(&[]).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_get_commits_touching_path() {
let git = SystemGit::open(&find_git_root()).unwrap();
let commits = git
.get_commits_touching_path(Path::new("Cargo.toml"), None, "HEAD")
.unwrap();
assert!(!commits.is_empty(), "Cargo.toml should have commits");
if commits.len() >= 2 {
assert!(
commits[0].timestamp <= commits[1].timestamp,
"Commits should be in chronological order"
);
}
}
#[test]
fn test_collect_tree_files_with_bulk() {
let git = SystemGit::open(&find_git_root()).unwrap();
let head = git.head_commit().unwrap();
let files = git.collect_tree_files(&head, Path::new("src")).unwrap();
assert!(!files.is_empty(), "src/ should contain files");
for (path, _content) in &files {
assert!(!path.as_os_str().is_empty(), "Path should not be empty");
}
let has_rust_with_content = files
.iter()
.any(|(path, content)| path.extension().and_then(|s| s.to_str()) == Some("rs") && !content.is_empty());
assert!(has_rust_with_content, "Should have at least one .rs file with content");
let all_files = git.collect_tree_files(&head, Path::new("")).unwrap();
assert!(
all_files.len() >= files.len(),
"Root should have at least as many files as src/"
);
}
#[test]
fn test_collect_tree_files_nonexistent() {
let git = SystemGit::open(&find_git_root()).unwrap();
let head = git.head_commit().unwrap();
let files = git
.collect_tree_files(&head, Path::new("this-directory-does-not-exist-12345"))
.unwrap();
assert!(files.is_empty(), "Non-existent directory should return empty list");
}
#[test]
fn test_parse_name_status_simple() {
let output = b"M\0src/main.rs\0A\0src/new.rs\0D\0src/old.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0], (PathBuf::from("src/main.rs"), 'M'));
assert_eq!(result[1], (PathBuf::from("src/new.rs"), 'A'));
assert_eq!(result[2], (PathBuf::from("src/old.rs"), 'D'));
}
#[test]
fn test_parse_name_status_rename() {
let output = b"R100\0src/old_name.rs\0src/new_name.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], (PathBuf::from("src/old_name.rs"), 'D'));
assert_eq!(result[1], (PathBuf::from("src/new_name.rs"), 'A'));
}
#[test]
fn test_parse_name_status_copy() {
let output = b"C095\0src/original.rs\0src/copied.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], (PathBuf::from("src/original.rs"), 'M'));
assert_eq!(result[1], (PathBuf::from("src/copied.rs"), 'A'));
}
#[test]
fn test_parse_name_status_paths_with_spaces() {
let output = b"M\0path with spaces/file name.rs\0A\0another path/new file.txt\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], (PathBuf::from("path with spaces/file name.rs"), 'M'));
assert_eq!(result[1], (PathBuf::from("another path/new file.txt"), 'A'));
}
#[test]
fn test_parse_name_status_rename_with_spaces() {
let output = b"R100\0old path/old file.rs\0new path/new file.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], (PathBuf::from("old path/old file.rs"), 'D'));
assert_eq!(result[1], (PathBuf::from("new path/new file.rs"), 'A'));
}
#[test]
fn test_parse_name_status_mixed() {
let output = b"M\0src/lib.rs\0R100\0src/old.rs\0src/renamed.rs\0A\0src/new.rs\0D\0src/deleted.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 5);
assert_eq!(result[0], (PathBuf::from("src/lib.rs"), 'M'));
assert_eq!(result[1], (PathBuf::from("src/old.rs"), 'D')); assert_eq!(result[2], (PathBuf::from("src/renamed.rs"), 'A')); assert_eq!(result[3], (PathBuf::from("src/new.rs"), 'A'));
assert_eq!(result[4], (PathBuf::from("src/deleted.rs"), 'D'));
}
#[test]
fn test_parse_name_status_empty() {
let result = parse_name_status_output_z(b"").unwrap();
assert!(result.is_empty());
let result = parse_name_status_output_z(b"\0\0").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_name_status_type_change() {
let output = b"T\0src/link.rs\0";
let result = parse_name_status_output_z(output).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0], (PathBuf::from("src/link.rs"), 'T'));
}
}