Skip to content

Commit dc0a980

Browse files
authored
fix(parser): attribute Git worktrees to repositories (#1294)
Activity reports currently fragment repository activity under short-lived worktree directory names when linked worktrees are backed by a bare Git repository or their checkout has already been removed. This change derives live bare-backed repositories from the common Git directory without spawning Git, and recognizes standard hosted worktree layouts when Git metadata is unavailable. Tool-anchored manager layouts keep their explicit path precedence, while generic hosted patterns defer to a live enclosing repository so matching fixture or nested paths are not misattributed. The parser data version advances so existing source-backed sessions are reparsed through the established non-destructive full-resync flow. Archived sessions whose original source is permanently unavailable are preserved, but their previous project attribution cannot be recomputed. The main review points are the filesystem-only `core.bare` detection, the anchored-versus-generic layout precedence, and the data-version-triggered repair path. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent 8ae2f92 commit dc0a980

4 files changed

Lines changed: 232 additions & 9 deletions

File tree

internal/db/db.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,13 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
333333
// (74: Claude Code IDE context reparse. Standalone ide_opened_file and
334334
// ide_selection wrappers are promoted to system metadata so existing
335335
// VS Code sessions no longer use them as titles or user turns.)
336-
const dataVersion = 74
336+
// (75: Git worktree project attribution reparse. Hosting-oriented worktree
337+
// paths retain the owning repository after checkout removal, live linked
338+
// worktrees backed by bare common repositories resolve to the repository
339+
// instead of the generated checkout leaf, and generic hosting fragments defer
340+
// to an enclosing live repository. Existing rows need re-parsing so activity
341+
// is neither fragmented by worktree names nor claimed by nested fixture paths.)
342+
const dataVersion = 75
337343

338344
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
339345

internal/db/db_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,9 +1008,9 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10081008
"expected tool_result_events table after reopen")
10091009
}
10101010

