From 995d263a48cc9861cc5c4c751f8e90f0a8e3248b Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Mon, 20 Jul 2026 17:33:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(commit):=20=E6=B7=BB=E5=8A=A0.gitignore?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BF=87=E8=A2=AB=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在自动暂存和`--all`模式下,检测并跳过被.gitignore规则匹配的文件,暂存完成后显示被移除的被忽略文件列表 --- .gitignore | 1 + Cargo.toml | 4 +- src/commands/commit.rs | 22 +++- src/git/mod.rs | 116 +++++++++++++++-- tests/gitignore_tests.rs | 266 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 tests/gitignore_tests.rs diff --git a/.gitignore b/.gitignore index a5de515..f09af1c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Cargo.lock # IDE .idea/ +.trae/ .vscode/ *.swp *.swo diff --git a/Cargo.toml b/Cargo.toml index 483eef0..47ad09d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "quicommit" -version = "0.4.0" +version = "0.4.1" edition = "2024" authors = ["Sidney Zhang "] -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" repository = "https://git.lyz.one/SidneyZhang/QuiCommit" keywords = ["git", "commit", "ai", "cli", "automation"] diff --git a/src/commands/commit.rs b/src/commands/commit.rs index c3f5b18..13efc71 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -121,8 +121,17 @@ impl CommitCommand { // 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 { println!("{}", messages.auto_stage_changes().yellow()); - repo.stage_all()?; + let removed = repo.stage_all()?; 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 let new_status = repo.status_summary()?; @@ -133,8 +142,17 @@ impl CommitCommand { // Stage all if requested if self.all { - repo.stage_all()?; + let removed = repo.stage_all()?; 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 diff --git a/src/git/mod.rs b/src/git/mod.rs index 2646060..8ac4f34 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -533,27 +533,116 @@ impl GitRepo { Ok(files) } - /// Stage files - pub fn stage_files>(&self, paths: &[P]) -> Result<()> { + /// Stage files, skipping paths matched by .gitignore rules + /// Returns the list of skipped paths (those matched by .gitignore) + pub fn stage_files>(&self, paths: &[P]) -> Result> { let mut index = self.repo.index()?; + let mut skipped = Vec::new(); for path in paths { let path = path.as_ref(); - if path.is_absolute() { - if let Ok(rel_path) = path.strip_prefix(&self.path) { - index.add_path(rel_path)?; + let rel_path = if path.is_absolute() { + match path.strip_prefix(&self.path) { + Ok(p) => p, + Err(_) => { + // Outside repo, skip + skipped.push(path.to_string_lossy().to_string()); + continue; + } } } 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()?; - Ok(()) + Ok(skipped) } - /// Stage all changes including subdirectories - pub fn stage_all(&self) -> Result<()> { + /// Check if a path is ignored by .gitignore rules + pub fn is_path_ignored>(&self, path: P) -> Result { + 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> { + 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 = 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> { // Use git command for reliable staging (handles all edge cases) let output = std::process::Command::new("git") .args(["add", "-A"]) @@ -569,7 +658,14 @@ impl GitRepo { // Force refresh the git2 index to pick up changes from git CLI 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 diff --git a/tests/gitignore_tests.rs b/tests/gitignore_tests.rs new file mode 100644 index 0000000..a792d91 --- /dev/null +++ b/tests/gitignore_tests.rs @@ -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 + ); +}