feat(commit): 添加.gitignore文件过滤功能,自动跳过被忽略的文件

在自动暂存和`--all`模式下,检测并跳过被.gitignore规则匹配的文件,暂存完成后显示被移除的被忽略文件列表
This commit is contained in:
2026-07-20 17:33:57 +08:00
parent 19aff8a6c1
commit 995d263a48
5 changed files with 395 additions and 14 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ Cargo.lock
# IDE # IDE
.idea/ .idea/
.trae/
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.4.0" version = "0.4.1"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
license = "MIT" license = "MIT"
repository = "https://git.lyz.one/SidneyZhang/QuiCommit" repository = "https://git.lyz.one/SidneyZhang/QuiCommit"
keywords = ["git", "commit", "ai", "cli", "automation"] keywords = ["git", "commit", "ai", "cli", "automation"]

View File

@@ -121,8 +121,17 @@ impl CommitCommand {
// Auto-add if no files are staged and there are unstaged/untracked changes // Auto-add if no files are staged and there are unstaged/untracked changes
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all { if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow()); println!("{}", messages.auto_stage_changes().yellow());
repo.stage_all()?; let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green()); println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
for file in &removed {
println!("{}", file);
}
}
// Re-check status after staging to ensure changes are detected // Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?; let new_status = repo.status_summary()?;
@@ -133,8 +142,17 @@ impl CommitCommand {
// Stage all if requested // Stage all if requested
if self.all { if self.all {
repo.stage_all()?; let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green()); println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
for file in &removed {
println!("{}", file);
}
}
} }
// Generate or build commit message // Generate or build commit message

View File

@@ -533,27 +533,116 @@ impl GitRepo {
Ok(files) Ok(files)
} }
/// Stage files /// Stage files, skipping paths matched by .gitignore rules
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> { /// Returns the list of skipped paths (those matched by .gitignore)
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<Vec<String>> {
let mut index = self.repo.index()?; let mut index = self.repo.index()?;
let mut skipped = Vec::new();
for path in paths { for path in paths {
let path = path.as_ref(); let path = path.as_ref();
if path.is_absolute() { let rel_path = if path.is_absolute() {
if let Ok(rel_path) = path.strip_prefix(&self.path) { match path.strip_prefix(&self.path) {
index.add_path(rel_path)?; Ok(p) => p,
Err(_) => {
// Outside repo, skip
skipped.push(path.to_string_lossy().to_string());
continue;
}
} }
} else { } else {
index.add_path(path)?; path
};
// Check if the path is ignored by .gitignore
if self.is_path_ignored(rel_path)? {
skipped.push(rel_path.to_string_lossy().to_string());
continue;
} }
index.add_path(rel_path)?;
} }
index.write()?; index.write()?;
Ok(()) Ok(skipped)
} }
/// Stage all changes including subdirectories /// Check if a path is ignored by .gitignore rules
pub fn stage_all(&self) -> Result<()> { pub fn is_path_ignored<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
let path = path.as_ref();
// Convert to relative path if absolute
let rel_path = if path.is_absolute() {
match path.strip_prefix(&self.path) {
Ok(p) => p,
Err(_) => return Ok(false), // Outside repo, not ignored
}
} else {
path
};
let path_str = match rel_path.to_str() {
Some(s) => s,
None => return Ok(false), // Non-UTF8 path, not ignored
};
let output = std::process::Command::new("git")
.args(["check-ignore", "--quiet", "--", path_str])
.current_dir(&self.path)
.output()?;
// Exit code 0: ignored, 1: not ignored, other: error
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Ok(false), // Treat errors as not ignored
}
}
/// Remove files from index that are tracked but should be ignored by .gitignore
pub fn remove_ignored_from_index(&self) -> Result<Vec<String>> {
let output = std::process::Command::new("git")
.args(["ls-files", "--cached", "-i", "--exclude-standard"])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list ignored tracked files: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let ignored_files: Vec<String> = stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if ignored_files.is_empty() {
return Ok(Vec::new());
}
// Remove these files from the index (keep working tree files)
let mut args = vec!["rm", "--cached", "--quiet", "--"];
for file in &ignored_files {
args.push(file);
}
let rm_output = std::process::Command::new("git")
.args(&args)
.current_dir(&self.path)
.output()?;
if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
bail!("Failed to remove ignored files from index: {}", stderr);
}
Ok(ignored_files)
}
/// Stage all changes including subdirectories, then remove ignored tracked files
pub fn stage_all(&self) -> Result<Vec<String>> {
// Use git command for reliable staging (handles all edge cases) // Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["add", "-A"]) .args(["add", "-A"])
@@ -569,7 +658,14 @@ impl GitRepo {
// Force refresh the git2 index to pick up changes from git CLI // Force refresh the git2 index to pick up changes from git CLI
let _ = self.repo.index()?.write(); let _ = self.repo.index()?.write();
Ok(()) // Remove files that are tracked but should be ignored by .gitignore
match self.remove_ignored_from_index() {
Ok(removed) => Ok(removed),
Err(e) => {
eprintln!("Warning: failed to clean ignored files from index: {}", e);
Ok(Vec::new())
}
}
} }
/// Unstage files /// Unstage files

