diff --git a/src/daemon/analyzers/history.rs b/src/daemon/analyzers/history.rs index 0fdd6fecea..17777d64bd 100644 --- a/src/daemon/analyzers/history.rs +++ b/src/daemon/analyzers/history.rs @@ -773,6 +773,7 @@ mod tests { let worktree = dir.path(); let git_dir = worktree.join(".git"); fs::create_dir_all(git_dir.join("logs")).expect("create logs"); + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); fs::write( git_dir.join("logs").join("HEAD"), concat!( diff --git a/src/daemon/trace_normalizer.rs b/src/daemon/trace_normalizer.rs index 24a8185bd6..6c521ce807 100644 --- a/src/daemon/trace_normalizer.rs +++ b/src/daemon/trace_normalizer.rs @@ -1810,6 +1810,7 @@ mod tests { let temp = tempfile::tempdir().expect("create tempdir"); let worktree = temp.path().join("repo"); fs::create_dir_all(worktree.join(".git")).expect("create git dir"); + fs::write(worktree.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); backend.set_alias(worktree.to_str().expect("utf8 worktree"), "ci", "commit"); let mut normalizer = TraceNormalizer::new(backend); @@ -2015,6 +2016,7 @@ mod tests { let outer = temp.path().join("outer"); let clone_dir = outer.join("nested").join("relative-clone"); fs::create_dir_all(clone_dir.join(".git")).expect("create clone git dir"); + fs::write(clone_dir.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let start = serde_json::json!({ "event":"start", @@ -2086,6 +2088,7 @@ mod tests { // Simulate repo discoverability only once clone is about to exit. fs::create_dir_all(clone_dir.join(".git")).expect("create clone git dir"); + fs::write(clone_dir.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let cmd = normalizer .ingest_payload(&exit) @@ -2104,7 +2107,9 @@ mod tests { let source_repo = temp.path().join("source-repo"); let cloned_repo = temp.path().join("cloned-repo"); fs::create_dir_all(source_repo.join(".git")).expect("create source git dir"); + fs::write(source_repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); fs::create_dir_all(cloned_repo.join(".git")).expect("create cloned git dir"); + fs::write(cloned_repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let start = serde_json::json!({ "event":"start", @@ -2154,6 +2159,7 @@ mod tests { let cwd = temp.path().join("projects"); // non-repo CWD let clone_dest = cwd.join("testing-git"); // the clone destination fs::create_dir_all(clone_dest.join(".git")).expect("create clone git dir"); + fs::write(clone_dest.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let root_sid = "20260327T000000.000000Z-Hdeadbeef-P00010000"; let child_sid = format!("{}/20260327T000000.000001Z-Hdeadbeef-P00010001", root_sid); @@ -2244,7 +2250,9 @@ mod tests { let repo_a = temp.path().join("repo-a"); let repo_b = temp.path().join("repo-b"); fs::create_dir_all(repo_a.join(".git")).expect("create repo-a git dir"); + fs::write(repo_a.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); fs::create_dir_all(repo_b.join(".git")).expect("create repo-b git dir"); + fs::write(repo_b.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let start_a = serde_json::json!({ "event":"start", @@ -2376,6 +2384,7 @@ mod tests { let temp = tempfile::tempdir().expect("create tempdir"); let repo = temp.path().join("repo"); fs::create_dir_all(repo.join(".git")).expect("create git dir"); + fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let start = serde_json::json!({ "event":"start", @@ -2417,6 +2426,7 @@ mod tests { let temp = tempfile::tempdir().expect("create tempdir"); let repo = temp.path().join("repo"); fs::create_dir_all(repo.join(".git")).expect("create git dir"); + fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); let start = serde_json::json!({ "event":"start", diff --git a/src/git/repo_state.rs b/src/git/repo_state.rs index 37c6c4762d..d784052c5b 100644 --- a/src/git/repo_state.rs +++ b/src/git/repo_state.rs @@ -13,11 +13,31 @@ pub struct HeadState { pub detached: bool, } +/// Returns `true` if `dot_git` looks like a valid `.git` entry. +/// +/// A `.git` *file* (worktree / submodule pointer) is always considered valid — +/// its contents will be validated later when the pointer is resolved. +/// +/// A `.git` *directory* is only valid when it contains a `HEAD` file, which is +/// a fundamental invariant of every git repository (standard, bare, worktree, +/// or submodule). An empty `.git` directory (e.g. one accidentally created by +/// docker-compose volume mounts) is rejected so that the tree-walk continues +/// upward to the real repository. +pub fn is_valid_git_dir(dot_git: &Path) -> bool { + if dot_git.is_file() { + return true; + } + if dot_git.is_dir() { + return dot_git.join("HEAD").is_file(); + } + false +} + pub fn worktree_root_for_path(path: &Path) -> Option { let mut current = Some(path); while let Some(candidate) = current { let dot_git = candidate.join(".git"); - if dot_git.is_dir() || dot_git.is_file() { + if is_valid_git_dir(&dot_git) { return Some(candidate.to_path_buf()); } current = candidate.parent(); @@ -673,6 +693,68 @@ mod tests { assert_eq!(resolved, worktree); } + #[test] + fn worktree_root_for_path_skips_empty_git_directory() { + let temp = tempfile::tempdir().unwrap(); + let real_repo = temp.path(); + // Real repo has a valid .git with HEAD + write_file(&real_repo.join(".git/HEAD"), "ref: refs/heads/main\n"); + + // Subdirectory with an empty .git (no HEAD) — should be skipped + let subdir = real_repo.join("services").join("app"); + fs::create_dir_all(subdir.join(".git")).unwrap(); + + let nested = subdir.join("src"); + fs::create_dir_all(&nested).unwrap(); + + let resolved = worktree_root_for_path(&nested).unwrap(); + assert_eq!(resolved, real_repo.to_path_buf()); + } + + #[test] + fn worktree_root_for_path_finds_valid_git_directory() { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path(); + write_file(&repo.join(".git/HEAD"), "ref: refs/heads/main\n"); + + let nested = repo.join("src"); + fs::create_dir_all(&nested).unwrap(); + + let resolved = worktree_root_for_path(&nested).unwrap(); + assert_eq!(resolved, repo.to_path_buf()); + } + + #[test] + fn is_valid_git_dir_rejects_empty_directory() { + let temp = tempfile::tempdir().unwrap(); + let dot_git = temp.path().join(".git"); + fs::create_dir_all(&dot_git).unwrap(); + assert!(!is_valid_git_dir(&dot_git)); + } + + #[test] + fn is_valid_git_dir_accepts_directory_with_head() { + let temp = tempfile::tempdir().unwrap(); + let dot_git = temp.path().join(".git"); + write_file(&dot_git.join("HEAD"), "ref: refs/heads/main\n"); + assert!(is_valid_git_dir(&dot_git)); + } + + #[test] + fn is_valid_git_dir_accepts_git_file() { + let temp = tempfile::tempdir().unwrap(); + let dot_git = temp.path().join(".git"); + fs::write(&dot_git, "gitdir: ../real/.git/worktrees/wt\n").unwrap(); + assert!(is_valid_git_dir(&dot_git)); + } + + #[test] + fn is_valid_git_dir_rejects_nonexistent() { + let temp = tempfile::tempdir().unwrap(); + let dot_git = temp.path().join(".git"); + assert!(!is_valid_git_dir(&dot_git)); + } + #[test] fn read_head_state_for_nested_path_uses_worktree_root() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/git/repository.rs b/src/git/repository.rs index 964efad162..ddf0dec780 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -2,7 +2,7 @@ use crate::authorship::rebase_authorship::rewrite_authorship_if_needed; use crate::config; use crate::error::GitAiError; use crate::git::repo_state::{ - common_dir_for_git_dir, git_dir_for_worktree, worktree_root_for_path, + common_dir_for_git_dir, git_dir_for_worktree, is_valid_git_dir, worktree_root_for_path, }; use crate::git::repo_storage::RepoStorage; use crate::git::rewrite_log::RewriteLogEvent; @@ -2428,8 +2428,9 @@ fn has_intervening_git_dir(file_path: &Path, workdir: &Path) -> bool { break; } let potential_git = workdir.join(parent).join(".git"); - if potential_git.is_dir() { - // A .git directory always indicates a separate independent repo. + if potential_git.is_dir() && is_valid_git_dir(&potential_git) { + // A valid .git directory (with HEAD) indicates a separate independent repo. + // Empty .git directories (e.g. from docker-compose volume mounts) are skipped. return true; } if potential_git.is_file() { @@ -2524,7 +2525,7 @@ pub fn find_repository_for_file( // Check for .git directory or file (file for submodules/worktrees) let git_path = dir.join(".git"); - if git_path.exists() { + if is_valid_git_dir(&git_path) { // Found a .git - but we need to check if this is a submodule // Submodules have a .git file (not directory) that points to the parent's .git/modules if git_path.is_file() { diff --git a/tests/integration/empty_git_dir.rs b/tests/integration/empty_git_dir.rs new file mode 100644 index 0000000000..3a3857675d --- /dev/null +++ b/tests/integration/empty_git_dir.rs @@ -0,0 +1,85 @@ +//! Tests that an empty `.git` directory (e.g. from a docker-compose volume mount) +//! does not fool git-ai into treating it as the repository root. +//! +//! Issue #1415: a user's docker-compose setup accidentally created an empty `.git` +//! directory in a subfolder. git-ai found it first and wrote authorship notes there +//! instead of the real repo's `.git`. + +use crate::repos::test_file::ExpectedLineExt; +use crate::repos::test_repo::TestRepo; +use std::fs; + +/// When a subdirectory contains an empty `.git` dir (no HEAD), checkpoints and +/// commits should still target the real parent repository. +#[test] +fn empty_git_subdir_does_not_hijack_attribution() { + let repo = TestRepo::new(); + + // Bootstrap the repo with an initial commit + let mut readme = repo.filename("README.md"); + readme.set_contents(crate::lines!["# Root repo"]); + repo.stage_all_and_commit("initial commit").unwrap(); + + // Create a subdirectory with an empty .git directory (simulates docker-compose + // accidentally creating one via a volume mount) + let subdir = repo.path().join("services").join("app"); + fs::create_dir_all(subdir.join(".git")).unwrap(); + + // Write a file inside that subdirectory + let file_path = subdir.join("main.txt"); + fs::write(&file_path, "Human line\n").unwrap(); + + // Fire a known-human checkpoint scoped to the file + repo.git_ai(&["checkpoint", "mock_known_human", "services/app/main.txt"]) + .unwrap(); + + // Now simulate an AI edit + fs::write(&file_path, "Human line\nAI line\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "services/app/main.txt"]) + .unwrap(); + + // Commit in the real repo + repo.stage_all_and_commit("add services/app/main.txt") + .unwrap(); + + // Verify attribution lands in the real repo, not in the empty .git + let mut file = repo.filename("services/app/main.txt"); + file.assert_committed_lines(crate::lines!["Human line".human(), "AI line".ai(),]); +} + +/// Verify that when the empty `.git` dir is between the file and the real repo, +/// worktree discovery still reaches the real repo root. +#[test] +fn empty_git_subdir_skipped_during_worktree_discovery() { + let repo = TestRepo::new(); + + let mut readme = repo.filename("README.md"); + readme.set_contents(crate::lines!["# Root repo"]); + repo.stage_all_and_commit("initial commit").unwrap(); + + // Empty .git in an intermediate directory + let intermediate = repo.path().join("packages").join("core"); + fs::create_dir_all(intermediate.join(".git")).unwrap(); + + // A deeper nested file + let deep = intermediate.join("src"); + fs::create_dir_all(&deep).unwrap(); + let file_path = deep.join("lib.txt"); + + fs::write(&file_path, "line one\n").unwrap(); + repo.git_ai(&[ + "checkpoint", + "mock_known_human", + "packages/core/src/lib.txt", + ]) + .unwrap(); + + fs::write(&file_path, "line one\nai added\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "packages/core/src/lib.txt"]) + .unwrap(); + + repo.stage_all_and_commit("deep nested file").unwrap(); + + let mut file = repo.filename("packages/core/src/lib.txt"); + file.assert_committed_lines(crate::lines!["line one".human(), "ai added".ai(),]); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index cdcc4dfc02..bffcd2de17 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -52,6 +52,7 @@ mod diff_ignore_binary; mod droid; mod e2big_post_filter; mod e2e_user_scenarios; +mod empty_git_dir; mod event_timestamp_extraction; mod fast_reader; mod fetch_notes;