From 955abbb8b22de314f67c2b0d85cacc5fddffa65e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:56:40 +0000 Subject: [PATCH] Fix directory pathspec matching in reset and add test - Fix pathspec matching in reset_hooks.rs, rebase_authorship.rs, and checkout_hooks.rs to properly handle directory pathspecs by checking for trailing slash separator (e.g. 'src' now correctly matches 'src/file.txt' but not 'srcfile.txt') - Add test_reset_with_directory_pathspec test verifying that git reset -- preserves AI authorship for files outside the reset directory Co-Authored-By: Sasha Varlamov --- src/authorship/rebase_authorship.rs | 8 +++- src/commands/hooks/checkout_hooks.rs | 6 ++- src/commands/hooks/reset_hooks.rs | 8 ++-- tests/reset.rs | 61 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/authorship/rebase_authorship.rs b/src/authorship/rebase_authorship.rs index 03a648083..6329bb951 100644 --- a/src/authorship/rebase_authorship.rs +++ b/src/authorship/rebase_authorship.rs @@ -1548,7 +1548,13 @@ pub fn reconstruct_working_log_after_reset( let pathspecs: Vec = if let Some(user_paths) = user_pathspecs { all_changed_files .into_iter() - .filter(|f| user_paths.iter().any(|p| f == p || f.starts_with(p))) + .filter(|f| { + user_paths.iter().any(|p| { + f == p + || (p.ends_with('/') && f.starts_with(p)) + || f.starts_with(&format!("{}/", p)) + }) + }) .collect() } else { all_changed_files diff --git a/src/commands/hooks/checkout_hooks.rs b/src/commands/hooks/checkout_hooks.rs index add65c8a2..49804e0ec 100644 --- a/src/commands/hooks/checkout_hooks.rs +++ b/src/commands/hooks/checkout_hooks.rs @@ -180,5 +180,9 @@ fn remove_attributions_for_pathspecs(repository: &Repository, head: &str, pathsp } fn matches_any_pathspec(file: &str, pathspecs: &[String]) -> bool { - pathspecs.iter().any(|p| file == p || file.starts_with(p)) + pathspecs.iter().any(|p| { + file == p + || (p.ends_with('/') && file.starts_with(p)) + || file.starts_with(&format!("{}/", p)) + }) } diff --git a/src/commands/hooks/reset_hooks.rs b/src/commands/hooks/reset_hooks.rs index 0ea68d728..cb1716fd4 100644 --- a/src/commands/hooks/reset_hooks.rs +++ b/src/commands/hooks/reset_hooks.rs @@ -276,9 +276,11 @@ fn handle_reset_pathspec_preserve_working_dir( let mut non_pathspec_checkpoints = Vec::new(); for mut checkpoint in existing_checkpoints { checkpoint.entries.retain(|entry| { - !pathspecs - .iter() - .any(|pathspec| entry.file == *pathspec || entry.file.starts_with(pathspec)) + !pathspecs.iter().any(|pathspec| { + entry.file == *pathspec + || (pathspec.ends_with('/') && entry.file.starts_with(pathspec)) + || entry.file.starts_with(&format!("{}/", pathspec)) + }) }); if !checkpoint.entries.is_empty() { non_pathspec_checkpoints.push(checkpoint); diff --git a/tests/reset.rs b/tests/reset.rs index 4db4cecd9..54ac6572e 100644 --- a/tests/reset.rs +++ b/tests/reset.rs @@ -2,6 +2,7 @@ mod repos; use repos::test_file::ExpectedLineExt; use repos::test_repo::TestRepo; +use std::fs; /// Test git reset --hard: should discard all changes and reset to target commit #[test] @@ -542,3 +543,63 @@ fn test_reset_mixed_pathspec_multiple_commits() { "// More lib".ai(), ]); } + +/// Test git reset with directory pathspec: should reset only files in the specified directory +#[test] +fn test_reset_with_directory_pathspec() { + let repo = TestRepo::new(); + + // Create directory structure + fs::create_dir_all(repo.path().join("src")).unwrap(); + fs::create_dir_all(repo.path().join("lib")).unwrap(); + + let mut src_file = repo.filename("src/app.rs"); + let mut lib_file = repo.filename("lib/utils.rs"); + let mut root_file = repo.filename("root.txt"); + + // Base commit with files in different directories + src_file.set_contents(lines!["fn main() {}", ""]); + lib_file.set_contents(lines!["pub fn helper() {}", ""]); + root_file.set_contents(lines!["root content", ""]); + let base_commit = repo.stage_all_and_commit("Base commit").unwrap(); + + // Second commit: AI modifies files in all directories + src_file.insert_at(1, lines![" // AI src change".ai()]); + lib_file.insert_at(1, lines![" // AI lib change".ai()]); + root_file.insert_at(1, lines!["// AI root change".ai()]); + repo.stage_all_and_commit("AI changes everywhere").unwrap(); + + // Make uncommitted AI changes to lib and root (not src) + lib_file.insert_at(2, lines![" // More AI lib".ai()]); + root_file.insert_at(2, lines!["// More AI root".ai()]); + + // Reset only the src directory to base commit using directory pathspec + repo.git(&["reset", &base_commit.commit_sha, "--", "src"]) + .expect("reset with directory pathspec should succeed"); + + // Stage all and commit to verify attributions + let new_commit = repo + .stage_all_and_commit("After directory pathspec reset") + .unwrap(); + + assert!( + !new_commit.authorship_log.attestations.is_empty(), + "AI authorship should be preserved for lib and root files" + ); + + // lib/utils.rs should still have AI changes (not in reset pathspec) + lib_file = repo.filename("lib/utils.rs"); + lib_file.assert_lines_and_blame(lines![ + "pub fn helper() {}".human(), + " // AI lib change".ai(), + " // More AI lib".ai(), + ]); + + // root.txt should still have AI changes (not in reset pathspec) + root_file = repo.filename("root.txt"); + root_file.assert_lines_and_blame(lines![ + "root content".human(), + "// AI root change".ai(), + "// More AI root".ai(), + ]); +}