wsb 0.196.4203

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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;

/// Run a git command in the given working directory, capturing stdout.
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())
}

/// List files changed between index and worktree (unstaged changes).
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())
}

/// List files changed between HEAD and index (staged changes).
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())
}

/// List files changed between a base ref and HEAD.
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())
}

/// Get the diff for a single file: index vs worktree (unstaged).
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())
}

/// Get the diff for a single file: HEAD vs index (staged).
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())
}

/// Get the diff for a single file between a base ref and HEAD.
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(())
}

/// Generate per-file diff files for a single git repo.
///
/// Behavior mirrors the bash `gitdiff_per_file` function:
/// - No base: diffs unstaged changes. With `--staged`, also appends staged diffs.
/// - With base: diffs against the given base ref (commit, branch, tag).
///
/// Each changed file `path/to/file.rs` gets a corresponding `outdir/path/to/file.rs.diff`.
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)
}

/// Fleet diff: generate per-file diffs for superproject + all submodules.
/// Superproject diffs go into `outdir/_superproject/`.
/// Each submodule's diffs go into `outdir/<submodule_name>/`.
pub fn execute<P: AsRef<Path>>(
    repo_root: P,
    base: Option<&str>,
    outdir: &str,
    include_staged: bool,
    clean: 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);

    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 '{}'",
        gitmodules.submodules.len(),
        outdir
    ));

    let mut total_files = 0;
    let mut failed = 0;

    // Superproject
    {
        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;
            }
        }
    }

    // Submodules
    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(())
}