266
tests/gitignore_tests.rs Normal file
View File

@@ -0,0 +1,266 @@
use quicommit::git::GitRepo;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a git command in the given directory, returning stdout as a String.
/// Panics if the command fails.
fn git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("Failed to execute git command");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("git {:?} failed in {:?}: {}", args, dir, stderr);
}
String::from_utf8_lossy(&output.stdout).to_string()
}
/// Initialize a new git repo in the given directory and configure a local
/// user identity so commits can be created.
fn init_repo(dir: &Path) {
git(dir, &["init"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "test@example.com"]);
// Disable commit signing in case the global config enables it.
git(dir, &["config", "commit.gpgsign", "false"]);
}
/// Write a file with the given content, creating parent directories as needed.
fn write_file(dir: &Path, rel_path: &str, content: &str) {
let file_path = dir.join(rel_path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent directories");
}
fs::write(&file_path, content).expect("Failed to write file");
}
/// Get the list of files in the index as a String (one path per line).
fn ls_files(dir: &Path) -> String {
git(dir, &["ls-files"])
}
// ---------------------------------------------------------------------------
// Tests for is_path_ignored
// ---------------------------------------------------------------------------
#[test]
fn test_is_path_ignored_with_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "subdir/__pycache__/bar.pyc", "more bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
// Top-level ignored file
assert!(
repo.is_path_ignored("__pycache__/foo.pyc").unwrap(),
"__pycache__/foo.pyc should be ignored"
);
// Nested ignored file under a subdirectory
assert!(
repo.is_path_ignored("subdir/__pycache__/bar.pyc").unwrap(),
"subdir/__pycache__/bar.pyc should be ignored"
);
}
#[test]
fn test_is_path_ignored_with_non_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
assert!(
!repo.is_path_ignored("src/main.rs").unwrap(),
"src/main.rs should not be ignored"
);
}
// ---------------------------------------------------------------------------
// Tests for stage_all
// ---------------------------------------------------------------------------
#[test]
fn test_stage_all_removes_ignored_tracked_files() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// Create and commit __pycache__/foo.pyc so it becomes a tracked file.
write_file(repo_path, "__pycache__/foo.pyc", "original bytecode");
git(repo_path, &["add", "__pycache__/foo.pyc"]);
git(repo_path, &["commit", "-m", "initial commit"]);
// Add a .gitignore that now ignores __pycache__/.
write_file(repo_path, ".gitignore", "__pycache__/\n");
// Modify the tracked file so the working tree has unstaged changes.
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
assert!(
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
"stage_all should return __pycache__/foo.pyc in removed list, got: {:?}",
removed
);
// Verify the file is no longer in the index.
let files = ls_files(repo_path);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should no longer be in the index, got: {}",
files
);
}
#[test]
fn test_stage_all_no_ignored_files_returns_empty() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "*.log\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
assert!(
removed.is_empty(),
"stage_all should return empty Vec when no ignored files are tracked, got: {:?}",
removed
);
// Verify src/main.rs was staged.
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be in the index, got: {}",
files
);
}
// ---------------------------------------------------------------------------
// Tests for stage_files
// ---------------------------------------------------------------------------
#[test]
fn test_stage_files_skips_ignored_paths() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc", "src/main.rs"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
assert!(
!skipped.iter().any(|f| f == "src/main.rs"),
"skipped list should not contain src/main.rs, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should not be staged, got: {}",
files
);
}
#[test]
fn test_stage_files_all_paths_ignored() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
// Verify the index remains empty (the file was not staged).
let files = ls_files(repo_path);
assert!(
files.trim().is_empty(),
"index should be empty, got: {}",
files
);
}
#[test]
fn test_stage_files_normal_paths_unchanged_behavior() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// No .gitignore (or one that does not match these paths).
write_file(repo_path, "src/main.rs", "fn main() {}");
write_file(repo_path, "src/lib.rs", "pub fn lib() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["src/main.rs", "src/lib.rs"])
.expect("stage_files should succeed");
assert!(
skipped.is_empty(),
"skipped list should be empty, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
files.lines().any(|l| l == "src/lib.rs"),
"src/lib.rs should be staged, got: {}",
files
);
}