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); + }); +});