From c3752e95ae97a8316c0e8e8b9b7c1b66dbb906e9 Mon Sep 17 00:00:00 2001 From: Josemar Luedke Date: Fri, 21 Aug 2026 14:47:34 -0700 Subject: [PATCH] fix: resolve the checkout root correctly inside a git worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit URLs were wrong for anyone running Docfy from a linked git worktree. `getRepoEditUrl` computed `path.relative(getRepoInfo(root).root, root)`, and git-repo-info's own typings document that `root` "is the directory containing the original copy, not the worktree" — so the relative path picked up the worktree's location and produced links to paths that do not exist on the remote. This is not hypothetical. It is why four committed .gjs templates shipped with `.claude/worktrees//` in their @editUrl values, fixed in #214, and it is the real reason the repo-info and generating-edit-url tests failed locally. I had been calling those failures environmental for most of this work; they were this bug. A worktree's metadata directory holds a `gitdir` file pointing at that worktree's `.git` file, whose parent is the top level we want. Outside a worktree `worktreeGitDir` equals `commonGitDir`, so there is nothing to correct — note that detecting a worktree requires comparing the two rather than checking `worktreeGitDir` for truthiness, since it is always populated. Adds a regression test that builds a real repository and worktree in a temp directory. CI runs in a normal checkout, so without it this fix could regress invisibly. Confirmed the test fails without the fix (`edit/linked/docs/` instead of `edit/main/docs/`) while its main-checkout counterpart still passes. @docfy/core's suite now passes fully in a worktree — 59/59 across 12 files, where 6 tests in 3 files used to fail. Building test-app-vite in a worktree no longer rewrites the committed templates either, which removes the manual path-stripping step that #215 needed three times. Co-Authored-By: Claude Opus 5 --- packages/core/src/-private/repo-info.ts | 36 ++++++++++++- .../core/tests/repo-info-worktree.test.ts | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/repo-info-worktree.test.ts diff --git a/packages/core/src/-private/repo-info.ts b/packages/core/src/-private/repo-info.ts index 2220313..13af44b 100644 --- a/packages/core/src/-private/repo-info.ts +++ b/packages/core/src/-private/repo-info.ts @@ -1,7 +1,41 @@ +import fs from 'fs'; import path from 'path'; import getRepoInfo from 'git-repo-info'; import GitHost from 'hosted-git-info'; +/** + * Resolves the top level of the checkout that `target` lives in. + * + * `git-repo-info` documents that its `root` points at the original copy rather + * than the worktree when called from inside a linked worktree. Using it + * directly makes the relative path below include the worktree's own location, + * producing edit URLs for paths that do not exist on the remote. + * + * A worktree's metadata directory holds a `gitdir` file pointing at that + * worktree's `.git` file, and its parent is the top level we want. Outside a + * worktree `worktreeGitDir` and `commonGitDir` are the same, so there is + * nothing to correct. + */ +function getCheckoutRoot(target: string): string { + const { root, commonGitDir, worktreeGitDir } = getRepoInfo(target); + + if (!worktreeGitDir || worktreeGitDir === commonGitDir) { + return root; + } + + try { + const gitdir = fs.readFileSync(path.join(worktreeGitDir, 'gitdir'), 'utf8').trim(); + + if (gitdir) { + return path.dirname(gitdir); + } + } catch { + // No readable `gitdir` pointer, so fall back to the reported root. + } + + return root; +} + // A whitelist, not a fallback: `getTreePath` only knows two URL shapes, Bitbucket's // and the `/edit/` form GitHub and GitLab accept. Every other host hosted-git-info can // parse (gist, sourcehut, anything a future release adds) would otherwise be handed a @@ -30,7 +64,7 @@ export function getRepoEditUrl(root: string, repoURL: string, branch = 'master') let result: string | null = null; try { - const gitRoot = getRepoInfo(root).root; + const gitRoot = getCheckoutRoot(root); const repo = GitHost.fromUrl(repoURL); const relative = path.relative(gitRoot, root); const tree = getTreePath(repo, branch, relative); diff --git a/packages/core/tests/repo-info-worktree.test.ts b/packages/core/tests/repo-info-worktree.test.ts new file mode 100644 index 0000000..c0e347e --- /dev/null +++ b/packages/core/tests/repo-info-worktree.test.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { getRepoEditUrl } from '../src/-private/repo-info.js'; + +/** + * `git-repo-info` reports the *original* checkout's root when called from + * inside a linked worktree, which used to leak the worktree's own location into + * every edit URL. CI runs in a normal checkout, so without this test that + * regression would be invisible there. + */ +describe('getRepoEditUrl in a git worktree', () => { + // realpath matters: on macOS os.tmpdir() is a symlink and git records the + // resolved path, so comparing against the unresolved one would break. + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'docfy-worktree-'))); + const mainCheckout = path.join(tmp, 'main'); + const worktree = path.join(tmp, 'linked'); + const expected = 'https://github.com/user/repo/edit/main/docs/{filepath}'; + + beforeAll(() => { + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: mainCheckout, stdio: 'pipe' }); + }; + + fs.mkdirSync(path.join(mainCheckout, 'docs'), { recursive: true }); + git('init', '-b', 'main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Docfy Test'); + fs.writeFileSync(path.join(mainCheckout, 'docs', 'index.md'), '# Hello\n'); + git('add', '.'); + git('commit', '-m', 'init'); + git('worktree', 'add', worktree, '-b', 'feature'); + }); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test('the edit url does not include the worktree location', () => { + expect( + getRepoEditUrl(path.join(worktree, 'docs'), 'https://github.com/user/repo', 'main') + ).toBe(expected); + }); + + test('the same source path resolves identically in the main checkout', () => { + expect( + getRepoEditUrl(path.join(mainCheckout, 'docs'), 'https://github.com/user/repo', 'main') + ).toBe(expected); + }); +});