Skip to content

fix(stats): count every worktree of a repo as that repo (#809) - #813

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-809-repo-attribution
Sep 24, 2026
Merged

jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-809-repo-attribution

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

stats --by-repo, the dashboard, session save and the wiki slug treated each git worktree as its own project, because events recorded only their cwd. Events now record the repo's main checkout, and every consumer attributes sessions to it.

 hook-dispatch handler (and legacy `teamai dashboard-report`)
   resolveConfigForDir(cwd) -> resolveAnchors(cwd)       # now memoized per dir
   event.dataHome = getDataHome(config)
+  event.projectAnchor = eventProjectAnchor(event.cwd)   # unset: no cwd (Copilot), no git, cwd gone
   appendEvent

+repoKeys(events)    session -> last projectAnchor it recorded ?? last cwd
+repoName(anchor)    directory name; a bare git dir (repo/.bare, repo.git) -> repo
+repoLabel(key, all) repoName, or parent/name on a clash between path keys (work/api)
Case Before After
stats --by-repo in the repo my-repo 2 / wt-demo 2 / src 1 sess my-repo 5 sess
two unrelated repos named api api 2 sess work/api 1 / personal/api 1
session save (worktree) Project: `/…/wt-demo` Project: `my-repo` + Directory:
import --dir . / codebase --extract in the worktree slug wt-demo slug my-repo
bare repo repo/.bare + main, feature slugs main, feature; rows main, feature slug and row repo
dashboard after git worktree remove "Unassigned sessions" my-repo, listed once in the filter

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

The e2e test drives the built CLI (sandbox HOME, local team repo, a worktree outside the checkout, hook payloads on stdin):

setup  sessions in repo, repo/src, worktree (both writers), HOME then repo, two unrelated repos named api
1. stats --by-repo in the repo           -> [[my-repo, 5]]
2. stats --by-repo in HOME               -> [work/api, 1], [personal/api, 1]
3. session save --session-id wt-1        -> Project: `my-repo`, Directory: `<worktree>`
4. import --dir . --dry-run; --extract   -> (project: my-repo); evidence/code/my-repo
5. git worktree remove; teamai dashboard -> my-repo holds all 5 sessions; filter options ["my-repo"]

On origin/main all five fail, e.g. expected [ [ 'my-repo', 2 ], …(2) ] to deeply equal [ [ 'my-repo', 5 ] ]; with this PR all pass.

Removing any one of 30 parts (writers, memo, filter, slug, repoName, remote-key rules) and rebuilding fails a test.

Reproduced with the real CLI, git mode: worktree
$ teamai pull --force        # in my-repo: legacy .teamai/ moves into its partition
$ git worktree add ../wt-demo -b wt-demo
$ teamai stats --by-repo     # in my-repo
  my-repo  5 sess, 5 turns, 5 tools
$ teamai session save --session-id wt-1
- Project: `my-repo`
- Directory: `…/wt-demo`
$ teamai import --dir . --dry-run   # in wt-demo
ℹ Scanning local directory: …/wt-demo (project: my-repo)
$ git worktree remove ../wt-demo; teamai dashboard   # /api/workspaces, /api/sessions
my-repo (project): cd-1, main-1, main-2, main-3, wt-1
  Repository filter options: ["my-repo"]
Unassigned sessions: -
Reproduced with the real CLI: bare repo (repo/.bare + repo/main + repo/feature)
$ teamai stats --by-repo     # user scope, one session per worktree
  repo  2 sess, 2 turns, 2 tools
$ teamai import --dir . --dry-run   # in repo/main, then repo/feature
ℹ Scanning local directory: …/repo/main (project: repo)
ℹ Scanning local directory: …/repo/feature (project: repo)
$ teamai codebase --extract .       # in repo/feature; ls teamwiki/evidence/code
repo
$ teamai session save --session-id s-feature
- Project: `repo`

From reading the code, not reproduced with the real CLI:

  • Copilot events get no anchor (unit test through the real dispatcher).
  • filterEventsByScope's legacy branch (src/team-push.ts:415) is unchanged.
  • Remote-form keys: unit-tested only.

Not run: gitlab/github providers and other agents; attribution depends on neither (left to CI).

Related Issues

Closes #809

Notes for Reviewers

  • Two writers. Current installs write through the hook-dispatch handler (src/hook-handlers.ts:189), not only dashboardReport. Both call eventProjectAnchor, which skips a missing cwd without git, so [bug] hook events after a worktree is deleted fall back to the user scope #810 changes one place. process_exit copies the anchor, like dataHome.
  • Memo. resolveAnchors keeps non-null results for the process's life, so the hook's second lookup runs no git inside a repo. null is not cached, per the issue. A long-lived dashboard keeps a removed worktree's answer, which attribution wants.
  • Wiki slug, narrower than the issue's rule. Only a linked worktree's root takes the repo's name; subdirectories keep theirs (--extract packages/api stays api). repoName detects a bare git dir on disk (HEAD and objects/, no .git). Gap: a main checkout reached through a differently named symlink keeps that name.
  • Labels. Rows group by label, so no_repo dirs still share a row; a repo keeps its name even in a dir like ~/workspace. Only path keys get a parent prefix, then the whole path on a second collision; remote keys keep owner/repo, one row across hosts. session save labels against the whole log.
  • Dashboard discovery reads event anchors; git runs only for events lacking one or whose anchor has no config.
  • Reversible. No migration; the field is optional and events without it fall back to cwd.
  • Follow-ups. Remote key github.com/acme/api and local /x/acme/api both label acme/api (src/utils/repo-attribution.ts:138-141); hooks never write remote keys. Unrelated flake: local-agent.test.ts > emits hint only once per sessionId shares an os.tmpdir() marker keyed by a fixed session id (src/local-agent.ts:1191).
  • Not in this PR, per the issue: backfill of old events, the legacy filterEventsByScope branch, already-pushed wiki entries, [bug] hook events after a worktree is deleted fall back to the user scope #810, a per-worktree breakdown.
  • Docs: data-directory-layout.md (projectAnchor, plus the fix(report): each scope keeps its own reported dashboard snapshots (#786) #795 scope note), dashboard-unified{,.zh-CN}.md and usage-guide{,.zh-CN}.md (repository filter, slug), --project help and regenerated commands.md.

@jeff-r2026 jeff-r2026 self-assigned this Sep 24, 2026
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] src/codebase-extract.ts:548 — Bare-repository worktrees are explicitly excluded from anchor-based slugging. For a bare repo with worktrees main and feature, running codebase --extract or import --dir from each still writes separate main and feature evidence trees, so the wiki consumer does not satisfy the PR’s worktree-unification goal.
  • [P3 nit] src/utils/repo-attribution.ts:95 — Collision handling breaks the existing remote canonicalization contract. Events keyed as github.com/Tencent/teamai-cli and cnb.cool/Tencent/teamai-cli both canonicalize to Tencent/teamai-cli, but repoLabel() detects that collision and returns the raw host-qualified keys, producing two stats rows for the same repository.

The PR description includes sufficient real-CLI end-to-end verification for this runtime change.

@SaulMoro
SaulMoro force-pushed the fix-809-repo-attribution branch from ed7143f to 69e9788 Compare September 24, 2026 16:21
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re: #813 (comment)

Thanks, both confirmed and fixed in 69e9788.

  1. [P2] bare-repo worktrees and the slug. Fixed. A new repoName(anchor) (src/utils/repo-attribution.ts) is shared by the slug and repoLabel. It detects a bare git dir on disk (HEAD and objects/, no .git) and names it after its repo: repo.git → repo, and repo/.bare or repo/.git → repo. In repo/.bare + repo/main + repo/feature, both worktrees now write teamwiki/evidence/code/repo/, and stats shows a single repo row. That also fixes the .bare label noted as a follow-up, and a session in the bare layout's own directory (repo/) counts as the same repo. Only a worktree's root is renamed; subdirectories keep their own name. Covered by unit tests (anchors.test.ts, session-analytics.test.ts) and a real-CLI run in the PR body.

  2. [P3] remote-form keys split by the collision handling. Fixed. Only absolute-path keys take part in collision handling now. github.com/Tencent/teamai-cli and cnb.cool/Tencent/teamai-cli both keep Tencent/teamai-cli and merge into one row again. Two local repos that share a basename still split into work/api and personal/api. Covered by a unit test on repoLabel and attributeByRepo.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/dashboard/client.ts:37 — The filter now groups by repository rather than working directory, but docs/usage-guide.md:1447 and docs/usage-guide.zh-CN.md:1386 still document a working-directory filter. This violates the repository rule requiring every affected bilingual document to be updated for behavior changes.
  • [P2 non-blocking] src/utils/repo-attribution.ts:128 — Absolute projectAnchor paths are passed through attributeRepo() before repoName(). A valid Git repository named workspace, home, data, etc. is therefore labeled no_repo; all its worktrees disappear into the unassigned stats row and session save records Project: no_repo.
  • [P3 nit] src/utils/repo-attribution.ts:132 — Remote keys bypass collision handling. With keys such as github.com/acme/api, /x/acme/api, and /y/other/api, the first local path and remote key both receive acme/api, causing attributeByRepo() to merge unrelated sessions.

The two earlier findings are resolved in the current diff: bare-repository worktrees now use the repository name, and equivalent remote-host keys canonicalize to one row. The PR description includes sufficient real-CLI end-to-end verification.

`stats --by-repo`, the dashboard, `session save` and the wiki slug of
`import --dir` / `codebase --extract` derived the project from a
per-worktree path, so each worktree became its own project, and once a
worktree was removed git could no longer map its path back to the repo.

The hook already resolves the repo's main checkout to find the config and
dropped it. Both event writers (the hook-dispatch handler and the legacy
`dashboard-report`) now store it as `projectAnchor`, resolved for the
event's cwd and skipped when that directory is gone. An event with no cwd
(Copilot, which keeps paths out of its events) gets none either. The
dashboard's process_exit event copies the session's anchor.
`resolveAnchors` remembers each directory's non-null result for the life of
the process, so the second lookup runs no git.

- repoKeys: a session's key is the last anchor it recorded, else its last
  cwd. repoLabel names a key by its repo (repoName), qualified by the
  parent when two path keys share a name, and by the whole key if that
  still collides. repoName is the directory's name, except for a bare git
  directory (HEAD and objects/, no .git): `repo.git` -> repo, and
  `repo/.bare` -> repo; keys named by the same directory share a label.
  A key that is a repo keeps its name even when attributeRepo reserves it
  for non-project directories (a repo in ~/workspace).
  Remote-form keys keep their canonical owner/repo, so the same repo on
  two hosts stays one row.
- stats --by-repo groups sessions under repoLabel(repoKey); directories
  that are not a repo still share one no_repo row.
- dashboard: an event belongs to the project rooted at its anchor, else the
  one holding its cwd; discovery uses the anchor and asks git only for
  events without one, or whose anchor holds no config (a legacy .teamai/
  in a worktree). Sessions carry repoKey/repoLabel, which the
  Repository filter uses; rows keep showing the cwd.
- session save: Project: is the repo label, Directory: the cwd.
- wiki slug: without --project, a linked worktree's root takes the repo's
  name (repoName), so a bare repo's worktrees share it too. Any other
  directory keeps its own name, including subdirectories, so per-package
  slugs in a monorepo do not change. The issue's rule would have renamed
  those too.
@SaulMoro
SaulMoro force-pushed the fix-809-repo-attribution branch from 69e9788 to 8c72294 Compare September 24, 2026 16:38
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re: #813 (comment)

Fixed in 8c72294:

  1. [P1] docs for the Repository filter. Fixed. docs/usage-guide.md and .zh-CN.md now say the Execution page filters by repository, with every worktree of a repo as one entry. docs/designs/dashboard-unified{,.zh-CN}.md had the same stale "working-directory filter" wording and is fixed too.
  2. [P2] repos in a directory named like workspace. Fixed. A key that is itself a repository (a checkout with .git, or a bare git dir) keeps its name and skips attributeRepo's non-project words. So a repo at ~/workspace and its worktrees are one workspace row, and session save prints Project: workspace. A plain directory with that name is still no_repo. Covered by a unit test for workspace, home and data.
  3. [P3] remote key vs a colliding local path. Left as a follow-up in the PR notes (src/utils/repo-attribution.ts:138-141). It needs remote-form keys, and hooks only record filesystem paths.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] src/codebase-extract.ts:548 — A main checkout accessed through a differently named symlink keeps the symlink basename, while its linked worktrees use the real main-checkout name. For example, extracting /tmp/alias -> /repos/my-repo creates alias, but extracting a linked worktree creates my-repo, splitting one repository’s evidence.
  • [P3 nit] src/utils/repo-attribution.ts:138 — Remote keys still bypass collision handling. With github.com/acme/api, /x/acme/api, and /y/other/api, the remote and first local repository both receive acme/api, so stats --by-repo merges unrelated sessions.

The earlier bare-repository, reserved-name, remote-host canonicalization, and bilingual documentation findings are resolved. The PR description includes sufficient real-CLI end-to-end verification.

@jeff-r2026
jeff-r2026 self-requested a review September 24, 2026 17:38
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Follow-ups from this PR's Notes and its last review pass are tracked in #823 (Cleanup: the symlinked main-checkout slug at codebase-extract.ts:548, the remote key vs local path label collision at repo-attribution.ts:139-145, and the flaky local-agent.test.ts).

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 24, 2026
Merges Tencent#817 (docs mirror), Tencent#788/Tencent#790 and Tencent#809/Tencent#813.

The docs mirror now targets the member's resolved docs set: it copies
only the delivered files and prunes every local entry the team repo does
not have, but a local copy of a team doc in an inactive namespace follows
the Tencent#707 rule (removed when byte-equal, kept and named when edited).
Withdrawal runs inside the mirror, after the single-repo early return, so
it never deletes from the team repo's own docs/. Doctor expects the
delivered set and does not report a withheld namespace's team doc as
stale. Tencent#817's failure handling (docsSyncFailed, cleared revision,
result.completed) is kept.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] stats, dashboard, session save and wiki slugs split one repo into one project per worktree

2 participants