use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::SubmoduleTree;
use crate::fleet::output::Formatter;
use crate::prelude::*;
use chrono::{DateTime, Duration, Utc};
use std::fs;
use std::io::Write;
const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const SUPERPROJECT_OUTDIR: &str = "_superproject";
fn git(repo_path: &Path, args: &[&str]) -> Result<String> {
let output = crate::git_backend::git_command()
.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())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DiffBase {
Explicit(String),
Since(String),
}
impl DiffBase {
fn as_ref(&self) -> &str {
match self {
Self::Explicit(base) | Self::Since(base) => base,
}
}
}
fn parse_since_duration(input: &str) -> Result<Duration> {
if input.len() < 2 {
return Err(SuperGitError::Other(format!(
"Invalid --since value '{}'. Expected formats like 2d, 4h, 30m, 15s, or 1w",
input
)));
}
let (number, unit) = input.split_at(input.len() - 1);
let value: i64 = number.parse().map_err(|_| {
SuperGitError::Other(format!(
"Invalid --since value '{}'. Expected a positive integer followed by s, m, h, d, or w",
input
))
})?;
if value <= 0 {
return Err(SuperGitError::Other(format!(
"Invalid --since value '{}'. Duration must be greater than zero",
input
)));
}
let duration = match unit {
"s" => Duration::seconds(value),
"m" => Duration::minutes(value),
"h" => Duration::hours(value),
"d" => Duration::days(value),
"w" => Duration::weeks(value),
_ => {
return Err(SuperGitError::Other(format!(
"Invalid --since unit '{}'. Supported units: s, m, h, d, w",
unit
)));
}
};
Ok(duration)
}
fn resolve_since_base(repo_path: &Path, since: &str) -> Result<DiffBase> {
let cutoff: DateTime<Utc> = Utc::now() - parse_since_duration(since)?;
let cutoff_arg = format!("--before={}", cutoff.to_rfc3339());
let stdout = git(repo_path, &["rev-list", "-1", &cutoff_arg, "HEAD"])?;
let base = stdout.trim();
if base.is_empty() {
Ok(DiffBase::Since(EMPTY_TREE_OID.to_string()))
} else {
Ok(DiffBase::Since(base.to_string()))
}
}
fn resolve_diff_base(
repo_path: &Path,
base: Option<&str>,
since: Option<&str>,
) -> Result<Option<DiffBase>> {
match (base, since) {
(Some(base_ref), None) => Ok(Some(DiffBase::Explicit(base_ref.to_string()))),
(None, Some(since_value)) => resolve_since_base(repo_path, since_value).map(Some),
(None, None) => Ok(None),
(Some(_), Some(_)) => Err(SuperGitError::Other(
"--base and --since cannot be used together".to_string(),
)),
}
}
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 = crate::git_backend::git_command()
.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 = crate::git_backend::git_command()
.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 = crate::git_backend::git_command()
.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>,
since: Option<&str>,
outdir: &Path,
include_staged: bool,
) -> Result<Vec<String>> {
let mut written_files: Vec<String> = Vec::new();
let resolved_base = resolve_diff_base(repo_path, base, since)?;
match resolved_base.as_ref() {
Some(base_ref) => {
let base_ref = base_ref.as_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>,
since: Option<&str>,
outdir: &str,
include_staged: bool,
clean: bool,
verbose: bool,
) -> Result<()> {
let repo_root = repo_root.as_ref();
let tree = SubmoduleTree::discover_allow_empty(repo_root)?;
let outdir_path = Path::new(outdir);
if let Some(node) = tree
.top_down()
.iter()
.find(|node| node.display_path == SUPERPROJECT_OUTDIR)
{
return Err(SuperGitError::Other(format!(
"Submodule '{}' would collide with the reserved '{}' output directory; \
pass a different --output directory or rename the submodule path",
node.label(),
SUPERPROJECT_OUTDIR
)));
}
if clean && outdir_path.exists() {
fs::remove_dir_all(outdir_path).map_err(|e| {
SuperGitError::Other(format!(
"Failed to remove '{}': {}",
outdir_path.display(),
e
))
})?;
}
Formatter::print_info(&format!(
"Generating per-file diffs for superproject + {} submodule(s) into '{}'",
tree.len(),
outdir
));
let mut total_files = 0;
let mut failed = 0;
{
let super_outdir = outdir_path.join(SUPERPROJECT_OUTDIR);
if verbose {
Formatter::print_submodule_header("[SUPERPROJECT]");
}
match generate_per_file_diffs(repo_root, base, since, &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 node in tree.top_down() {
if !node.initialized {
Formatter::print_warning(&format!(
"Submodule '{}' not initialized, skipping",
node.label()
));
continue;
}
let sub_outdir = outdir_path.join(&node.display_path);
if verbose {
Formatter::print_submodule_header(node.label());
}
match generate_per_file_diffs(&node.abs_path, base, since, &sub_outdir, include_staged) {
Ok(files) => {
if files.is_empty() {
if verbose {
Formatter::print_info(&format!("No changes in '{}'", node.label()));
}
} else {
Formatter::print_success(&format!(
"'{}': {} diff file(s) written",
node.label(),
files.len()
));
total_files += files.len();
}
}
Err(e) => {
Formatter::print_error(&format!("Diff failed for '{}': {}", node.label(), 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(())
}
#[cfg(test)]
mod tests {
use super::parse_since_duration;
#[test]
fn parse_since_duration_supports_expected_units() {
assert_eq!(parse_since_duration("15s").unwrap().num_seconds(), 15);
assert_eq!(parse_since_duration("30m").unwrap().num_minutes(), 30);
assert_eq!(parse_since_duration("2h").unwrap().num_hours(), 2);
assert_eq!(parse_since_duration("3d").unwrap().num_days(), 3);
assert_eq!(parse_since_duration("2w").unwrap().num_weeks(), 2);
}
#[test]
fn parse_since_duration_rejects_invalid_values() {
assert!(parse_since_duration("0d").is_err());
assert!(parse_since_duration("abc").is_err());
assert!(parse_since_duration("10x").is_err());
}
}