OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / 3316944

self-provision the git-remote-punch helper for punch:// fetches
git fetch punch://... needs the git-remote-punch remote helper on the git child's PATH. It is a SEPARATE bare app (its own repo), not something git ships, and was hand-dropped in ~/.local/bin -- off the daemon's PATH on ashatron, absent entirely on celltron. So punch:// clones silently failed on fresh peers. Fix at the point of use: hyper-sync now locates its bundled git-remote-punch (sibling of hyper-sync in the dev tree and when flattened into node_modules) plus the running bare (Bare.argv[0]), writes a shim into <reposDir>/.remote-helpers/, and prepends that dir to the PATH of the git-fetch child (_gitEnv on both _gitFetch and _gitRefetch). Idempotent, cached; degrades to null (existing PATH) when it cannot provision. git-remote-punch added as a dep so the pgh bundle ships it to every peer; process -> bare-process added to the imports map. Proven e2e: the generated shim launches git-remote-punch under bare and it answers the git remote-helper capabilities handshake (connect). Tests: remote-helper 11/11, basic 4/4. (push-acl pre-existing bare-incompat: events not in imports map -- untouched.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3316944f4279b236c2b6f7296a085e678c276743
by plankatron <fleeky@gmail.com> · 2026-07-18T02:24:43.000Z

Changed files (3) +100 -4

