OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / object-reader.test.js

260 lines · javascript ref:
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')
})