feat(commit): 添加.gitignore文件过滤功能,自动跳过被忽略的文件
在自动暂存和`--all`模式下,检测并跳过被.gitignore规则匹配的文件,暂存完成后显示被移除的被忽略文件列表
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ Cargo.lock
|
|||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
.trae/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
116
src/git/mod.rs
116
src/git/mod.rs
@@ -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
266
tests/gitignore_tests.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user