index.js +58 -2
……@@ -5,6 +5,7 @@ const crypto = require('hypercore-crypto');
55const fs = require('fs');
66const path = require('path');
77const { spawn } = require('child_process');
8const process = require('process');
89
910const { scanRepos, repoInfo, readRefs } = require('./lib/repos');
1011const { deriveTopic } = require('./lib/topics');
……@@ -327,7 +328,7 @@ module.exports = class HyperSync extends ReadyResource {
327328 return new Promise((resolve, reject) => {
328329 const proc = spawn('git', [
329330 '-C', repoPath, 'fetch', '--refetch', fetchUrl, '+refs/*:refs/*', '--no-tags'
330 ], { stdio: ['ignore', 'pipe', 'pipe'] });
331 ], { stdio: ['ignore', 'pipe', 'pipe'], env: this._gitEnv() });
331332 const errChunks = [];
332333 proc.stderr.on('data', (d) => errChunks.push(d));
333334 proc.on('exit', (code) => {
……@@ -485,6 +486,61 @@ module.exports = class HyperSync extends ReadyResource {
485486
486487 // ── Helpers ───────────────────────────────────────────────
487488
489 // git-remote-punch is the remote helper that makes `git fetch punch://...`
490 // work — a SEPARATE bare app, not something git ships. Rather than rely on it
491 // being hand-installed on every peer's PATH (it usually isn't, and is
492 // off-PATH even on machines that have it), self-provision: locate our bundled
493 // git-remote-punch + the running bare, write a tiny shim into a helper dir,
494 // and prepend that dir to the PATH of the `git` child we spawn. Idempotent +
495 // cached; returns the helper dir, or null when we can't provision (git then
496 // falls back to whatever `git-remote-punch` is already on PATH — no regression
497 // for hosts that already have it).
498 _ensureRemoteHelper () {
499 if (this._remoteHelperDir !== undefined) return this._remoteHelperDir;
500 this._remoteHelperDir = null;
501 try {
502 // git-remote-punch sits as a SIBLING of hyper-sync — in the dev tree
503 // (hyper-fleeky/{hyper-sync,git-remote-punch}) and when flattened into
504 // node_modules (hoisted next to us). Also check the nested layout.
505 const candidates = [
506 path.join(__dirname, '..', 'git-remote-punch', 'bin', 'git-remote-punch'),
507 path.join(__dirname, 'node_modules', 'git-remote-punch', 'bin', 'git-remote-punch')
508 ];
509 let helperBin = null;
510 for (const c of candidates) { try { if (fs.existsSync(c)) { helperBin = c; break; } } catch {} }
511 if (!helperBin) return null;
512
513 // git-remote-punch is `#!/usr/bin/env bare`, so the shim runs it under the
514 // bare running us. Bare.argv[0] is authoritative under bare; execPath is
515 // the fallback.
516 const barePath = (typeof Bare !== 'undefined' && Bare.argv && Bare.argv[0])
517 ? Bare.argv[0]
518 : process.execPath;
519 if (!barePath) return null;
520
521 // Shim lives under the repos dir but NOT as a *.git, so repo scans ignore
522 // it. Rewritten each boot (cheap) so bare/helper paths stay current.
523 const dir = path.join(this._reposDir, '.remote-helpers');
524 fs.mkdirSync(dir, { recursive: true });
525 const shimPath = path.join(dir, 'git-remote-punch');
526 fs.writeFileSync(shimPath, '#!/bin/sh\nexec "' + barePath + '" "' + helperBin + '" "$@"\n');
527 fs.chmodSync(shimPath, 0o755);
528 this._remoteHelperDir = dir;
529 } catch {
530 this._remoteHelperDir = null;
531 }
532 return this._remoteHelperDir;
533 }
534
535 // Env for a `git` child, with the self-provisioned git-remote-punch helper
536 // dir prepended to PATH so `git fetch punch://...` resolves the helper.
537 _gitEnv () {
538 const dir = this._ensureRemoteHelper();
539 const env = Object.assign({}, process.env);
540 if (dir) env.PATH = dir + path.delimiter + (env.PATH || '');
541 return env;
542 }
543
488544 _gitFetch (repoPath, fetchUrl) {
489545 return new Promise((resolve, reject) => {
490546 // '+refs/*:refs/*' — leading '+' means force-update. Lets us
……@@ -495,7 +551,7 @@ module.exports = class HyperSync extends ReadyResource {
495551 // fetch and get reclaimed by the next gc.
496552 const proc = spawn('git', [
497553 '-C', repoPath, 'fetch', fetchUrl, '+refs/*:refs/*', '--no-tags'
498 ], { stdio: ['ignore', 'pipe', 'pipe'] });
554 ], { stdio: ['ignore', 'pipe', 'pipe'], env: this._gitEnv() });
499555
500556 const errChunks = [];
501557 proc.stderr.on('data', (d) => errChunks.push(d));
package.json +4 -2
……@@ -1,18 +1,20 @@
11{
22 "name": "hyper-sync",
3 "version": "0.1.1",
3 "version": "0.1.2",
44 "description": "Manage and sync bare git repos over Hyperswarm",
55 "main": "index.js",
66 "imports": {
77 "fs": { "bare": "bare-fs", "default": "fs" },
88 "path": { "bare": "bare-path", "default": "path" },
99 "os": { "bare": "bare-os", "default": "os" },
10 "child_process": { "bare": "bare-subprocess", "default": "child_process" }
10 "child_process": { "bare": "bare-subprocess", "default": "child_process" },
11 "process": { "bare": "bare-process", "default": "process" }
1112 },
1213 "scripts": {
1314 "test": "brittle test/*.test.js"
1415 },
1516 "dependencies": {
17 "git-remote-punch": "file:../git-remote-punch",
1618 "hypercore-crypto": "^3.0.0",
1719 "hyperswarm": "^4.0.0",
1820 "protomux": "^3.0.0",
test/remote-helper.test.js +38 -0
……@@ -0,0 +1,38 @@
1const test = require('brittle');
2const fs = require('fs');
3const os = require('os');
4const path = require('path');
5const HyperSync = require('../');
6
7// _ensureRemoteHelper self-provisions the git-remote-punch remote helper so
8// `git fetch punch://...` works even when the helper isn't on the daemon's
9// PATH (the common case on a fresh peer). Tested via Object.create so we don't
10// spin up a Hyperswarm.
11test('_ensureRemoteHelper writes a git-remote-punch shim + _gitEnv prepends it to PATH', (t) => {
12 const tmpRepos = fs.mkdtempSync(path.join(os.tmpdir(), 'hyper-sync-helper-'));
13 t.teardown(() => { try { fs.rmSync(tmpRepos, { recursive: true, force: true }); } catch {} });
14
15 const obj = Object.create(HyperSync.prototype);
16 obj._reposDir = tmpRepos;
17
18 const dir = obj._ensureRemoteHelper();
19 t.ok(dir, 'helper dir provisioned (git-remote-punch located as a sibling)');
20 t.is(dir, path.join(tmpRepos, '.remote-helpers'), 'helper dir lives under the repos dir (non-.git → repo scans ignore it)');
21
22 const shim = path.join(dir, 'git-remote-punch');
23 t.ok(fs.existsSync(shim), 'shim file written');
24
25 const content = fs.readFileSync(shim, 'utf8');
26 t.ok(content.startsWith('#!/bin/sh'), 'shim is a shell script');
27 t.ok(content.includes('exec "'), 'shim uses exec');
28 t.ok(content.includes('git-remote-punch'), 'shim references the git-remote-punch bin');
29 t.ok(content.trim().endsWith('"$@"'), 'shim forwards all args to the helper');
30
31 t.ok((fs.statSync(shim).mode & 0o111) !== 0, 'shim is executable');
32
33 t.is(obj._ensureRemoteHelper(), dir, 'idempotent — second call returns the cached dir');
34
35 const env = obj._gitEnv();
36 t.ok(env.PATH.startsWith(dir + path.delimiter), '_gitEnv prepends the helper dir to PATH');
37 t.ok(env.PATH.length > dir.length + 1, 'base PATH preserved after the helper dir');
38});