use std::{io::IsTerminal, process::Command, time::Duration};
use glob::Pattern;
use indicatif::{ProgressBar, ProgressDrawTarget};
use crate::errors::{GitError, Result, RonaError};
use super::{
repository::get_top_level_path,
status::{
count_renamed_files, get_all_staged_file_paths, get_status_files,
process_deleted_files_for_staging,
},
};
fn pattern_matches_file(
pattern: &Pattern,
file_path: &str,
current_dir_rel_to_repo: Option<&str>,
) -> bool {
if pattern.matches(file_path) {
return true;
}
if let Some(current_subdir) = current_dir_rel_to_repo
&& !current_subdir.is_empty()
{
if let Some(relative_path) = file_path.strip_prefix(&format!("{current_subdir}/"))
&& pattern.matches(relative_path)
{
return true;
}
}
if let Some(filename) = std::path::Path::new(file_path).file_name()
&& let Some(filename_str) = filename.to_str()
&& pattern.matches(filename_str)
{
return true;
}
false
}
#[tracing::instrument(skip(exclude_patterns))]
pub fn git_add_with_exclude_patterns(
exclude_patterns: &[Pattern],
verbose: bool,
dry_run: bool,
) -> Result<()> {
tracing::debug!("Adding files...");
let repo_root = get_top_level_path()?;
let current_dir_rel_to_repo = {
use std::env;
let current_dir = env::current_dir().map_err(RonaError::Io)?;
current_dir
.strip_prefix(&repo_root)
.ok()
.and_then(|p| p.to_str())
.map(String::from)
};
if dry_run {
let deleted_files = process_deleted_files_for_staging()?;
let all_files = get_status_files()?;
let total_len = all_files.len() + deleted_files.len();
let files_to_add: Vec<String> = all_files
.into_iter()
.filter(|f| {
!exclude_patterns
.iter()
.any(|p| pattern_matches_file(p, f, current_dir_rel_to_repo.as_deref()))
})
.collect();
let deleted_to_stage: Vec<String> = deleted_files
.into_iter()
.filter(|f| {
!exclude_patterns
.iter()
.any(|p| pattern_matches_file(p, f, current_dir_rel_to_repo.as_deref()))
})
.collect();
let excluded_count = total_len - files_to_add.len() - deleted_to_stage.len();
print_dry_run_summary(&files_to_add, &deleted_to_stage, excluded_count);
return Ok(());
}
let show_progress = std::io::stderr().is_terminal() && !verbose;
let pb = if show_progress {
let bar = ProgressBar::new_spinner();
bar.set_draw_target(ProgressDrawTarget::stderr());
bar.set_message("Staging files...");
bar.enable_steady_tick(Duration::from_millis(80));
Some(bar)
} else {
None
};
let output = Command::new("git")
.current_dir(&repo_root)
.args(["add", "-A"])
.output()
.map_err(RonaError::Io)?;
if !output.status.success() {
if let Some(bar) = &pb {
bar.finish_and_clear();
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RonaError::Git(GitError::CommandFailed {
command: "git add -A".to_string(),
output: stderr.trim().to_string(),
}));
}
let staged_files = get_all_staged_file_paths()?;
let total_staged = staged_files.len();
let files_to_unstage: Vec<String> = staged_files
.into_iter()
.filter(|f| {
exclude_patterns
.iter()
.any(|p| pattern_matches_file(p, f, current_dir_rel_to_repo.as_deref()))
})
.collect();
if !files_to_unstage.is_empty() {
let output = Command::new("git")
.current_dir(&repo_root)
.args(["rm", "--cached", "--"])
.args(&files_to_unstage)
.output()
.map_err(RonaError::Io)?;
if !output.status.success() {
if let Some(bar) = &pb {
bar.finish_and_clear();
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RonaError::Git(GitError::CommandFailed {
command: "git rm --cached".to_string(),
output: stderr.trim().to_string(),
}));
}
}
if let Some(bar) = pb {
bar.finish_and_clear();
}
let excluded_count = files_to_unstage.len();
let staged_count = total_staged - excluded_count;
let renamed_count = count_renamed_files()?;
println!(
"Added {staged_count} files, renamed {renamed_count} while excluding {excluded_count} files for commit."
);
Ok(())
}
fn print_dry_run_summary(
files_to_add: &[String],
deleted_files: &[String],
staged_files_len: usize,
) {
println!("Would add {} files:", files_to_add.len());
for file in files_to_add {
println!(" + {file}");
}
println!("Would delete {} files:", deleted_files.len());
for file in deleted_files {
println!(" - {file}");
}
let excluded_files_len = staged_files_len - files_to_add.len();
println!("Would exclude {excluded_files_len} files");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pattern_matches_file_full_path() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let pattern = Pattern::new("tp08-sujet/RESPONSE.md")?;
let file_path = "tp08-sujet/RESPONSE.md";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_matches_file_relative_to_current_dir()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("RESPONSE.md")?;
let file_path = "tp08-sujet/RESPONSE.md";
let current_dir = Some("tp08-sujet");
assert!(pattern_matches_file(&pattern, file_path, current_dir));
Ok(())
}
#[test]
fn test_pattern_matches_file_filename_only()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("RESPONSE.md")?;
let file_path = "some/nested/dir/RESPONSE.md";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_matches_file_glob_pattern()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("*/RESPONSE.md")?;
let file_path = "tp08-sujet/RESPONSE.md";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_matches_file_double_star_glob()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("**/RESPONSE.md")?;
let file_path = "some/deep/nested/dir/RESPONSE.md";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_does_not_match() -> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("README.md")?;
let file_path = "tp08-sujet/RESPONSE.md";
assert!(!pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_matches_relative_path_in_subdirectory()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("src/main.java")?;
let file_path = "tp08-sujet/src/main.java";
let current_dir = Some("tp08-sujet");
assert!(pattern_matches_file(&pattern, file_path, current_dir));
Ok(())
}
#[test]
fn test_pattern_matches_nested_path_from_root()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("tp08-sujet/src/main.java")?;
let file_path = "tp08-sujet/src/main.java";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_with_extension_wildcard() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let pattern = Pattern::new("*.md")?;
let file_path = "tp08-sujet/RESPONSE.md";
assert!(pattern_matches_file(&pattern, file_path, None));
Ok(())
}
#[test]
fn test_pattern_at_repo_root() -> std::result::Result<(), Box<dyn std::error::Error>> {
let pattern = Pattern::new("README.md")?;
let file_path = "README.md";
let current_dir = Some("");
assert!(pattern_matches_file(&pattern, file_path, current_dir));
Ok(())
}
}