Skip to content

Follow symlinked child repos in workspace discovery - #328

Open
L4XB wants to merge 1 commit into
trailhq:mainfrom
L4XB:fix/workspace-discovery-follow-symlinked-children
Open

Follow symlinked child repos in workspace discovery#328
L4XB wants to merge 1 commit into
trailhq:mainfrom
L4XB:fix/workspace-discovery-follow-symlinked-children

Conversation

@L4XB

@L4XB L4XB commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #319

discoverWorkspaceChildren() filtered readdirSync(root, { withFileTypes: true }) with e.isDirectory(), which is false for a symlink, so a child that is a symlink to a git repo was silently left out of graft build <workspace> (not built, not in workspace.json, no warning). A git worktree (.git file) was fine, and graft build ws/repoC-link on its own worked, which made the omission surprising.

Change: an entry is a workspace child when it is a real directory or a symlink that resolves to a directory. A symlinked child is followed one level, consistent with how walkDir canonicalises a symlinked root once. Dangling links, links to files, and links that resolve to the workspace root or one of its ancestors (a cycle) are not children.

Test: discoverWorkspaceChildren follows a symlinked child repo, not dangling or cyclic links (#319) in test/graph-scopes.test.ts builds a workspace with a real repo, a symlink to a repo outside the workspace, a dangling link, a link to a file and a link back to the workspace itself, and expects ["repoA", "repoC-link"]. It fails on main (["repoA"]) and passes with the change; the rest of graph-scopes.test.ts (16 tests) passes.

discoverWorkspaceChildren() only accepted entries for which
Dirent.isDirectory() is true, which is false for a symlink, so a child
that is a symlink to a git repo was silently left out of a workspace
build. Ticket workspaces assembled from worktrees plus symlinks are a
common way to give one session exactly the repos a change touches.

Treat a symlink as a child when it resolves to a directory, following
it one level like walkDir follows a symlinked root. Dangling links,
links to files, and links that point back at the workspace root or one
of its ancestors are not children.

Fixes trailhq#319
@trailhq-graft

trailhq-graft Bot commented Sep 9, 2026

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 2 areas can be affected. 10 dependent symbols, depth 2.
Tests: 1 area updated its tests.
Tag: @anirudhkumar-nanonets — 3 of 3 areas · @shhdwi — 3 of 3 areas

flowchart TB
  A0(("Workspace Federation<br/>9 symbols"))
  A1(("CLI Entry Point<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Workspace Federation 9 src/graph/workspace.ts:L97-L101 isWorkspaceBuildRoot — calls, depth 1 Workspace Scope Discovery
CLI Entry Point 1 src/cli.ts:L1-L1197 cli.ts — calls, depth 1 Workspace Scope Discovery
Who knows this code — 2 people across 3 areas
Area Who knows it
Workspace Scope Discovery · changed @shhdwi — 4 commits, last 2mo ago · @anirudhkumar-nanonets — 2 commits, last 1mo ago
Workspace Federation · affected @shhdwi — 6 commits, last 28d ago · @anirudhkumar-nanonets — 3 commits, last 29d ago
CLI Entry Point · affected @anirudhkumar-nanonets — 36 commits, last 7d ago · @shhdwi — 23 commits, last 27d ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 10 dependent symbols, grouped by area

Workspace Federation — 9 symbols in 2 files

  • src/graph/workspace.ts:L97-L101 — isWorkspaceBuildRoot (calls, depth 1)
    100: return discoverWorkspaceChildren(root).length >= 2;
  • src/graph/workspace.ts:L145-L156 — loadWorkspaceGraphs (calls, depth 1)
    147: const children = (ws ? ws.children : discoverWorkspaceChildren(root)).slice().sort();
  • src/graph/workspace.ts:L709-L722 — splitWorkspace (calls, depth 1)
    715: const children = discoverWorkspaceChildren(root).slice().sort();
  • src/graph/workspace-cli.ts:L48-L86 — runWorkspaceBuild (calls, depth 2)
  • src/graph/workspace.ts:L240-L558 — federateAsk (calls, depth 2)
  • src/graph/workspace.ts:L659-L695 — federateCallers (calls, depth 2)
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 2)
  • src/graph/workspace.ts:L563-L603 — federateGrep (calls, depth 2)
  • src/graph/workspace.ts:L607-L626 — federateMap (calls, depth 2)

CLI Entry Point — 1 symbol in 1 file

  • src/cli.ts:L1-L1197 — cli.ts (calls, depth 1)
    26: import { discoverWorkspaceChildren } from "./graph/scopes.js";
Test signal per changed area — 1 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • Workspace Scope Discovery — 1 of 2 reached · 1 test file changed here: test/graph-scopes.test.ts
    • not reached: isChildDirectory
2 test suites also reference this code

3 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ingest-fs.test.ts
  • test/workspace.test.ts

graft blast · origin/main...HEAD · depth 2 · 2 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 9, 2026
Ercaner1988 added a commit to Ercaner1988/Graft that referenced this pull request Sep 12, 2026
- generic-extract.test.ts: the union merge of trailhq#196, trailhq#200 and trailhq#201 spliced
  their appended tests into one another and left the file unterminated.
  Rebuilt as trailhq#196's file plus trailhq#200's and trailhq#201's appended blocks verbatim.
- The trailhq#139 test asserted that a cached parse failure is replayed without a
  re-parse; trailhq#317 (fixes trailhq#312) deliberately re-parses it instead and did not
  update the test. It now asserts the re-parse and that the error still
  comes back.
- The trailhq#319 symlink test (trailhq#328) needed symlink privilege on Windows. Directory
  links are junctions there, as test/graph-root and test/ingest-fs already
  do, and the file-link case is skipped with a diagnostic when it cannot be
  created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Workspace discovery skips symlinked child repos (Dirent.isDirectory is false for a symlink)

1 participant