use crate::fleet::config::GitModules;
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::output::Formatter;
use crate::git_backend;
use gix::bstr::ByteSlice;
use std::fs;
use std::path::{Path, PathBuf};
pub fn generate_per_file_diffs(
repo_path: &Path,
base: Option<&str>,
outdir: &Path,
include_staged: bool,
) -> Result<Vec<String>> {
let repo = git_backend::open_repo(repo_path).map_err(|err| SuperGitError::Other(err.to_string()))?;
let mut written_files: Vec<String> = Vec::new();
match base {
Some(base_ref) => {
let names = changed_files_against_base(&repo, base_ref)?;
for file in &names {
let diff = diff_against_base(&repo, 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)?;
for file in &names {
let diff = diff_index_to_worktree(&repo, 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)?;
for file in &staged_names {
let diff = diff_head_to_index(&repo, 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)
}
fn unstaged_changed_files(repo: &gix::Repository) -> Result<Vec<String>> {
let mut files = Vec::new();
for item in repo
.status(gix::progress::Discard)
.map_err(|err| SuperGitError::Other(err.to_string()))?
.into_iter(Vec::<gix::bstr::BString>::new())
.map_err(|err| SuperGitError::Other(err.to_string()))?
{
match item.map_err(|err| SuperGitError::Other(err.to_string()))? {
gix::status::Item::IndexWorktree(entry) => {
let path = match entry {
gix::status::index_worktree::Item::Modification { rela_path, .. } => {
rela_path.to_string()
}
gix::status::index_worktree::Item::DirectoryContents { entry, .. } => {
entry.rela_path.to_string()
}
gix::status::index_worktree::Item::Rewrite {
dirwalk_entry, ..
} => dirwalk_entry.rela_path.to_string(),
};
if !files.contains(&path) {
files.push(path);
}
}
gix::status::Item::TreeIndex(_) => {}
}
}
Ok(files)
}
fn staged_changed_files(repo: &gix::Repository) -> Result<Vec<String>> {
let mut files = Vec::new();
for item in repo
.status(gix::progress::Discard)
.map_err(|err| SuperGitError::Other(err.to_string()))?
.into_iter(Vec::<gix::bstr::BString>::new())
.map_err(|err| SuperGitError::Other(err.to_string()))?
{
if let gix::status::Item::TreeIndex(change) =
item.map_err(|err| SuperGitError::Other(err.to_string()))?
{
let path = change.location().to_string();
if !files.contains(&path) {
files.push(path);
}
}
}
Ok(files)
}
fn changed_files_against_base(repo: &gix::Repository, base_ref: &str) -> Result<Vec<String>> {
let base_commit = repo
.rev_parse_single(base_ref)
.map_err(|err| SuperGitError::Other(err.to_string()))?
.object()
.map_err(|err| SuperGitError::Other(err.to_string()))?
.peel_to_commit()
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let head_commit = repo
.head_commit()
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let base_tree = base_commit
.tree()
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let head_tree = head_commit
.tree()
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let changes = repo
.diff_tree_to_tree(&base_tree, &head_tree, None)
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let mut files = Vec::new();
for change in changes {
let path = match change {
gix::diff::tree_with_rewrites::Change::Addition { location, .. }
| gix::diff::tree_with_rewrites::Change::Deletion { location, .. }
| gix::diff::tree_with_rewrites::Change::Modification { location, .. }
| gix::diff::tree_with_rewrites::Change::Rewrite { location, .. } => {
location.to_string()
}
};
if !files.contains(&path) {
files.push(path);
}
}
Ok(files)
}
fn diff_against_base(repo: &gix::Repository, base_ref: &str, file: &str) -> Result<String> {
diff_between_sources(
repo,
&DiffSource::TreePath {
treeish: base_ref.to_string(),
path: file.to_string(),
},
&DiffSource::TreePath {
treeish: "HEAD".to_string(),
path: file.to_string(),
},
)
}
fn diff_head_to_index(repo: &gix::Repository, file: &str) -> Result<String> {
diff_between_sources(
repo,
&DiffSource::TreePath {
treeish: "HEAD".to_string(),
path: file.to_string(),
},
&DiffSource::IndexPath(file.to_string()),
)
}
fn diff_index_to_worktree(repo: &gix::Repository, file: &str) -> Result<String> {
diff_between_sources(
repo,
&DiffSource::IndexPath(file.to_string()),
&DiffSource::WorktreePath(file.to_string()),
)
}
enum DiffSource {
TreePath { treeish: String, path: String },
IndexPath(String),
WorktreePath(String),
}
fn diff_between_sources(repo: &gix::Repository, old: &DiffSource, new: &DiffSource) -> Result<String> {
let old_data = read_diff_source(repo, old)?;
let new_data = read_diff_source(repo, new)?;
let interner = gix::diff::blob::intern::InternedInput::new(
gix::diff::blob::sources::byte_lines(&old_data),
gix::diff::blob::sources::byte_lines(&new_data),
);
let unified = gix::diff::blob::UnifiedDiff::new(
&interner,
gix::diff::blob::unified_diff::ConsumeBinaryHunk::new(gix::bstr::BString::default(), "\n"),
gix::diff::blob::unified_diff::ContextSize::symmetrical(3),
);
let diff = gix::diff::blob::diff(gix::diff::blob::Algorithm::Histogram, &interner, unified)
.map_err(|err| SuperGitError::Other(err.to_string()))?;
Ok(String::from_utf8_lossy(diff.as_bytes()).into_owned())
}
fn read_diff_source(repo: &gix::Repository, source: &DiffSource) -> Result<Vec<u8>> {
match source {
DiffSource::WorktreePath(path) => {
let full_path = repo
.workdir()
.ok_or_else(|| SuperGitError::Other("Bare repositories are not supported".to_string()))?
.join(path);
match fs::read(&full_path) {
Ok(bytes) => Ok(bytes),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(SuperGitError::Other(format!(
"Failed to read '{}': {}",
full_path.display(),
err
))),
}
}
DiffSource::IndexPath(path) => {
let index = repo
.index_or_load_from_head_or_empty()
.map_err(|err| SuperGitError::Other(err.to_string()))?
.into_owned();
let entry = index
.entry_by_path(path.as_bytes().as_bstr())
.ok_or_else(|| SuperGitError::Other(format!("Path '{}' not found in index", path)))?;
if entry.id.is_null() {
return Ok(Vec::new());
}
repo.find_object(entry.id)
.map_err(|err| SuperGitError::Other(err.to_string()))
.map(|obj| obj.data.clone())
}
DiffSource::TreePath { treeish, path } => {
let tree = repo
.rev_parse_single(treeish.as_str())
.map_err(|err| SuperGitError::Other(err.to_string()))?
.object()
.map_err(|err| SuperGitError::Other(err.to_string()))?
.peel_to_tree()
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let entry = tree
.lookup_entry_by_path(PathBuf::from(path))
.map_err(|err| SuperGitError::Other(err.to_string()))?;
let Some(entry) = entry else {
return Ok(Vec::new());
};
repo.find_object(entry.id())
.map_err(|err| SuperGitError::Other(err.to_string()))
.map(|obj| obj.data.clone())
}
}
}
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 {
use std::io::Write;
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 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(())
}