diff --git a/tests/test_worktree_detect_project.py b/tests/test_worktree_detect_project.py new file mode 100644 index 0000000..a9f7457 --- /dev/null +++ b/tests/test_worktree_detect_project.py @@ -0,0 +1,63 @@ +"""Regression tests for worktree-aware detect_project(). + +Drop into the upstream `dschartman/trace` test suite (e.g. tests/test_projects.py +or a standalone module). Covers the data-loss bug where a `trc` write from +inside a git worktree truncated the parent checkout's .trace/issues.jsonl. +""" +import subprocess +from pathlib import Path + +import pytest + +from trace_core.projects import detect_project, _resolve_git_dir + + +def _git(cwd, *args): + subprocess.run( + ["git", "-c", "commit.gpgsign=false", *args], + cwd=str(cwd), check=True, capture_output=True, text=True, + ) + + +def test_resolve_git_dir_handles_pointer_file(tmp_path): + # A worktree's .git is a file: "gitdir: ". Build that shape by hand. + canonical = tmp_path / ".git" + canonical.mkdir() + wt_gitdir = canonical / "worktrees" / "wt" + wt_gitdir.mkdir(parents=True) + (wt_gitdir / "commondir").write_text("../..\n") + wt = tmp_path / "wt" + wt.mkdir() + (wt / ".git").write_text(f"gitdir: {wt_gitdir}\n") + + cgd, root = _resolve_git_dir(wt / ".git") + assert cgd == canonical + assert root == tmp_path + + +def test_detect_project_identity_matches_across_worktree(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "t@t.co") + _git(repo, "config", "user.name", "t") + _git(repo, "remote", "add", "origin", "https://github.com/example/repo.git") + (repo / "f.txt").write_text("x") + _git(repo, "add", "f.txt") + _git(repo, "commit", "-m", "init") + _git(repo, "worktree", "add", "wt") + + main = detect_project(str(repo)) + wt = detect_project(str(repo / "wt")) + + assert main is not None and wt is not None + # The worktree must resolve to the SAME project identity + root as the main + # checkout — otherwise export_to_jsonl writes a per-worktree subset and + # truncates the parent's issues.jsonl. + assert wt == main + assert wt["path"] == str(repo) + assert wt["id"] == "github.com/example/repo" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/trace_core/projects.py b/trace_core/projects.py index 19e0fbd..9a6422c 100644 --- a/trace_core/projects.py +++ b/trace_core/projects.py @@ -17,6 +17,37 @@ ] +def _resolve_git_dir(dot_git: Path): + """Resolve a .git entry that may be a worktree pointer file. + + Returns (canonical_git_dir, project_root). For a normal checkout the + canonical git dir is ``/.git`` and the root is its parent. For a + git worktree, ``.git`` is a *file* containing ``gitdir: ``; we follow + it to the worktree's gitdir, read its ``commondir`` to find the canonical + ``.git``, and return the main checkout root. Returns (None, None) when the + entry is not a usable git dir/pointer, so detect_project keeps walking up. + """ + if dot_git.is_dir(): + return dot_git, dot_git.parent + if dot_git.is_file(): + try: + content = dot_git.read_text().strip() + except OSError: + return None, None + if not content.startswith("gitdir:"): + return None, None + worktree_gitdir = Path(content.split(":", 1)[1].strip()) + if not worktree_gitdir.is_absolute(): + worktree_gitdir = (dot_git.parent / worktree_gitdir).resolve() + commondir_file = worktree_gitdir / "commondir" + if commondir_file.is_file(): + commondir = (worktree_gitdir / commondir_file.read_text().strip()).resolve() + else: + commondir = worktree_gitdir + return commondir, commondir.parent + return None, None + + def detect_project(cwd: Optional[str] = None) -> Optional[Dict[str, str]]: """Detect project from git repository. @@ -45,27 +76,35 @@ def detect_project(cwd: Optional[str] = None) -> Optional[Dict[str, str]]: # Walk up directory tree looking for .git for parent in [current_path] + list(current_path.parents): - git_dir = parent / ".git" + dot_git = parent / ".git" + + if not dot_git.exists(): + continue + + # Resolve worktree pointer files to the canonical .git + main checkout + # root, so a `trc` write from inside a worktree does not register a new + # per-worktree project and truncate the parent's issues.jsonl. + canonical_git_dir, project_root = _resolve_git_dir(dot_git) + if canonical_git_dir is None or project_root is None: + continue - if git_dir.exists(): - # Found a git repository - project_path = str(parent.absolute()) + project_path = str(project_root.absolute()) - # Try to extract project_id and name from git remote - project_id = _extract_project_id_from_git_remote(git_dir) - project_name = _extract_name_from_git_remote(git_dir) + # Try to extract project_id and name from git remote + project_id = _extract_project_id_from_git_remote(canonical_git_dir) + project_name = _extract_name_from_git_remote(canonical_git_dir) - # Fall back to absolute path and directory name if no remote found - if not project_id: - project_id = project_path + # Fall back to absolute path and directory name if no remote found + if not project_id: + project_id = project_path - if not project_name: - project_name = parent.name + if not project_name: + project_name = project_root.name - # Sanitize the project name - project_name = sanitize_project_name(project_name) + # Sanitize the project name + project_name = sanitize_project_name(project_name) - return {"id": project_id, "name": project_name, "path": project_path} + return {"id": project_id, "name": project_name, "path": project_path} # Not in a git repository return None