import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { createHash } from 'bare-crypto'
import { deflateSync } from 'bare-zlib'
import { readLooseObject, writeLooseObject } from '../../lib/git/object-reader.js'
const tmpDir = path.join(os.tmpdir(), 'pear-git-test-object-reader-' + Date.now())
test('setup: create temp dir', async (t) => {
await fs.promises.mkdir(tmpDir, { recursive: true })
t.pass('temp dir created')
})
test('round-trip: write blob then read it back', async (t) => {
const objectsDir = path.join(tmpDir, 'roundtrip-blob', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const data = Buffer.from('hello world\n')
const sha = await writeLooseObject(objectsDir, 'blob', data)
t.is(sha.length, 40, 'SHA is 40 hex chars')
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'blob', 'type is blob')
t.is(obj.size, data.length, 'size matches data length')
t.alike(obj.data, data, 'data matches original')
t.is(obj.sha, sha, 'sha matches')
})
test('round-trip: write commit then read it back', async (t) => {
const objectsDir = path.join(tmpDir, 'roundtrip-commit', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const commitContent = Buffer.from(
'tree aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' +
'author Test User <test@example.com> 1234567890 +0000\n' +
'committer Test User <test@example.com> 1234567890 +0000\n' +
'\n' +
'Initial commit\n'
)
const sha = await writeLooseObject(objectsDir, 'commit', commitContent)
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'commit', 'type is commit')
t.is(obj.size, commitContent.length, 'size matches')
t.alike(obj.data, commitContent, 'data matches original')
})
test('round-trip: write tree then read it back', async (t) => {
const objectsDir = path.join(tmpDir, 'roundtrip-tree', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
// A tree entry is: "<mode> <name>\0<20-byte SHA>"
const name = 'hello.txt'
const sha20 = Buffer.alloc(20, 0xab)
const entry = Buffer.concat([
Buffer.from('100644 ' + name + '\0'),
sha20
])
const sha = await writeLooseObject(objectsDir, 'tree', entry)
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'tree', 'type is tree')
t.is(obj.size, entry.length, 'size matches')
t.alike(obj.data, entry, 'data matches original')
})
test('round-trip: write tag then read it back', async (t) => {
const objectsDir = path.join(tmpDir, 'roundtrip-tag', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const tagContent = Buffer.from(
'object aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' +
'type commit\n' +
'tag v1.0.0\n' +
'tagger Test User <test@example.com> 1234567890 +0000\n' +
'\n' +
'Release v1.0.0\n'
)
const sha = await writeLooseObject(objectsDir, 'tag', tagContent)
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'tag', 'type is tag')
t.alike(obj.data, tagContent, 'data matches original')
})
test('SHA1 verification: computed SHA matches git format', async (t) => {
// Manually compute what git would produce for "hello world\n"
const data = Buffer.from('hello world\n')
const header = Buffer.from('blob ' + data.length + '\0')
const store = Buffer.concat([header, data])
const expectedSha = createHash('sha1').update(store).digest('hex')
const objectsDir = path.join(tmpDir, 'sha-verify', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const sha = await writeLooseObject(objectsDir, 'blob', data)
t.is(sha, expectedSha, 'SHA matches manual computation')
})
test('write creates correct directory structure (XX/YYYY...)', async (t) => {
const objectsDir = path.join(tmpDir, 'dir-structure', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const data = Buffer.from('test content\n')
const sha = await writeLooseObject(objectsDir, 'blob', data)
const prefix = sha.slice(0, 2)
const suffix = sha.slice(2)
const objPath = path.join(objectsDir, prefix, suffix)
const exists = await fs.promises.stat(objPath).then(() => true).catch(() => false)
t.ok(exists, 'object file exists at XX/YYYY... path')
// Verify the directory was created
const dirExists = await fs.promises.stat(path.join(objectsDir, prefix)).then(s => s.isDirectory()).catch(() => false)
t.ok(dirExists, 'prefix directory exists')
})
test('read throws on corrupted SHA (wrong hash)', async (t) => {
const objectsDir = path.join(tmpDir, 'corrupt-sha', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
// Write a valid object first
const data = Buffer.from('good data\n')
const sha = await writeLooseObject(objectsDir, 'blob', data)
// Corrupt the SHA by using a different one for the lookup path
// Write different content to the same path to simulate corruption
const prefix = sha.slice(0, 2)
const suffix = sha.slice(2)
const objPath = path.join(objectsDir, prefix, suffix)
// Replace file content with different compressed data
const corruptData = Buffer.from('corrupted!\n')
const corruptHeader = Buffer.from('blob ' + corruptData.length + '\0')
const corruptStore = Buffer.concat([corruptHeader, corruptData])
const compressed = deflateSync(corruptStore)
await fs.promises.writeFile(objPath, compressed)
// Now read with the original SHA — should fail because content hash differs
try {
await readLooseObject(objectsDir, sha)
t.fail('should have thrown on SHA mismatch')
} catch (err) {
t.ok(err.message.includes('SHA1'), 'error mentions SHA1 mismatch')
}
})
test('read throws on nonexistent object', async (t) => {
const objectsDir = path.join(tmpDir, 'missing-obj', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const fakeSha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
try {
await readLooseObject(objectsDir, fakeSha)
t.fail('should have thrown for missing object')
} catch (err) {
t.ok(err, 'throws error for missing object')
}
})
test('read throws on invalid SHA format', async (t) => {
const objectsDir = path.join(tmpDir, 'bad-sha-format', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
try {
await readLooseObject(objectsDir, 'not-a-sha')
t.fail('should have thrown for invalid SHA')
} catch (err) {
t.ok(err.message.includes('40-char'), 'error mentions 40-char requirement')
}
})
test('handles empty blob', async (t) => {
const objectsDir = path.join(tmpDir, 'empty-blob', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const data = Buffer.alloc(0)
const sha = await writeLooseObject(objectsDir, 'blob', data)
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'blob', 'type is blob')
t.is(obj.size, 0, 'size is 0')
t.is(obj.data.length, 0, 'data is empty buffer')
})
test('handles binary data in blob', async (t) => {
const objectsDir = path.join(tmpDir, 'binary-blob', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
// Create binary data with null bytes and high bytes
const data = Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x00, 0x80, 0x7f, 0x00])
const sha = await writeLooseObject(objectsDir, 'blob', data)
const obj = await readLooseObject(objectsDir, sha)
t.is(obj.type, 'blob', 'type is blob')
t.alike(obj.data, data, 'binary data round-trips correctly')
})
test('write is idempotent (same content gives same SHA)', async (t) => {
const objectsDir = path.join(tmpDir, 'idempotent', 'objects')
await fs.promises.mkdir(objectsDir, { recursive: true })
const data = Buffer.from('same content\n')
const sha1 = await writeLooseObject(objectsDir, 'blob', data)
const sha2 = await writeLooseObject(objectsDir, 'blob', data)
t.is(sha1, sha2, 'same content produces same SHA')
})
// --- Integration tests against real bare git repos ---
test('readLooseObject works on real bare git repo', async (t) => {
// Find first loose object in a real repo
const reposDir = path.join(os.homedir(), 'git-repos')
let objectsDir = null
try {
const entries = await fs.promises.readdir(reposDir)
for (const e of entries) {
if (e.endsWith('.git')) {
const candidate = path.join(reposDir, e, 'objects')
try { await fs.promises.stat(candidate); objectsDir = candidate; break } catch {}
}
}
} catch {}
if (!objectsDir) { t.pass('skip: no bare repos'); return }
// Find first loose object (2-char dir with files)
const prefixes = await fs.promises.readdir(objectsDir)
for (const prefix of prefixes) {
if (prefix === 'pack' || prefix === 'info' || prefix.length !== 2) continue
const dirPath = path.join(objectsDir, prefix)
try {
const files = await fs.promises.readdir(dirPath)
if (files.length > 0) {
const sha = prefix + files[0]
const obj = await readLooseObject(objectsDir, sha)
t.ok(['blob', 'tree', 'commit', 'tag'].includes(obj.type), 'type is valid: ' + obj.type)
t.ok(obj.size >= 0, 'has valid size')
t.ok(obj.data.length >= 0, 'has data')
t.is(obj.sha, sha, 'SHA matches')
return
}
} catch {}
}
t.pass('no loose objects found (all packed)')
})
test('cleanup: remove temp dir', async (t) => {
await fs.promises.rm(tmpDir, { recursive: true, force: true })
t.pass('cleaned up')
})