1011-
func TestCurrentDataVersionClaudeIDEContext(t *testing.T) {
1012-
assert.Equal(t, 74, CurrentDataVersion(),
1013-
"Claude IDE context parsing requires a data version bump")
1011+
func TestCurrentDataVersionGitWorktreeProjectAttribution(t *testing.T) {
1012+
assert.Equal(t, 75, CurrentDataVersion(),
1013+
"final git worktree project attribution requires a data version bump")
10141014
}
10151015

10161016
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/project.go

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,10 @@ func extractProjectFromCwdWithBranch(
115115
}
116116
cleaned := filepath.Clean(norm)
117117

118-
// Recognize worktree manager layouts before walking git roots.
119-
// These layouts encode the owning project in the path even when
118+
// Recognize tool-anchored worktree manager layouts before walking git
119+
// roots. These layouts encode the owning project in the path even when
120120
// the git root basename is a branch or generated worktree id.
121-
if p := projectFromWorktreeLayout(cleaned); p != "" {
121+
if p := projectFromAnchoredWorktreeLayout(cleaned); p != "" {
122122
return NormalizeName(p)
123123
}
124124

@@ -137,6 +137,13 @@ func extractProjectFromCwdWithBranch(
137137
}
138138
}
139139

140+
// Generic hosting layouts are intentionally a fallback after live Git
141+
// metadata. Otherwise a normal repository containing a matching fixture
142+
// path would be attributed to the fixture's repository component.
143+
if p := projectFromWorktreeLayout(cleaned); p != "" {
144+
return NormalizeName(p)
145+
}
146+
140147
name := filepath.Base(cleaned)
141148
if isInvalidPathBase(name) {
142149
return ""
@@ -156,6 +163,7 @@ type worktreeLayout struct {
156163
projectPart int
157164
minParts int
158165
roborevCIBareLayout bool
166+
gitFallbackOnly bool
159167
}
160168

161169
var worktreeLayouts []worktreeLayout
@@ -169,8 +177,21 @@ func init() {
169177
{marker: sep + ".superset" + sep + "worktrees" + sep, projectPart: 0, minParts: 2},
170178
// conductor/workspaces/$PROJECT/$BRANCH[/...]
171179
{marker: sep + "conductor" + sep + "workspaces" + sep, projectPart: 0, minParts: 2},
172-
// ~/.config/middleman/worktrees/github.com/$OWNER/$REPO/$WORKTREE[/...]
173-
{marker: sep + ".config" + sep + "middleman" + sep + "worktrees" + sep + "github.com" + sep, projectPart: 1, minParts: 3},
180+
// .../worktrees/github/github.com/$OWNER/$REPO/$WORKTREE[/...]
181+
{
182+
marker: sep + "worktrees" + sep + "github" + sep +
183+
"github.com" + sep,
184+
projectPart: 1,
185+
minParts: 3,
186+
gitFallbackOnly: true,
187+
},
188+
// .../worktrees/github.com/$OWNER/$REPO/$WORKTREE[/...]
189+
{
190+
marker: sep + "worktrees" + sep + "github.com" + sep,
191+
projectPart: 1,
192+
minParts: 3,
193+
gitFallbackOnly: true,
194+
},
174195
// ~/.codex/worktrees/$WORKTREE_ID/$REPO[/...]
175196
{marker: sep + ".codex" + sep + "worktrees" + sep, projectPart: 1, minParts: 2},
176197
// roborev CI: ~/.roborev/ci-worktrees/$REPO/roborev-ci-<jobID>-<id>[/...].
@@ -190,7 +211,18 @@ func init() {
190211
// directory layouts and extracts the project name component.
191212
// Returns "" if the path does not match any known layout.
192213
func projectFromWorktreeLayout(path string) string {
214+
return projectFromWorktreeLayouts(path, true)
215+
}
216+
217+
func projectFromAnchoredWorktreeLayout(path string) string {
218+
return projectFromWorktreeLayouts(path, false)
219+
}
220+
221+
func projectFromWorktreeLayouts(path string, includeGitFallbacks bool) string {
193222
for _, layout := range worktreeLayouts {
223+
if layout.gitFallbackOnly && !includeGitFallbacks {
224+
continue
225+
}
194226
_, rest, found := strings.Cut(path, layout.marker)
195227
if !found {
196228
continue
@@ -702,6 +734,15 @@ func repoRootFromGitFile(repoDir, gitFilePath string) string {
702734
if filepath.Base(commonDir) == ".git" {
703735
return filepath.Dir(commonDir)
704736
}
737+
if gitConfigCoreBare(commonDir) {
738+
// Bare repositories have no main checkout root. Return a
739+
// conceptual sibling path so the caller can use its basename
740+
// as the stable repository name.
741+
name := strings.TrimSuffix(filepath.Base(commonDir), ".git")
742+
if !isInvalidPathBase(name) {
743+
return filepath.Join(filepath.Dir(commonDir), name)
744+
}
745+
}
705746
}
706747

707748
// Fallback for linked worktrees if commondir is missing.
@@ -750,6 +791,52 @@ func readCommonDir(gitDir string) string {
750791
return filepath.Clean(filepath.Join(gitDir, value))
751792
}
752793

794+
func gitConfigCoreBare(gitDir string) bool {
795+
b, err := os.ReadFile(filepath.Join(gitDir, "config"))
796+
if err != nil {
797+
return false
798+
}
799+
800+
inCore := false
801+
for raw := range strings.SplitSeq(string(b), "\n") {
802+
line := strings.TrimSpace(raw)
803+
if line == "" ||
804+
strings.HasPrefix(line, "#") ||
805+
strings.HasPrefix(line, ";") {
806+
continue
807+
}
808+
if strings.HasPrefix(line, "[") {
809+
end := strings.IndexByte(line, ']')
810+
if end < 0 {
811+
inCore = false
812+
continue
813+
}
814+
section := strings.TrimSpace(line[1:end])
815+
section, _, _ = strings.Cut(section, " ")
816+
inCore = strings.EqualFold(section, "core")
817+
continue
818+
}
819+
if !inCore {
820+
continue
821+
}
822+
823+
key, value, hasValue := strings.Cut(line, "=")
824+
if !strings.EqualFold(strings.TrimSpace(key), "bare") {
825+
continue
826+
}
827+
if !hasValue {
828+
return true
829+
}
830+
switch strings.ToLower(strings.TrimSpace(value)) {
831+
case "true", "yes", "on", "1":
832+
return true
833+
default:
834+
return false
835+
}
836+
}
837+
return false
838+
}
839+
753840
func trimBranchSuffix(name, gitBranch string) string {
754841
branch := strings.TrimSpace(gitBranch)
755842
if name == "" || branch == "" {

internal/parser/project_git_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,54 @@ func TestExtractProjectFromCwdWithBranchContext_GitWorktreeMainRoot(t *testing.T
106106
"kit-backed worktree resolution should use the main repo name")
107107
}
108108

109+
func TestExtractProjectFromCwd_BareBackedGitWorktree(t *testing.T) {
110+
skipIfNoGit(t)
111+
112+
root := t.TempDir()
113+
source := filepath.Join(root, "source")
114+
bareRepo := filepath.Join(root, "shared", "sample-repo.git")
115+
worktree := filepath.Join(root, "checkouts", "generated-leaf")
116+
subdir := filepath.Join(worktree, "internal", "parser")
117+
118+
mustMkdirAll(t, source)
119+
mustMkdirAll(t, filepath.Dir(bareRepo))
120+
mustMkdirAll(t, filepath.Dir(worktree))
121+
gitRun(t, source, "init", "-q", "-b", "main")
122+
gitRun(t, source,
123+
"-c", "user.email=test@example.com",
124+
"-c", "user.name=Test User",
125+
"-c", "commit.gpgsign=false",
126+
"commit", "--allow-empty", "-q", "-m", "seed",
127+
)
128+
gitRun(t, root, "clone", "--bare", "-q", source, bareRepo)
129+
gitRun(t, root,
130+
"--git-dir", bareRepo,
131+
"worktree", "add", "-q", "-b", "feature", worktree, "main",
132+
)
133+
mustMkdirAll(t, subdir)
134+
135+
assert.Equal(t, "sample_repo", ExtractProjectFromCwd(subdir))
136+
}
137+
138+
func TestRepoRootFromGitFileDoesNotTreatNonBareCommonDirAsBare(
139+
t *testing.T,
140+
) {
141+
root := t.TempDir()
142+
checkout := filepath.Join(root, "checkouts", "generated-leaf")
143+
commonDir := filepath.Join(root, "shared", "sample-repo.git")
144+
gitDir := filepath.Join(commonDir, "worktrees", "generated-leaf")
145+
gitFile := filepath.Join(checkout, ".git")
146+
147+
mustMkdirAll(t, checkout)
148+
mustMkdirAll(t, gitDir)
149+
mustWriteFile(t, gitFile, "gitdir: "+gitDir+"\n")
150+
mustWriteFile(t, filepath.Join(gitDir, "commondir"), "../..\n")
151+
mustWriteFile(t, filepath.Join(commonDir, "config"),
152+
"[core]\n\tbare = false\n")
153+
154+
assert.Equal(t, checkout, repoRootFromGitFile(checkout, gitFile))
155+
}
156+
109157
func TestExtractProjectFromCwdPlainRepoDoesNotInvokeGit(t *testing.T) {
110158
if runtime.GOOS == "windows" {
111159
t.Skip("test uses a POSIX shell git shim")
@@ -546,6 +594,88 @@ func TestExtractProjectFromCwdWithBranch_NestedWorktree(
546594
"ExtractProjectFromCwdWithBranch(%q, %q)", deleted, "tauri-packaging")
547595
}
548596

597+
func TestExtractProjectFromCwd_HostingWorktreeLayouts(t *testing.T) {
598+
root := t.TempDir()
599+
tests := []struct {
600+
name string
601+
parts []string
602+
want string
603+
}{
604+
{
605+
name: "HostingWorktree",
606+
parts: []string{
607+
"worktrees", "github.com", "example-org",
608+
"sample-repo", "feature-branch",
609+
},
610+
want: "sample_repo",
611+
},
612+
{
613+
name: "HostingWorktreeSubdirectory",
614+
parts: []string{
615+
"worktrees", "github.com", "example-org",
616+
"sample-repo", "feature-branch", "internal", "parser",
617+
},
618+
want: "sample_repo",
619+
},
620+
{
621+
name: "NamespacedHostingWorktree",
622+
parts: []string{
623+
"worktrees", "github", "github.com", "example-org",
624+
"data-pipeline", "pr-17",
625+
},
626+
want: "data_pipeline",
627+
},
628+
{
629+
name: "NamespacedHostingWorktreeSubdirectory",
630+
parts: []string{
631+
"worktrees", "github", "github.com", "example-org",
632+
"data-pipeline", "pr-17", "cmd", "worker",
633+
},
634+
want: "data_pipeline",
635+
},
636+
}
637+
638+
for _, tt := range tests {
639+
t.Run(tt.name, func(t *testing.T) {
640+
cwd := filepath.Join(append([]string{root}, tt.parts...)...)
641+
assert.Equal(t, tt.want, ExtractProjectFromCwd(cwd))
642+
})
643+
}
644+
}
645+
646+
func TestExtractProjectFromCwd_HostingLayoutInsideGitRepoPrefersRepo(
647+
t *testing.T,
648+
) {
649+
root := t.TempDir()
650+
repo := filepath.Join(root, "outer-repo")
651+
cwd := filepath.Join(
652+
repo, "worktrees", "github.com", "example-org",
653+
"sample-repo", "fixture",
654+
)
655+
656+
mustMkdirAll(t, filepath.Join(repo, ".git"))
657+
mustMkdirAll(t, cwd)
658+
659+
assert.Equal(t, "outer_repo", ExtractProjectFromCwd(cwd))
660+
}
661+
662+
func TestProjectFromWorktreeLayoutRequiresWorktreeLeaf(t *testing.T) {
663+
root := t.TempDir()
664+
tests := []string{
665+
filepath.Join(
666+
root, "worktrees", "github.com", "example-org", "sample-repo",
667+
),
668+
filepath.Join(
669+
root, "worktrees", "github", "github.com",
670+
"example-org", "sample-repo",
671+
),
672+
}
673+
674+
for _, path := range tests {
675+
assert.Empty(t, projectFromWorktreeLayout(path), path)
676+
}
677+
}
678+
549679
func TestExtractProjectFromCwdWithBranch(t *testing.T) {
550680
tests := []struct {
551681
name string

0 commit comments

Comments
 (0)