eths-git
Version:
**eths-git-remote** is a decentralized Git solution designed to manage repositories on-chain. It provides two main components:
415 lines (414 loc) • 16.1 kB
JavaScript
import { spawn } from 'node:child_process';
import { createReadStream } from 'node:fs';
import * as path from "path";
import { v4 as uuidv4 } from "uuid";
import fs from "fs/promises";
import os from "os";
import { log } from "./log.js";
// --- Helper Constants ---
const MAX_PACK_SIZE_DEFAULT = 10 * 1024 * 1024; // 10MB
// --- 1. General Process Execution Utilities ---
/**
* Determines the Current Working Directory (CWD) for Git commands.
* Assumes gitdir is the path to the .git directory; commands should run in the parent directory.
* @param gitdir Path to the .git directory.
* @returns The CWD for the command execution.
*/
function getCwd(gitdir) {
return path.dirname(gitdir);
}
/**
* Executes a command and captures its standard output.
* @param command The command to execute (e.g., 'git').
* @param args List of arguments.
* @param options SpawnOptions.
* @param input Optional input to pipe to stdin.
* @returns Promise<Buffer> A buffer containing the standard output.
*/
function spawnWithOutput(command, args, options, input) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
const chunks = [];
if (!child.stdout)
return reject(new Error(`stdout not piped for ${command}`));
child.stdout.on('data', (chunk) => chunks.push(chunk));
child.on('error', reject);
child.on('close', (code, signal) => {
if (code === 0) {
resolve(Buffer.concat(chunks));
}
else {
const fullCommand = `"${command} ${args.join(' ')}"`;
reject(new Error(`Command ${fullCommand} exited ${code ?? signal}`));
}
});
if (child.stdin && input)
child.stdin.end(input);
else if (child.stdin)
child.stdin.end(); // Ensure stdin is closed even if no input
});
}
/**
* Executes a command, ignoring its standard output.
* @param command The command to execute (e.g., 'git').
* @param args List of arguments.
* @param options SpawnOptions (default stdio: ['pipe', 'ignore', 'inherit']).
* @param input Optional input to pipe to stdin.
* @returns Promise<void>
*/
function spawnNoOutput(command, args, options = {}, input) {
const adjusted = {
...options,
// Default: stdin pipe, stdout ignore, stderr inherit
stdio: options.stdio ?? ['pipe', 'ignore', 'inherit'],
};
return new Promise((resolve, reject) => {
const child = spawn(command, args, adjusted);
child.on('error', reject);
child.on('close', (code, signal) => code === 0 ? resolve() : reject(new Error(`Command "${command} ${args.join(' ')}" exited ${code ?? signal}`)));
if (input && child.stdin)
child.stdin.end(input);
else if (child.stdin)
child.stdin.end();
});
}
// --- 2. Core Git Command Wrappers ---
/**
* Executes a Git command and captures its standard output.
* @param args Git command arguments (e.g., ['rev-list', 'HEAD']).
* @param gitdir Path to the .git directory.
* @returns Promise<string> The trimmed standard output.
*/
async function gitRunCapture(args, gitdir) {
const cwd = getCwd(gitdir);
const buf = await spawnWithOutput('git', args, { cwd, stdio: ['pipe', 'pipe', 'inherit'] });
return buf.toString('utf8').trim();
}
/**
* Executes a Git command, ignoring its standard output.
* @param args Git command arguments.
* @param gitdir Path to the .git directory.
* @returns Promise<void>
*/
async function gitRunNoOutput(args, gitdir) {
const cwd = getCwd(gitdir);
await spawnNoOutput('git', args, { cwd });
}
async function gitRunNoOutputIgnore(args, gitdir) {
const cwd = getCwd(gitdir);
await spawnNoOutput('git', args, { cwd, stdio: ['pipe', 'ignore', 'ignore'] });
}
// --- 3. Git Tool Functions (Using Core Wrappers) ---
/**
* Runs `git index-pack` to import a pack file into the repository.
* Tries with `--keep` first, and falls back to piping with `--fix-thin` on failure.
* @param packFilePath Path to the pack file.
* @param gitdir Path to the .git directory.
*/
export async function runGitPackFromFile(packFilePath, gitdir) {
const cwd = getCwd(gitdir);
try {
// Attempt standard index-pack with --keep
await gitRunNoOutputIgnore(['index-pack', '--keep', packFilePath], gitdir);
}
catch (err) {
await new Promise((resolve, reject) => {
const child = spawn('git', ['index-pack', '--fix-thin', '--stdin', '-v'], { cwd, stdio: ['pipe', 'ignore', 'pipe'] });
const stderr = [];
if (child.stderr)
child.stderr.on('data', d => stderr.push(d));
child.on('error', reject);
child.on('close', code => {
if (code === 0)
resolve();
else
reject(new Error(`git index-pack --fix-thin failed: ${Buffer.concat(stderr).toString()}`));
});
createReadStream(packFilePath).pipe(child.stdin);
});
}
}
/**
* Gets all commit OIDs under a specified ref.
* @param refName Branch or tag name (e.g., 'refs/heads/main').
* @param gitdir Path to the .git directory.
* @returns Promise<Set<string>> A set of commit OIDs.
*/
export async function getLocalCommitOids(refName, gitdir) {
try {
// Check if ref exists
await gitRunNoOutput(['show-ref', '--quiet', '--verify', refName], gitdir);
}
catch {
return new Set();
}
try {
const output = await gitRunCapture(['rev-list', refName], gitdir);
// gitRunCapture result is already trimmed
return new Set(output.split('\n').filter(Boolean));
}
catch (err) {
log(`[warn] 'git rev-list ${refName}' failed: ${err.message}`);
return new Set();
}
}
/**
* Gets the OID for a specified ref.
* @param refName Branch or tag name.
* @param gitdir Path to the .git directory.
* @returns Promise<string> The OID.
*/
export async function getOidFromRef(refName, gitdir) {
return gitRunCapture(["rev-parse", refName], gitdir);
}
/**
* Finds a local tracking branch that matches a remote ref based on git configuration.
* @param remoteRef Remote ref (e.g., 'refs/remotes/origin/main').
* @param gitdir Path to the .git directory.
* @returns Promise<string | null> The local ref (e.g., 'refs/heads/main') or null.
*/
export async function findMatchingLocalBranch(remoteRef, gitdir) {
try {
// Check if inside a work tree
await gitRunNoOutput(['rev-parse', '--is-inside-work-tree'], gitdir);
}
catch {
return null;
}
// Check if branch configs exist
try {
await gitRunCapture(['config', '--get-regexp', '^branch\\.'], gitdir);
}
catch {
return null;
}
let remote = 'origin';
let branchName;
const remotesMatch = remoteRef.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
if (remotesMatch) {
[, remote, branchName] = remotesMatch;
}
else if (remoteRef.startsWith('refs/heads/')) {
branchName = remoteRef.replace('refs/heads/', '');
}
else {
const parts = remoteRef.split('/');
if (parts.length >= 2) {
remote = parts[0];
branchName = parts.slice(1).join('/');
}
else {
return null;
}
}
const mergeRef = `refs/heads/${branchName}`;
let configOutput;
try {
configOutput = await gitRunCapture(['config', '--list'], gitdir);
}
catch {
return null;
}
const branchConfig = {};
for (const line of configOutput.split('\n')) {
const equalsIndex = line.indexOf('=');
if (equalsIndex === -1)
continue;
const key = line.substring(0, equalsIndex);
const val = line.substring(equalsIndex + 1);
const match = key.match(/^branch\.(.+?)\.(remote|merge)$/);
if (match) {
const [, branch, prop] = match;
branchConfig[branch] ||= {};
branchConfig[branch][prop] = val.trim();
}
}
let currentBranch = null;
try {
const output = await gitRunCapture(['rev-parse', '--abbrev-ref', 'HEAD'], gitdir);
if (output && output !== 'HEAD') {
currentBranch = output;
}
}
catch {
currentBranch = null;
}
// 1. Check current branch first
if (currentBranch) {
const currentConf = branchConfig[currentBranch];
if (currentConf?.remote === remote && currentConf?.merge === mergeRef) {
return `refs/heads/${currentBranch}`;
}
}
// 2. Iterate through all other branches
for (const [branch, { remote: r, merge: m }] of Object.entries(branchConfig)) {
if (r === remote && m === mergeRef) {
return `refs/heads/${branch}`;
}
}
return null;
}
// ======================= 4. Packfile Creation Utilities =========================================
/**
* Checks if a parentOid is an ancestor of a branchRef using `git merge-base --is-ancestor`.
* @param parentOid The potential ancestor OID.
* @param branchRef The branch reference.
* @param gitdir Path to the .git directory.
* @returns Promise<boolean> True if the parent is an ancestor, false otherwise.
*/
async function gitParentExistsOnBranch(parentOid, branchRef, gitdir) {
try {
await gitRunNoOutput(['merge-base', '--is-ancestor', parentOid, branchRef], gitdir);
return true;
}
catch (e) {
return false;
}
}
/**
* Gets all commit OIDs within a range [parentOid exclusion..newOid inclusion].
* @param newOid The ending OID of the range (inclusive).
* @param parentOid The starting OID of the range (exclusive, can be null).
* @param gitdir Path to the .git directory.
* @returns Promise<string[]> List of OIDs (in reverse topological order).
*/
async function gitGetAllCommits(newOid, parentOid, gitdir) {
const args = ['rev-list', '--topo-order', '--reverse', newOid];
if (parentOid)
args.push(`^${parentOid}`);
const out = await gitRunCapture(args, gitdir);
return out.split(/\s+/).filter(Boolean); // gitRunCapture result is already trimmed
}
/**
* Creates a pack file Buffer for the given commit range.
* @param endOid The ending OID of the range.
* @param parentOid The starting OID of the range (can be null).
* @param gitdir Path to the .git directory.
* @returns Promise<Buffer> The pack file content.
*/
async function gitCreatePackForRange(endOid, parentOid, gitdir) {
const revs = parentOid ? `${endOid}\n^${parentOid}\n` : `${endOid}\n`;
const args = ['pack-objects', '--stdout', '--revs', '--thin', '--delta-base-offset', '--quiet'];
const cwd = getCwd(gitdir);
return await spawnWithOutput('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }, Buffer.from(revs, 'utf8'));
}
/**
* Saves a Buffer to a temporary pack file.
* @param packDir The temporary directory path.
* @param buf The pack file content buffer.
* @returns Promise<string> The path to the saved pack file.
*/
async function savePackFile(packDir, buf) {
const prefix = `pack_${uuidv4().replace(/-/g, '')}`;
const outputBase = path.join(packDir, prefix);
const packPath = `${outputBase}.pack`;
await fs.writeFile(packPath, buf);
return packPath;
}
/**
* Uses a binary search approach to split the commit list into pack files
* that respect the maximum size limit.
* @param commits The list of commit OIDs.
* @param parentOid The OID where the chunking starts (exclusive).
* @param maxSizeBytes Maximum size allowed per pack file.
* @param tempPackDir Temporary directory for saving packs.
* @param gitdir Path to the .git directory.
* @returns Promise<PackCreationResult> Pack file chunk information.
*/
async function binarySearchPackCreation(commits, parentOid, maxSizeBytes, tempPackDir, gitdir) {
const MIN_SIZE_THRESHOLD = maxSizeBytes * 0.5;
const chunks = [];
let currentParent = parentOid;
let idx = 0;
while (idx < commits.length) {
let left = idx;
let right = commits.length - 1;
let best = idx;
// 1. Quick check: try to pack all remaining commits
try {
const packBuffer = await gitCreatePackForRange(commits[right], currentParent, gitdir);
if (packBuffer.length <= maxSizeBytes) {
const packfilePath = await savePackFile(tempPackDir, packBuffer);
chunks.push({
path: packfilePath,
size: packBuffer.length,
startOid: currentParent ?? '',
endOid: commits[right]
});
break; // Done
}
}
catch { }
// 2. Binary search for the optimal range endpoint
while (left <= right) {
const mid = Math.floor((left + right) / 2);
try {
const packBuffer = await gitCreatePackForRange(commits[mid], currentParent, gitdir);
if (packBuffer.length <= maxSizeBytes) {
best = mid; // Valid candidate
left = mid + 1; // Try larger range
}
else {
right = mid - 1; // Range too large
}
}
catch {
right = mid - 1; // Command failed (e.g., OOM), try smaller range
}
}
// 3. Create the final pack, try to merge one more commit to meet minimum size threshold
let packBuffer = await gitCreatePackForRange(commits[best], currentParent, gitdir);
if (packBuffer.length < MIN_SIZE_THRESHOLD && best + 1 < commits.length) {
const nextBestIndex = best + 1;
const nextEndOid = commits[nextBestIndex];
try {
// Try to include the next commit to avoid small pack files
packBuffer = await gitCreatePackForRange(nextEndOid, currentParent, gitdir);
best = nextBestIndex;
}
catch { }
}
const packfilePath = await savePackFile(tempPackDir, packBuffer);
chunks.push({
path: packfilePath,
size: packBuffer.length,
startOid: currentParent ?? '',
endOid: commits[best]
});
currentParent = commits[best];
idx = best + 1;
}
return { chunks, tempDir: tempPackDir };
}
/**
* Creates a series of pack files, chunking commits from parentOid to newOid
* based on a maximum size limit.
* @param branchRef The branch reference.
* @param newOid The latest commit OID.
* @param parentOid The old common ancestor OID (exclusive, can be null).
* @param gitdir Path to the .git directory.
* @param maxSizeBytes Maximum size allowed per pack file.
* @returns Promise<PackCreationResult> Pack file chunk info and temporary directory.
*/
export async function createCommitBoundaryPacks(branchRef, newOid, parentOid, gitdir, maxSizeBytes = MAX_PACK_SIZE_DEFAULT) {
const tempPackDir = path.join(os.tmpdir(), `git-adaptive-${uuidv4().substring(0, 8)}`);
await fs.mkdir(tempPackDir, { recursive: true });
try {
let validParent = parentOid;
// 1. Validate parentOid
if (parentOid && !(await gitParentExistsOnBranch(parentOid, branchRef, gitdir))) {
log(`[warn] parentOid ${parentOid} not an ancestor of ${branchRef}, ignoring.`);
validParent = null;
}
// 2. Get the list of commits
const commits = await gitGetAllCommits(newOid, validParent, gitdir);
if (!commits.length)
return { chunks: [], tempDir: tempPackDir };
// 3. Complex path: Binary search chunking
return await binarySearchPackCreation(commits, validParent, maxSizeBytes, tempPackDir, gitdir);
}
catch (err) {
await fs.rm(tempPackDir, { recursive: true, force: true }).catch(() => { });
throw new Error(`create packfile failed: ${err.message}`);
}
}