Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/core/src/-private/repo-info.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions packages/core/tests/repo-info-worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading