Skip to content
Open
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
63 changes: 63 additions & 0 deletions tests/test_worktree_detect_project.py
Original file line number Diff line number Diff line change
@@ -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: <path>". 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"]))
69 changes: 54 additions & 15 deletions trace_core/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<root>/.git`` and the root is its parent. For a
git worktree, ``.git`` is a *file* containing ``gitdir: <path>``; 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.

Expand Down Expand Up @@ -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
Expand Down