specpress
Version:
Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files
218 lines (202 loc) • 8.24 kB
JavaScript
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
/**
* Git utility functions for specification processing.
*
* These are VSCode-independent and can be used in CLI tools and CI pipelines.
*/
/**
* Returns the git repository root for a given path.
*
* @param {string} filePath - Any path inside the repository.
* @returns {string} Absolute path to the repository root.
*/
function getRepoRoot(filePath) {
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: filePath, encoding: 'utf8' }).trim()
}
/**
* Reads a file's content from a specific git commit as a UTF-8 string.
*
* @param {string} repoRoot - Absolute path to the repository root.
* @param {string} absPath - Absolute path to the file.
* @param {string} commit - Git commit reference (hash, HEAD, tag, etc.).
* @returns {string} File content at that commit.
*/
function getFileFromCommit(repoRoot, absPath, commit) {
const rel = path.relative(repoRoot, absPath).replace(/\\/g, '/')
return execFileSync('git', ['show', `${commit}:${rel}`], { cwd: repoRoot, encoding: 'utf8' })
}
/**
* Reads a file's content from a specific git commit as a Buffer (binary-safe).
*
* @param {string} repoRoot - Absolute path to the repository root.
* @param {string} absPath - Absolute path to the file.
* @param {string} commit - Git commit reference (hash, HEAD, tag, etc.).
* @returns {Buffer} File content at that commit.
*/
function getBinaryFileFromCommit(repoRoot, absPath, commit) {
const rel = path.relative(repoRoot, absPath).replace(/\\/g, '/')
return execFileSync('git', ['show', `${commit}:${rel}`], { cwd: repoRoot, maxBuffer: 50 * 1024 * 1024 })
}
/**
* Collects spec files that exist in a git commit, filtered to the given paths.
*
* For each path, lists tracked files under that path at the given commit.
* Filters for .md, .markdown, and .asn extensions.
*
* @param {string} repoRoot - Absolute path to the repository root.
* @param {string[]} paths - Absolute paths to files or directories.
* @param {string} commit - Git commit reference.
* @returns {string[]} Sorted array of absolute file paths that exist in the commit.
*/
function collectFilesFromCommit(repoRoot, paths, commit) {
const files = []
for (const p of paths) {
const rel = path.relative(repoRoot, p).replace(/\\/g, '/')
const isDir = fs.existsSync(p) && fs.statSync(p).isDirectory()
const prefix = isDir ? (rel ? rel + '/' : '.') : (rel || '.')
try {
const listing = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, '--', prefix], { cwd: repoRoot, encoding: 'utf8' })
listing.trim().split('\n').filter(Boolean).forEach(f => {
if (f.endsWith('.md') || f.endsWith('.markdown') || f.endsWith('.asn')) {
files.push(path.join(repoRoot, f))
}
})
} catch (e) {
// path may not exist in that commit — skip
}
}
return [...new Set(files)].sort()
}
/**
* Returns recent git log entries.
*
* @param {string} repoRoot - Absolute path to the repository root.
* @param {number} [maxCount=200] - Maximum number of entries to return.
* @returns {{hash: string, shortHash: string, subject: string, refNames: string}[]}
*/
function getGitLog(repoRoot, maxCount = 200) {
const raw = execFileSync(
'git', ['log', '--all', `--format=%H%x09%h%x09%s%x09%D`, '-n', String(maxCount)],
{ cwd: repoRoot, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
)
return raw.trim().split('\n').filter(Boolean).map(line => {
const [hash, shortHash, subject, refNames] = line.split('\t')
return { hash, shortHash, subject, refNames: refNames || '' }
})
}
/**
* Bulk-extracts spec-relevant files from a git commit using git archive + tar parsing.
* Returns a Map of absolute path to content (Buffer for binary files, string for text).
*
* @param {string} repoRoot - Absolute path to the repository root.
* @param {string} commit - Git commit reference.
* @param {string[]} searchPaths - Absolute paths to directories to extract from.
* @returns {Map<string, Buffer|string>} Map of absolute file path to content.
*/
function extractFilesFromCommit(repoRoot, commit, searchPaths) {
const cache = new Map()
for (const p of searchPaths) {
const rel = path.relative(repoRoot, p).replace(/\\/g, '/')
const prefix = rel ? rel + '/' : ''
try {
const tar = execFileSync('git', ['archive', commit, '--', prefix], {
cwd: repoRoot, maxBuffer: 50 * 1024 * 1024
})
let offset = 0
let longName = ''
while (offset < tar.length - 512) {
const header = tar.slice(offset, offset + 512)
// Two consecutive zero blocks mark end of archive
if (header.every(b => b === 0)) break
const nameField = header.slice(0, 100).toString().replace(/\0/g, '').trim()
const sizeStr = header.slice(124, 136).toString().replace(/\0/g, '').trim()
const size = parseInt(sizeStr, 8) || 0
const typeFlag = header[156] // ASCII char: '0'=file, '5'=dir, 'L'=GNU long name, 'x'/'g'=pax
// GNU ustar prefix (bytes 345-500) for paths > 100 chars
const prefixField = header.slice(345, 500).toString().replace(/\0/g, '').trim()
offset += 512
// Handle GNU long name extension
if (typeFlag === 76 /* 'L' */) {
longName = tar.slice(offset, offset + size).toString().replace(/\0/g, '').trim()
offset += Math.ceil(size / 512) * 512
continue
}
// Skip pax extended headers
if (typeFlag === 120 /* 'x' */ || typeFlag === 103 /* 'g' */) {
offset += Math.ceil(size / 512) * 512
continue
}
// Resolve actual file name
const name = longName || (prefixField ? prefixField + '/' + nameField : nameField)
longName = ''
// Skip directories (type '5' or name ending with '/')
if (typeFlag === 53 /* '5' */ || name.endsWith('/')) {
offset += Math.ceil(size / 512) * 512
continue
}
if (size > 0 && /\.(md|markdown|asn|json|png|jpg|jpeg|gif|bmp|svg)$/.test(name)) {
const isImage = /\.(png|jpg|jpeg|gif|bmp|svg)$/.test(name)
const content = isImage
? tar.slice(offset, offset + size)
: tar.slice(offset, offset + size).toString('utf8')
cache.set(path.join(repoRoot, name), content)
}
offset += Math.ceil(size / 512) * 512
}
} catch (e) { /* path may not exist in that commit */ }
}
return cache
}
/**
* Creates a file resolver from a pre-extracted cache.
* Falls back to the local filesystem if the file isn't in the cache.
*
* @param {Map<string, Buffer|string>} cache - Map from extractFilesFromCommit.
* @returns {function(string): Buffer|string} File resolver function.
*/
function makeCachedFileResolver(cache) {
const normPath = (p) => p.replace(/\\/g, '/').toLowerCase()
return (filePath) => {
if (cache.has(filePath)) return cache.get(filePath)
const target = normPath(filePath)
for (const [key, val] of cache) {
if (normPath(key) === target) return val
}
return fs.readFileSync(filePath)
}
}
/**
* Creates a text file reader from a pre-extracted cache (for concatenateFiles).
* Falls back to the local filesystem if the file isn't in the cache.
*
* @param {Map<string, Buffer|string>} cache - Map from extractFilesFromCommit.
* @returns {function(string): string} Text reader function.
*/
function makeCachedTextReader(cache) {
const normPath = (p) => p.replace(/\\/g, '/').toLowerCase()
return (filePath) => {
if (cache.has(filePath)) {
const content = cache.get(filePath)
return Buffer.isBuffer(content) ? content.toString('utf8') : content
}
const target = normPath(filePath)
for (const [key, val] of cache) {
if (normPath(key) === target) {
return Buffer.isBuffer(val) ? val.toString('utf8') : val
}
}
return fs.readFileSync(filePath, 'utf8')
}
}
module.exports = {
getRepoRoot,
getFileFromCommit,
getBinaryFileFromCommit,
collectFilesFromCommit,
getGitLog,
extractFilesFromCommit,
makeCachedFileResolver,
makeCachedTextReader
}