Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/daemon/analyzers/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
10 changes: 10 additions & 0 deletions src/daemon/trace_normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
84 changes: 83 additions & 1 deletion src/git/repo_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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();
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 5 additions & 4 deletions src/git/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
85 changes: 85 additions & 0 deletions tests/integration/empty_git_dir.rs
Original file line number Diff line number Diff line change
@@ -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(),]);
}
1 change: 1 addition & 0 deletions tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down