OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / 82cc474

Fix U40: auto-repoint dangling HEAD on bare repos
Bare repos created with `git init --bare` get HEAD pointing at refs/heads/master by default. If the operator's first push is a `main` branch, master never gets created and HEAD becomes dangling: git show HEAD:README.md → fatal: invalid object name 'HEAD' This breaks the pear-git-v3 blob viewer + repo-docs page + README rendering for any repo set up that way (which is most modern defaults). Fix in lib/repos.js: repoInfo() now calls a new helper repointHeadIfDangling() after reading refs. The helper rewrites the bare HEAD file from `ref: refs/heads/master` to `ref: refs/heads/main` (or the first available branch in master/main/<first> priority order) when the current target doesn't exist. Direct file write — no `git symbolic-ref` subprocess. Idempotent: no-op when HEAD already resolves cleanly. Skips detached HEADs (plain SHAs). Same priority order as the .head field repoInfo() returns, so downstream consumers see consistent state. Test: reproduces the bug end-to-end — fresh `git init --bare`, push `main` from a work tree, verify HEAD is still dangling, run scanRepos, verify HEAD is repointed and `git show HEAD:README.md` succeeds. 11 tests / 36 asserts passing. Closes should-fix U40 from hyper-fleeky/pear-git-v3/docs/plans/2026-04-14-ux-reaudit.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82cc474b44fbcfd595241504670870babfbe7a03
by plankatron <fleeky@gmail.com> · 2026-04-15T21:21:08.000Z

Changed files (2) +81 -1

lib/repos.js +44 -1
……@@ -37,11 +37,23 @@ function scanRepos (dir) {
3737
3838/**
3939 * Get info for a single bare repo.
40 *
41 * U40 fix (audit doc 2026-04-14-ux-reaudit.md): if HEAD points to a
42 * branch that doesn't exist (`git init --bare` leaves HEAD →
43 * refs/heads/master, but the operator's first push was `main` so
44 * master never gets created), `git show HEAD:path` and friends throw
45 * "fatal: invalid object name 'HEAD'". Detect that here and repoint
46 * HEAD to the first available branch — the SAME branch repoInfo
47 * picks for the .head field, so downstream readers stay consistent.
4048 */
4149function repoInfo (repoPath, name) {
4250 const refs = readRefs(repoPath);
4351 const branches = Object.keys(refs);
44 const head = refs.master || refs.main || refs[branches[0]] || null;
52 const headBranch = refs.master ? 'master'
53 : refs.main ? 'main'
54 : (branches[0] || null);
55 const head = headBranch ? refs[headBranch] : null;
56 if (headBranch) repointHeadIfDangling(repoPath, refs, headBranch);
4557
4658 let size = 0;
4759 try {
……@@ -78,6 +90,37 @@ function repoInfo (repoPath, name) {
7890 };
7991}
8092
93/**
94 * If the bare repo's HEAD is a symbolic ref pointing at a branch that
95 * doesn't exist (the dangling-HEAD case described above), rewrite
96 * HEAD to point at `targetBranch` instead. No-op when HEAD already
97 * resolves cleanly.
98 *
99 * Direct file write — no `git symbolic-ref` subprocess. The HEAD
100 * file format is: `ref: refs/heads/<branch>\n` for symbolic refs,
101 * or a plain SHA for detached HEAD.
102 */
103function repointHeadIfDangling (repoPath, refs, targetBranch) {
104 if (!targetBranch || refs[targetBranch] == null) return false;
105 const headPath = path.join(repoPath, 'HEAD');
106 let raw;
107 try { raw = fs.readFileSync(headPath, 'utf-8').trim(); } catch { return false; }
108 // Detached HEAD (just a SHA) — leave alone.
109 if (!raw.startsWith('ref:')) return false;
110 const currentTarget = raw.slice('ref:'.length).trim();
111 if (!currentTarget.startsWith('refs/heads/')) return false;
112 const currentBranch = currentTarget.slice('refs/heads/'.length);
113 // HEAD already points at a branch that exists — nothing to do.
114 if (refs[currentBranch] != null) return false;
115 if (currentBranch === targetBranch) return false;
116 try {
117 fs.writeFileSync(headPath, `ref: refs/heads/${targetBranch}\n`);
118 return true;
119 } catch {
120 return false;
121 }
122}
123
81124/**
82125 * Read refs from a bare repo — direct file read, no subprocess.
83126 * Returns { refShortName: sha }
test/basic.test.js +37 -0
……@@ -42,6 +42,43 @@ test('scanRepos handles missing directory', async (t) => {
4242 t.is(repos.size, 0, 'empty map for missing dir');
4343});
4444
45test('repoInfo repoints dangling HEAD to first available branch (U40)', async (t) => {
46 // Reproduce the U40 scenario: bare repo created with `git init --bare`
47 // (HEAD → refs/heads/master), then first push is `main` so master
48 // never gets created. After the fix, scanRepos's repoInfo should
49 // detect the dangling pointer and rewrite HEAD → refs/heads/main.
50 const { execSync } = require('child_process');
51 const reposRoot = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-u40-'));
52 const bareDir = path.join(reposRoot, 'u40-test.git');
53 execSync(`git init --bare ${bareDir}`, { stdio: 'ignore' });
54
55 const headBefore = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
56 t.is(headBefore, 'ref: refs/heads/master', 'fresh bare repo HEAD points at master');
57
58 const work = fs.mkdtempSync(path.join(tmpdir, 'hyper-sync-u40-work-'));
59 const g = (a) => execSync(`git -C ${work} ${a}`, { stdio: 'ignore' });
60 g('init --initial-branch=main');
61 g('config user.email t@t');
62 g('config user.name T');
63 fs.writeFileSync(path.join(work, 'README.md'), '# u40\n');
64 g('add -A');
65 g('commit -m seed');
66 g(`push ${bareDir} main`);
67
68 const headMid = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
69 t.is(headMid, 'ref: refs/heads/master', 'HEAD still dangling after push');
70
71 // Now scanRepos runs repoInfo, which should call repointHeadIfDangling.
72 const repos = scanRepos(reposRoot);
73 t.ok(repos.has('u40-test'), 'scanned the repo');
74
75 const headAfter = fs.readFileSync(path.join(bareDir, 'HEAD'), 'utf-8').trim();
76 t.is(headAfter, 'ref: refs/heads/main', 'HEAD repointed to main after scan');
77
78 const readme = execSync(`git -C ${bareDir} show HEAD:README.md`, { encoding: 'utf-8' });
79 t.is(readme, '# u40\n', 'git show HEAD:README.md works after repoint');
80});
81
4582// ── Topic Derivation ────────────────────────────────────────
4683
4784test('deriveTopic is deterministic', async (t) => {