Skip to content

fix(config): keep a relative worktreeParent inside its bare-repository container - #29

Draft
chrissena wants to merge 1 commit into
mainfrom
fix/bare-container-worktree-parent-escape
Draft

chrissena wants to merge 1 commit into
mainfrom
fix/bare-container-worktree-parent-escape

Conversation

@chrissena

Copy link
Copy Markdown
Member

The bug

Running wt new … --json from /home/chris/workspace/syrf/main — a .bare/ container
layout — created the worktree at

/home/chris/workspace/pr/pr2897.shared-local-sqlserver-for-quartz          ← WRONG

instead of

/home/chris/workspace/syrf/pr/pr2897.shared-local-sqlserver-for-quartz     ← RIGHT

The syrf container segment was silently dropped. A second stranded worktree from an
earlier run (pr2864.…) shows the same misplacement, so it is reproducible rather than
a one-off.

Root cause

Two correct-in-isolation pieces combine into a silent misplacement:

  1. getMainWorktreeRoot() — src/lib/git.ts:694-722. For a .bare/ container it
    returns path.dirname(<common-dir>), i.e. the container (…/syrf), not the main
    worktree (…/syrf/main). That is deliberate (feat(config): anchor worktree placement to the main worktree root (spec Part 3) #25, v1.15.0) and is what
    src/integration/worktree-layout.integration.test.ts and wt init's bare-layout
    scaffold (worktreeParent: "pr", src/cli/wt/init.ts:260) both assume.

  2. generateWorktreePath() — src/lib/config.ts:800-808. It did
    path.resolve(anchor, config.worktreeParent) with no containment check.

#25 moved the anchor from the invoking worktree root to the container without
migrating existing configs. syrf's .worktreerc.local still carries the pre-#25
value "worktreeParent": "../pr" — correct when read from …/syrf/main, and one
directory too high when read from …/syrf. So resolve("…/syrf", "../pr") escapes the
container to /home/chris/workspace/pr.

Reproduced against the real layout with the installed 1.15.0 build before touching
anything:

repoRoot         = /home/chris/workspace/syrf/main
mainWorktreeRoot = /home/chris/workspace/syrf
worktreeParent   = ../pr   anchor = main-worktree
RESOLVED PATH    = /home/chris/workspace/pr/pr2897.shared-local-sqlserver-for-quartz

— byte-for-byte the reported wrong path.

The fix

New exported resolveRelativeWorktreeParent(anchor, worktreeParent) in
src/lib/config.ts. When the anchor is a bare-repository container (a .bare/
directory containing a HEAD) and a relative parent resolves outside it, the escaping
leading ../ segments are dropped so the parent stays inside the container, and a
warning names the offending value, the path that would have been used, and the
replacement to write:

[WARN] worktreeParent "../pr" resolves outside the bare-repository container
"/home/chris/workspace/syrf" (would be "/home/chris/workspace/pr"). Relative
worktreeParent values are anchored to the container, so leading "../" segments were
dropped and the worktree will be created under "/home/chris/workspace/syrf/pr"
instead. Update worktreeParent to "pr" (or set an absolute path /
worktreeParentAnchor: "repo-root") to silence this warning.

Deliberately not clamped, so putting worktrees outside a container stays possible —
it just has to be unambiguous:

  • an absolute worktreeParent
  • worktreeParentAnchor: "repo-root"

Non-container anchors are untouched. The trigger is the .bare/+HEAD filesystem
check, not "the path escapes the anchor" — the default worktreeParent: ".." escapes
the anchor by design and must keep resolving to the repository's sibling directory.

Also documents worktreeParentAnchor (previously absent from the README options table)
and adds a Bare-repository containers section.

Test evidence

Baseline before any change — green

 Test Files  123 passed | 1 skipped (124)
      Tests  3668 passed | 14 skipped (3682)

Regression tests written first — red

Real bare-container fixture (git clone --bare into <container>/.bare, a container-level
.git file pointing at it, a main/ worktree and a pr/ worktree — matching the layout
that triggered this):

 FAIL  … > keeps a legacy "../pr" parent inside the container when invoked from main/
 FAIL  … > keeps a legacy "../pr" parent inside the container when invoked from a pr worktree
   Expected: "…/container/pr/pr2897.shared-local-sqlserver-for-quartz"
   Received: "/tmp/pr/pr2897.shared-local-sqlserver-for-quartz"

 FAIL  … > clamps multiple escaping segments back into the container
 FAIL  … > never places the worktree outside the container

 Test Files  1 failed (1)
      Tests  4 failed | 6 passed (10)

After the fix — green

 ✓ src/integration/worktree-layout.integration.test.ts (10 tests) 160ms
 Test Files  1 passed (1)
      Tests  10 passed (10)

Full suite after the fix — green, +19 tests, no regressions

 Test Files  123 passed | 1 skipped (124)
      Tests  3687 passed | 14 skipped (3701)

pnpm run build clean · pnpm run lint 0 errors (66 pre-existing warnings) ·
prettier --check . clean.

End-to-end against the real layout, with the fixed build

--- invoked from /home/chris/workspace/syrf/main
  anchor = /home/chris/workspace/syrf   worktreeParent = ../pr
  RESULT = /home/chris/workspace/syrf/pr/pr2897.shared-local-sqlserver-for-quartz   ✅

(Read-only: the pure resolver was called against the live config; nothing under
/home/chris/workspace/syrf was created or modified.)

New coverage

19 tests — 5 asserting non-container anchors are never clamped (default "..",
"../worktrees", ".worktrees", a .bare file, a .bare directory with no HEAD),
6 on container clamping and the warning text, 3 on generateWorktreePath including the
repo-root anchor and absolute-parent escape hatches, and 5 integration tests against a
real bare container.

Config finding (no change made here)

syrf's .worktreerc.local — "worktreeParent": "../pr" — is a stale pre-#25 value.
It now works either way, but the tidy fix on that side is "worktreeParent": "pr",
which silences the warning. Left alone deliberately; that repo is out of scope for this PR.

https://claude.ai/code/session_01R84soQ82VQsGznotpWTvHo

…y container

A relative `worktreeParent` is resolved against `getMainWorktreeRoot()`, which
for a `.bare/` container layout is the container directory itself. Configs
written before that anchor moved (v1.15.0, #25) hold worktree-relative values
such as `"../pr"`, meaning `<container>/pr` when read from `<container>/main`.
Resolved against the container those escape it entirely, and the new worktree
was silently created beside the container with the container segment dropped:

    <container>/main + "../pr"  ->  /home/chris/workspace/pr/pr2897.<slug>
    expected                    ->  /home/chris/workspace/syrf/pr/pr2897.<slug>

Add `resolveRelativeWorktreeParent()`: when the anchor is a bare-repository
container and a relative parent resolves outside it, drop the escaping leading
segments so the parent stays inside the container, and warn with the offending
value, the path it would have used and the suggested replacement. Absolute
`worktreeParent` values and `worktreeParentAnchor: "repo-root"` are never
clamped, so placing worktrees outside a container stays possible - it just has
to be said unambiguously.

Non-container anchors are untouched: the default `".."` still resolves to the
repository's sibling directory.

Claude-Session: https://claude.ai/code/session_01R84soQ82VQsGznotpWTvHo
@claude

claude Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrissena's task in 4m 15s —— View job


Review: fix(config): keep a relative worktreeParent inside its bare-repository container

  • Read changed files
  • Review src/lib/config.ts — core fix logic
  • Review src/lib/config.test.ts — unit tests
  • Review src/integration/worktree-layout.integration.test.ts — integration tests
  • Review README.md — docs update
  • Post inline findings
  • Post overall summary

Overall verdict: ✅ Approve (3 nits, no blocking issues)

The fix is correct. The algorithm (isWithin → short-circuit; isBareRepositoryContainer → strip leading .. segments → warn) is sound, the three helpers are well-scoped and private, and the test pyramid is good: 11 unit tests for resolveRelativeWorktreeParent (covering non-container pass-through, .bare-file false-positive, container clamping, warn text), 3 generateWorktreePath integration tests, and 5 real-git-filesystem tests reproducing the exact bug scenario. The README diff is clear and the JSON schema (schemas/worktreerc.schema.json) already had worktreeParentAnchor — no schema gap.

Three things to address (all minor):

  1. isBareRepositoryContainer line 822 — mixes statSync (throws, caught) with existsSync (never throws). Inline suggestion posted: use statSync(...).isFile() for the HEAD check so both paths live entirely in the try/catch. (see comment)

  2. stripLeadingParentSegments line 842 — the || segments[0] === '.' branch is dead for the clamping trigger ("./foo" is already within the container so isWithin short-circuits before this function is called). Either drop it or add "./pr" -> "pr" to the JSDoc. (see comment)

  3. Warning message line 875 — hardcodes "../" (POSIX). On Windows, a backslash config value echoed earlier in the same warning would produce a mixed-separator message. Cosmetic only. (see comment)

One observation (not blocking): The integration test titled "documents the pre-fix bug" (line 113 of worktree-layout.integration.test.ts) asserts that the stale path still appears when mainWorktreeRoot is omitted. No live caller omits it, so this is documenting an unsupported code path rather than an open bug. Renaming to something like "falls back to the invoking worktree when mainWorktreeRoot is not provided" would make the intent clearer.

Comment thread src/lib/config.ts
function isBareRepositoryContainer(dir: string): boolean {
try {
const bare = path.join(dir, '.bare');
return fs.statSync(bare).isDirectory() && fs.existsSync(path.join(bare, 'HEAD'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor inconsistency: statSync (throws on missing path, caught by the outer try/catch) is mixed with existsSync (never throws). Keeping both in the caught pattern is cleaner and avoids a potential TOCTOU if .bare disappears between the two calls:

Suggested change
return fs.statSync(bare).isDirectory() && fs.existsSync(path.join(bare, 'HEAD'));
return (
fs.statSync(bare).isDirectory() && fs.statSync(path.join(bare, 'HEAD')).isFile()
);

This follows the existing statSync-in-try/catch pattern from git.ts.

Comment thread src/lib/config.ts
*/
function stripLeadingParentSegments(relativePath: string): string {
const segments = relativePath.split(/[/\\]+/).filter((segment) => segment.length > 0);
while (segments.length > 0 && (segments[0] === '..' || segments[0] === '.')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The . branch of this condition is dead code for the clamping trigger. A "./pr" input resolves to <container>/pr, which is within the container, so isWithin returns true and stripLeadingParentSegments is never called with a leading . segment in normal use. The stripping is harmless but adds noise; the JSDoc examples ("../pr" -> "pr", .." -> "") don't mention it either.

If keeping it for defensive completeness, add "./pr" -> "pr" to the JSDoc. Otherwise just remove the || segments[0] === '.' branch since a contained "./foo" is returned untouched above.

Comment thread src/lib/config.ts
logger.warn(
`worktreeParent "${worktreeParent}" resolves outside the bare-repository container ` +
`"${anchor}" (would be "${resolved}"). Relative worktreeParent values are anchored to ` +
`the container, so leading "../" segments were dropped and the worktree will be created ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The literal "../" in this string is POSIX-style. On Windows the user's config value could use \ ("..\pr"), and the warning text would be slightly off ("leading '../' segments" vs the actual backslash form). Since worktreeParent is echoed directly in the earlier part of the message (which is correct), consider referencing the value there rather than hard-coding the separator:

Suggested change
`the container, so leading "../" segments were dropped and the worktree will be created ` +
`the container, so leading parent-directory segments were dropped and the worktree will be created ` +

Cross-platform cosmetic only — no logic impact.

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.79%. Comparing base (4087f41) to head (cccc1e3).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #29      +/-   ##
==========================================
+ Coverage   81.76%   81.79%   +0.02%     
==========================================
  Files          97       97              
  Lines       20839    20873      +34     
  Branches     4546     4558      +12     
==========================================
+ Hits        17040    17074      +34     
  Misses       3799     3799              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant