import test from 'brittle'
import path from 'bare-path'
import os from 'bare-os'
import { spawn } from 'bare-subprocess'
import { createGitPipe } from '../../lib/p2p/sync/git-sync-pipe.js'
/**
* Create a temporary bare git repo for testing.
* Returns the repo path. Caller should clean up.
*/
async function createBareRepo () {
const tmp = path.join(os.tmpdir(), 'test-pipe-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.git')
const init = spawn('git', ['init', '--bare', tmp])
await new Promise((resolve) => init.on('exit', resolve))
return tmp
}
/**
* Push an initial commit to the bare repo so git-upload-pack has something to serve.
*/
async function pushInitialCommit (bareRepoPath) {
const workDir = path.join(os.tmpdir(), 'test-work-' + Date.now() + '-' + Math.random().toString(36).slice(2))
const cmds = [
['git', ['clone', bareRepoPath, workDir]],
['git', ['-C', workDir, 'config', 'user.email', 'test@test.com']],
['git', ['-C', workDir, 'config', 'user.name', 'Test']],
['sh', ['-c', `echo "hello" > ${workDir}/README.md`]],
['git', ['-C', workDir, 'add', '.']],
['git', ['-C', workDir, 'commit', '-m', 'initial']],
['git', ['-C', workDir, 'push', 'origin', 'master']]
]
for (const [cmd, args] of cmds) {
const proc = spawn(cmd, args)
const code = await new Promise((resolve) => proc.on('exit', resolve))
if (code !== 0) throw new Error(`${cmd} ${args.join(' ')} exited with ${code}`)
}
// Clean up work dir
spawn('rm', ['-rf', workDir])
}
/**
* Create a mock data channel that captures sent messages.
* Mimics the { message } entry returned by GitSyncBase.openDataChannel().
*/
function createMockChannel () {
const chunks = []
return {
chunks,
message: {
send (data) { chunks.push(Buffer.from(data)) },
onmessage () {} // createGitPipe overrides this
}
}
}
/**
* Wait until at least one chunk has been received by the mock channel.
*/
function waitForOutput (mock) {
return new Promise((resolve) => {
const check = setInterval(() => {
if (mock.chunks.length > 0) {
clearInterval(check)
resolve()
}
}, 50)
})
}
// ---------------------------------------------------------------------------
// Test: createGitPipe sends git output through channel
// ---------------------------------------------------------------------------
test('createGitPipe sends git-upload-pack output through channel', async (t) => {
t.timeout(15000)
const repoPath = await createBareRepo()
await pushInitialCommit(repoPath)
const mock = createMockChannel()
const pipe = createGitPipe('git-upload-pack', repoPath, mock)
// git-upload-pack advertises refs then blocks waiting for wants on stdin.
// Wait for ref advertisement to arrive, then close stdin.
await waitForOutput(mock)
pipe.closeStdin()
const { exitCode } = await pipe.exitPromise
// git-upload-pack exits 0 or 128 when stdin closes without protocol completion
t.ok(exitCode === 0 || exitCode === 128, 'git-upload-pack exited after ref advertisement')
t.ok(mock.chunks.length > 0, 'git output was sent through channel')
// The output should contain ref advertisement (pkt-line format)
const combined = Buffer.concat(mock.chunks).toString('utf-8')
t.ok(combined.includes('refs/heads/'), 'output contains ref advertisement')
// Clean up
spawn('rm', ['-rf', repoPath])
})
// ---------------------------------------------------------------------------
// Test: writeToGit sends data to git stdin
// ---------------------------------------------------------------------------
test('writeToGit sends data to git stdin', async (t) => {
t.timeout(15000)
const repoPath = await createBareRepo()
await pushInitialCommit(repoPath)
const mock = createMockChannel()
const pipe = createGitPipe('git-upload-pack', repoPath, mock)
// Wait for ref advertisement output
await waitForOutput(mock)
t.ok(mock.chunks.length > 0, 'output was produced before stdin data')
// Send flush packet (0000) — tells git-upload-pack the client has no wants.
// Then close stdin so git can fully exit.
pipe.writeToGit(Buffer.from('0000'))
pipe.closeStdin()
const { exitCode } = await pipe.exitPromise
t.ok(exitCode === 0 || exitCode === 128, 'git-upload-pack exited after receiving stdin data')
// Clean up
spawn('rm', ['-rf', repoPath])
})
// ---------------------------------------------------------------------------
// Test: exitPromise resolves with exit code and stderr
// ---------------------------------------------------------------------------
test('exitPromise resolves with exitCode and stderr for valid repo', async (t) => {
t.timeout(15000)
const repoPath = await createBareRepo()
await pushInitialCommit(repoPath)
const mock = createMockChannel()
const pipe = createGitPipe('git-upload-pack', repoPath, mock)
await waitForOutput(mock)
pipe.closeStdin()
const result = await pipe.exitPromise
t.is(typeof result.exitCode, 'number', 'exitCode is a number')
t.ok(result.exitCode === 0 || result.exitCode === 128, 'exit code for valid repo')
t.ok(typeof result.stderr === 'string', 'stderr is a string')
t.is(result.timedOut, false, 'did not time out')
t.is(result.killed, false, 'was not killed')
// Clean up
spawn('rm', ['-rf', repoPath])
})
test('exitPromise captures non-zero exit code for invalid repo', async (t) => {
t.timeout(15000)
const mock = createMockChannel()
// git-upload-pack on a non-existent repo should fail immediately
const pipe = createGitPipe('git-upload-pack', '/tmp/nonexistent-repo-' + Date.now() + '.git', mock)
const result = await pipe.exitPromise
t.ok(result.exitCode !== 0, 'non-zero exit code for invalid repo')
t.ok(result.stderr.length > 0, 'stderr contains error message')
})
// ---------------------------------------------------------------------------
// Test: timeout kills process
// ---------------------------------------------------------------------------
test('timeout kills long-running process', async (t) => {
t.timeout(10000)
const mock = createMockChannel()
// Use 'sleep' as a stand-in for a hung git process
const pipe = createGitPipe('sleep', '60', mock, { timeout: 500 })
const result = await pipe.exitPromise
// The key assertion: timedOut flag must be set
t.is(result.timedOut, true, 'timedOut flag is set')
// Process was terminated (exit code varies by runtime — may be 0, null, or signal code)
t.is(result.killed, false, 'killed flag is not set (timeout, not manual kill)')
})
// ---------------------------------------------------------------------------
// Test: kill() terminates process
// ---------------------------------------------------------------------------
test('kill terminates the git process', async (t) => {
t.timeout(10000)
const mock = createMockChannel()
const pipe = createGitPipe('sleep', '60', mock, { timeout: 30000 })
// Wait for process to start
await new Promise((resolve) => setTimeout(resolve, 100))
pipe.kill()
const result = await pipe.exitPromise
// The key assertion: killed flag must be set
t.is(result.killed, true, 'killed flag is set')
t.is(result.timedOut, false, 'timedOut flag is not set (manual kill, not timeout)')
})
// ---------------------------------------------------------------------------
// Test: channel onmessage is wired to git stdin
// ---------------------------------------------------------------------------
test('channel onmessage is wired to git stdin', async (t) => {
t.timeout(15000)
const repoPath = await createBareRepo()
await pushInitialCommit(repoPath)
const mock = createMockChannel()
const pipe = createGitPipe('git-upload-pack', repoPath, mock)
// Wait for ref advertisement
await waitForOutput(mock)
// Simulate receiving data through the channel (like Protomux would deliver).
// createGitPipe overrides mock.message.onmessage to forward to git stdin.
mock.message.onmessage(Buffer.from('0000'))
pipe.closeStdin()
const { exitCode } = await pipe.exitPromise
t.ok(exitCode === 0 || exitCode === 128, 'git process received channel data and exited')
// Clean up
spawn('rm', ['-rf', repoPath])
})
// ---------------------------------------------------------------------------
// Test: closeStdin ends git process stdin
// ---------------------------------------------------------------------------
test('closeStdin ends git process stdin', async (t) => {
t.timeout(15000)
const repoPath = await createBareRepo()
await pushInitialCommit(repoPath)
const mock = createMockChannel()
const pipe = createGitPipe('git-upload-pack', repoPath, mock)
// Wait for ref advertisement
await waitForOutput(mock)
// Close stdin without sending any wants — git-upload-pack should exit
pipe.closeStdin()
const result = await pipe.exitPromise
// git-upload-pack exits 0 or 128 depending on git version when stdin closes mid-protocol
t.ok(result.exitCode === 0 || result.exitCode === 128, 'git-upload-pack exits after stdin close')
t.is(result.timedOut, false, 'did not time out')
t.is(result.killed, false, 'was not killed')
// Clean up
spawn('rm', ['-rf', repoPath])
})