clank-together/src/server/git.js
Clank Together 836610da8f Clank Together: coordinator, sandboxed verifier and agent client
Coordinator (accounts with approval, API tokens, tasks with leases, sandboxed MSVC verification against
the original exe, peer review by three agents for non-identical code, human review, serial merge queue),
the `td` contributor CLI with an MCP server for agents, pinned Ghidra integration, deployment (Caddy,
systemd, nftables, Forgejo) and the wowdecomp 15890 migration tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNwtXPYqFu3oKaaXwYwNWW
2026-10-18 01:29:16 +02:00

157 lines
4.9 KiB
JavaScript

// Canonical repository handling: reads, three-way merges, commits, and
// read-only clone access through `git http-backend`.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, execFileSync } from 'node:child_process';
export class Repo {
constructor(dir) {
this.dir = dir;
}
git(args, opts = {}) {
return execFileSync('git', args, { cwd: this.dir, encoding: opts.encoding ?? 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], input: opts.input });
}
head() {
return this.git(['rev-parse', 'HEAD']).trim();
}
isAncestor(commit) {
try {
this.git(['merge-base', '--is-ancestor', commit, 'HEAD']);
return true;
} catch {
return false;
}
}
/** File content at a commit, or null if it does not exist there. */
show(commit, file) {
try {
return this.git(['show', `${commit}:${file}`]);
} catch {
return null;
}
}
isClean() {
return this.git(['status', '--porcelain']).trim() === '';
}
/**
* Three-way merges submitted files (written against `base`) onto HEAD.
* @returns {{files: Record<string,string>, conflicts: string[]}}
*/
mergeOntoHead(base, files) {
const head = this.head();
const out = {};
const conflicts = [];
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'td-merge-'));
try {
for (const [file, theirs] of Object.entries(files)) {
const baseText = this.show(base, file);
const headText = this.show(head, file);
if (baseText === headText || headText === null) {
out[file] = theirs;
continue;
}
if (baseText === null) {
// Both sides created the file independently.
conflicts.push(file);
continue;
}
const [b, h, t] = ['base', 'head', 'theirs'].map((n) => path.join(tmp, n));
fs.writeFileSync(b, baseText);
fs.writeFileSync(h, headText);
fs.writeFileSync(t, theirs);
try {
out[file] = execFileSync('git', ['merge-file', '-p', h, b, t], { encoding: 'utf8' });
} catch {
conflicts.push(file);
}
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
return { files: out, conflicts };
}
commitFiles(files, message, author) {
for (const [file, content] of Object.entries(files)) {
const abs = path.join(this.dir, file);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
this.git(['add', '--', ...Object.keys(files)]);
this.git(['-c', 'user.name=Clank Together', '-c', 'user.email=bot@clank-together.invalid', 'commit', '-q', '--author', author, '-F', '-'], { input: message });
return this.head();
}
/** Discards uncommitted changes (after a failed merge attempt). */
reset() {
this.git(['reset', '-q', '--hard', 'HEAD']);
this.git(['clean', '-qfd']);
}
push(remote) {
this.git(['push', '-q', remote, 'HEAD']);
}
}
/**
* Serves read-only smart-HTTP clones of `repoDir` under `/git/repo`.
* Pushes are refused here and in the repo config.
*/
export function serveGit(req, res, { projectRoot, subPath, query }) {
const allowed = subPath === '/repo/info/refs' ? query.get('service') === 'git-upload-pack' : subPath === '/repo/git-upload-pack' || subPath === '/repo/HEAD';
if (!allowed) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('read-only repository\n');
return;
}
const env = {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: projectRoot,
GIT_HTTP_EXPORT_ALL: '1',
PATH_INFO: subPath,
REQUEST_METHOD: req.method,
QUERY_STRING: query.toString(),
CONTENT_TYPE: req.headers['content-type'] ?? '',
REMOTE_ADDR: req.socket.remoteAddress ?? '',
HTTP_CONTENT_ENCODING: req.headers['content-encoding'] ?? '',
GIT_PROTOCOL: req.headers['git-protocol'] ?? '',
};
if (req.headers['content-length']) env.CONTENT_LENGTH = req.headers['content-length'];
const child = spawn('git', ['http-backend'], { env });
req.pipe(child.stdin);
let headerBuf = Buffer.alloc(0);
let headersDone = false;
child.stdout.on('data', (chunk) => {
if (headersDone) return void res.write(chunk);
headerBuf = Buffer.concat([headerBuf, chunk]);
const idx = headerBuf.indexOf('\r\n\r\n');
if (idx < 0) return;
headersDone = true;
let status = 200;
const headers = {};
for (const line of headerBuf.subarray(0, idx).toString().split('\r\n')) {
const c = line.indexOf(':');
if (c < 0) continue;
const k = line.slice(0, c).trim();
const v = line.slice(c + 1).trim();
if (k.toLowerCase() === 'status') status = parseInt(v, 10);
else headers[k] = v;
}
res.writeHead(status, headers);
res.write(headerBuf.subarray(idx + 4));
});
child.stdout.on('end', () => res.end());
child.on('error', () => {
if (!res.headersSent) res.writeHead(500);
res.end();
});
}