hivecode-mcp 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hive-branch.js CHANGED
@@ -58,6 +58,19 @@ export function roomFolder(link, name) {
58
58
 
59
59
  // ---- git ----------------------------------------------------------------------------------
60
60
 
61
+ // OUR SCRATCH WORKTREES RUN WITHOUT THE DEVELOPER'S GIT HOOKS. A post-checkout hook that exits 1
62
+ // made `pr` fail with Q563 although git had made the worktree (measured 25 Oct 2026, a vexp hook);
63
+ // others run npm install or rewrite files in what is a scratch folder about to be deleted. Only
64
+ // commands inside our own temporary worktrees: the developer's checkout and every push keep their
65
+ // hooks. A hooks path that is never created means "no hooks" on every platform git runs on.
66
+ export const NO_HOOKS_DIR = path.join(os.tmpdir(), 'hivecode-no-hooks-never-created')
67
+ export const noHooks = (args) => ['-c', `core.hooksPath=${NO_HOOKS_DIR}`, ...args]
68
+ /** The same for a child process that runs git itself. Appends to any GIT_CONFIG_* already set. */
69
+ export function noHooksEnv(env = process.env) {
70
+ const n = Number(env.GIT_CONFIG_COUNT) > 0 ? Number(env.GIT_CONFIG_COUNT) : 0
71
+ return { ...env, GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: 'core.hooksPath', [`GIT_CONFIG_VALUE_${n}`]: NO_HOOKS_DIR }
72
+ }
73
+
61
74
  function git(cwd, args, { input, raw = false, trim = true } = {}) {
62
75
  const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
63
76
  if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
@@ -74,8 +87,14 @@ export function repoRoot(dir) {
74
87
  return r.code === 0 && r.out ? path.resolve(r.out) : null
75
88
  }
76
89
 
90
+ // Links are resolved on both sides: git names a repository's top with them resolved, while the home
91
+ // directory comes as written, and on macOS the temp folders sit behind one (6 Oct 2026).
77
92
  const sameDir = (a, b) => {
78
- const n = (p) => path.resolve(p).replace(/[\\/]+$/, '')
93
+ const n = (p) => {
94
+ let r = path.resolve(p)
95
+ try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
96
+ return r.replace(/[\\/]+$/, '')
97
+ }
79
98
  return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
80
99
  }
81
100
 
@@ -186,7 +205,7 @@ export function roomCarries(files) {
186
205
  // folder look held by some other process, and a foreign shadow is somebody else's ancestry.
187
206
  // By exact name: a project's own `.hiverc` is its business. All of these are already never carried
188
207
  // (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
189
- const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json'])
208
+ const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json', '.hive-stop-hook.json'])
190
209
  const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
191
210
 
192
211
  function writeFiles(dir, files) {
@@ -350,7 +369,7 @@ function defaultBase(root, name) {
350
369
  * line, and naming this room in its .hive.json. The path comes from git config, and config is
351
370
  * editable text -- a path that fails any of these is refused, never removed.
352
371
  */
353
- function removeRoomFolder(dir, room) {
372
+ export function removeRoomFolder(dir, room) {
354
373
  const abs = path.resolve(dir)
355
374
  const home = path.resolve(hiveHome()) + path.sep
356
375
  let ours = false
@@ -517,7 +536,7 @@ export async function landBranch({ cwd, branch, dry = false, push = false, pr =
517
536
  git(root, ['worktree', 'prune'])
518
537
  if (revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
519
538
  }
520
- const wt = git(root, ['worktree', 'add', '--detach', tmp, tip])
539
+ const wt = git(root, noHooks(['worktree', 'add', '--detach', tmp, tip]))
521
540
  if (wt.code !== 0) { cleanup(); throw coded('Q563', `could not make a temporary worktree: ${wt.err}`) }
522
541
 
523
542
  try {
@@ -528,7 +547,7 @@ export async function landBranch({ cwd, branch, dry = false, push = false, pr =
528
547
  if (dry) args.push('--dry-run')
529
548
  if (force) args.push('--force', force)
530
549
  if (only) args.push('--only', only)
531
- const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: { ...process.env, HIVE_PROVENANCE: 'on' }, maxBuffer: 64 * 1024 * 1024, windowsHide: true })
550
+ const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: noHooksEnv({ ...process.env, HIVE_PROVENANCE: 'on' }), maxBuffer: 64 * 1024 * 1024, windowsHide: true })
532
551
  if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
533
552
  // Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
534
553
  // added. For a branch room that is an answer, not a failure -- and deletions may still land.
@@ -543,17 +562,22 @@ export async function landBranch({ cwd, branch, dry = false, push = false, pr =
543
562
  if (deletions.length) {
544
563
  // hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
545
564
  // commit it never made one, so make it here, at the same tip.
546
- if (!revParse(root, `refs/heads/${temp}`)) git(tmp, ['checkout', '-q', '-b', temp])
565
+ if (!revParse(root, `refs/heads/${temp}`)) git(tmp, noHooks(['checkout', '-q', '-b', temp]))
547
566
  const rm = git(tmp, ['rm', '-q', '--', ...deletions])
548
567
  if (rm.code !== 0) throw coded('Q563', `could not stage the room's deletions: ${rm.err}`)
549
568
  const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
550
- const c = git(tmp, ['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>'])
569
+ const c = git(tmp, noHooks(['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>']))
551
570
  if (c.code !== 0) throw coded('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
552
571
  }
553
- if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); return { ok: true, landed: 0 } }
554
-
555
- const newTip = revParse(root, `refs/heads/${temp}`)
556
- if (!newTip || newTip === tip) { log(`nothing to land: ${name} already has everything the room holds.`); return { ok: true, landed: 0 } }
572
+ // NOTHING NEW TO LAND IS NOT "DONE" WHEN A PUSH OR A PR WAS ASKED FOR. The usual sequence is a
573
+ // plain `pr` to look, then `pr --pr`; the second run lands nothing, and returning here skipped
574
+ // the push and the PR while printing success (1 Nov 2026). Push and open what is already there.
575
+ let nothingNew = false
576
+ if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); nothingNew = true }
577
+ const newTip = nothingNew ? tip : revParse(root, `refs/heads/${temp}`)
578
+ if (!nothingNew && (!newTip || newTip === tip)) { log(`nothing to land: ${name} already has everything the room holds.`); nothingNew = true }
579
+ if (nothingNew && !push && !pr) return { ok: true, landed: 0 }
580
+ if (nothingNew) return finish('')
557
581
 
558
582
  // Move the branch. Checked out somewhere: only a clean fast-forward of that checkout, which is
559
583
  // what `git pull` would do; with local changes it is refused, because moving the branch under a
@@ -571,21 +595,27 @@ export async function landBranch({ cwd, branch, dry = false, push = false, pr =
571
595
  }
572
596
  const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
573
597
  log(`\nlanded on ${name}:\n${made}`)
574
-
575
- if (push) {
576
- const p = git(root, ['push', '-u', 'origin', name])
577
- if (p.code !== 0) throw coded('Q573', `the work landed on ${name} locally, but the push failed: ${p.err}`)
578
- log(`pushed ${name}`)
579
- }
580
- if (pr) {
581
- const into = base || b.from
582
- if (!into) throw coded('Q574', `the work landed on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
583
- const body = [`Opened by Hivecode from the room bound to \`${name}\`.`, '', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.', '', '```', made, '```'].join('\n')
584
- const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
585
- if (g.status !== 0) throw coded('Q574', `the work landed on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
586
- log((g.stdout || '').trim())
598
+ return finish(made)
599
+
600
+ // `made` is '' when nothing new landed this run: the push and the PR are of what is already on
601
+ // the branch, and the PR body lists the branch's commits since its base instead.
602
+ function finish(made) {
603
+ if (push) {
604
+ const p = git(root, ['push', '-u', 'origin', name])
605
+ if (p.code !== 0) throw coded('Q573', `the work ${made ? 'landed' : 'is'} on ${name} locally, but the push failed: ${p.err}`)
606
+ log(`pushed ${name}`)
607
+ }
608
+ if (pr) {
609
+ const into = base || b.from
610
+ if (!into) throw coded('Q574', `the work is on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
611
+ const listed = made || git(root, ['log', '--format= %h %an %s', `${into}..${name}`]).out
612
+ const body = [`Opened by Hivecode from the room bound to \`${name}\`.`, '', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.', '', '```', listed, '```'].join('\n')
613
+ const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
614
+ if (g.status !== 0) throw coded('Q574', `the work is on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
615
+ log((g.stdout || '').trim())
616
+ }
617
+ return { ok: true, landed: made ? made.split('\n').filter(Boolean).length : 0, tip: made ? newTip : tip, deletions }
587
618
  }
588
- return { ok: true, landed: made.split('\n').filter(Boolean).length, tip: newTip, deletions }
589
619
  } finally {
590
620
  cleanup()
591
621
  }