use crate::fleet::config::GitModules;
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::output::Formatter;
use crate::prelude::*;
use std::fs;
use std::io::Write;
use std::process::Command;
fn git(repo_path: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.map_err(|e| SuperGitError::Other(format!("Failed to execute git: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SuperGitError::Other(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn unstaged_changed_files(repo_path: &Path) -> Result<Vec<String>> {
let stdout = git(repo_path, &["diff", "--name-only"])?;
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
fn staged_changed_files(repo_path: &Path) -> Result<Vec<String>> {
let stdout = git(repo_path, &["diff", "--cached", "--name-only"])?;
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
fn changed_files_against_base(repo_path: &Path, base_ref: &str) -> Result<Vec<String>> {
let stdout = git(repo_path, &["diff", "--name-only", base_ref])?;
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
fn diff_index_to_worktree(repo_path: &Path, file: &str) -> Result<String> {
let output = Command::new("git")
.args(["diff", "--", file])
.current_dir(repo_path)
.output()
.map_err(|e| SuperGitError::Other(format!("Failed to execute git diff: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SuperGitError::Other(format!(
"git diff -- {} failed: {}",
file,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn diff_head_to_index(repo_path: &Path, file: &str) -> Result<String> {
let output = Command::new("git")
.args(["diff", "--cached", "--", file])
.current_dir(repo_path)
.output()
.map_err(|e| SuperGitError::Other(format!("Failed to execute git diff --cached: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SuperGitError::Other(format!(
"git diff --cached -- {} failed: {}",
file,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn diff_against_base(repo_path: &Path, base_ref: &str, file: &str) -> Result<String> {
let output = Command::new("git")
.args(["diff", base_ref, "--", file])
.current_dir(repo_path)
.output()
.map_err(|e| {
SuperGitError::Other(format!("Failed to execute git diff {}: {}", base_ref, e))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SuperGitError::Other(format!(
"git diff {} -- {} failed: {}",
base_ref,
file,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn write_diff_file(outdir: &Path, file: &str, diff: &str, append: bool) -> Result<()> {
let diff_path = outdir.join(format!("{}.diff", file));
if let Some(parent) = diff_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
SuperGitError::Other(format!(
"Failed to create directory '{}': {}",
parent.display(),
e
))
})?;
}
if append {
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&diff_path)
.map_err(|e| {
SuperGitError::Other(format!(
"Failed to open '{}' for appending: {}",
diff_path.display(),
e
))
})?;
f.write_all(diff.as_bytes()).map_err(|e| {
SuperGitError::Other(format!(
"Failed to write to '{}': {}",
diff_path.display(),
e
))
})?;
} else {
fs::write(&diff_path, diff).map_err(|e| {
SuperGitError::Other(format!("Failed to write '{}': {}", diff_path.display(), e))
})?;
}
Ok(())
}
pub fn generate_per_file_diffs(
repo_path: &Path,
base: Option<&str>,
outdir: &Path,
include_staged: bool,
) -> Result<Vec<String>> {
let mut written_files: Vec<String> = Vec::new();
match base {
Some(base_ref) => {
let names = changed_files_against_base(repo_path, base_ref)?;
for file in &names {
let diff = diff_against_base(repo_path, base_ref, file)?;
if !diff.is_empty() {
write_diff_file(outdir, file, &diff, false)?;
written_files.push(file.clone());
}
}
}
None => {
let names = unstaged_changed_files(repo_path)?;
for file in &names {
let diff = diff_index_to_worktree(repo_path, file)?;
if !diff.is_empty() {
write_diff_file(outdir, file, &diff, false)?;
written_files.push(file.clone());
}
}
if include_staged {
let staged_names = staged_changed_files(repo_path)?;
for file in &staged_names {
let diff = diff_head_to_index(repo_path, file)?;
if !diff.is_empty() {
let is_append = written_files.contains(file);
write_diff_file(outdir, file, &diff, is_append)?;
if !written_files.contains(file) {
written_files.push(file.clone());
}
}
}
}
}
}
Ok(written_files)
}
pub fn execute<P: AsRef<Path>>(
repo_root: P,
base: Option<&str>,
outdir: &str,
include_staged: bool,
verbose: bool,
) -> Result<()> {
let repo_root = repo_root.as_ref();
let gitmodules = match GitModules::parse(repo_root) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => GitModules {
entries: Vec::new(),
submodules: Vec::new(),
},
Err(err) => return Err(err),
};
let outdir_path = Path::new(outdir);
Formatter::print_info(&format!(
"Generating per-file diffs for superproject + {} submodule(s) into '{}'",
gitmodules.submodules.len(),
outdir
));
let mut total_files = 0;
let mut failed = 0;
{
let super_outdir = outdir_path.join("_superproject");
if verbose {
Formatter::print_submodule_header("[SUPERPROJECT]");
}
match generate_per_file_diffs(repo_root, base, &super_outdir, include_staged) {
Ok(files) => {
if files.is_empty() {
if verbose {
Formatter::print_info("No changes");
}
} else {
Formatter::print_success(&format!(
"Superproject: {} diff file(s) written",
files.len()
));
total_files += files.len();
}
}
Err(e) => {
Formatter::print_error(&format!("Superproject diff failed: {}", e));
failed += 1;
}
}
}
for submodule in &gitmodules.submodules {
let submodule_path = repo_root.join(&submodule.path);
if !submodule_path.exists() {
Formatter::print_warning(&format!(
"Submodule '{}' not initialized, skipping",
submodule.name
));
continue;
}
let sub_outdir = outdir_path.join(&submodule.name);
if verbose {
Formatter::print_submodule_header(&submodule.name);
}
match generate_per_file_diffs(&submodule_path, base, &sub_outdir, include_staged) {
Ok(files) => {
if files.is_empty() {
if verbose {
Formatter::print_info(&format!("No changes in '{}'", submodule.name));
}
} else {
Formatter::print_success(&format!(
"'{}': {} diff file(s) written",
submodule.name,
files.len()
));
total_files += files.len();
}
}
Err(e) => {
Formatter::print_error(&format!("Diff failed for '{}': {}", submodule.name, e));
failed += 1;
}
}
}
if total_files > 0 || failed == 0 {
Formatter::print_success(&format!(
"Total: {} diff file(s) written to '{}'",
total_files, outdir
));
}
if failed > 0 {
return Err(SuperGitError::Other(format!(
"{} repo(s) failed to generate diffs",
failed
)));
}
Ok(())
}