|
import path from 'path'; |
|
import { promises as fsP } from 'fs'; |
|
import stream from 'stream'; |
|
import EventEmitter from 'events'; |
|
import { GitExecOptions, GitProcess, LineReader, git } from './spawn.js'; |
|
import createDebug from 'debug'; |
|
const debug = createDebug('git:repository'); |
|
|
|
/** null object id */ |
|
export const NULL_ID = '0000000000000000000000000000000000000000'; |
|
|
|
/** file modes used by git */ |
|
export enum FileMode { |
|
/** regular file, not executable */ |
|
Regular = 100644, |
|
/** regular file, executable */ |
|
RegularExec = 100755, |
|
/** symbolic link */ |
|
Symlink = 120000, |
|
/** not sure */ |
|
Gitlink = 160000, |
|
/** fake mode used to delete entries from index */ |
|
Delete = 0 |
|
} |
|
|
|
export interface GitAuthorInfo { |
|
authorName: string; |
|
authorEmail: string; |
|
committerName?: string; |
|
committerEmail?: string; |
|
} |
|
|
|
export async function streamWrite(stream: stream.Writable, chunk: Buffer | string) { |
|
let canContinue = stream.write(chunk); |
|
if (!canContinue) { |
|
await EventEmitter.once(stream, 'drain'); |
|
} |
|
} |
|
|
|
export class BaseRepository { |
|
constructor(public gitdir: string) {} |
|
|
|
git(argv: string[], opts?: GitExecOptions): GitProcess { |
|
return git(argv, Object.assign({ gitdir: this.gitdir }, opts)); |
|
} |
|
|
|
/** parse a revision name to an id */ |
|
async revParse(rev: string): Promise<string | null> { |
|
let proc = await this.git(['rev-parse', '--verify', '--end-of-options', rev]) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
|
|
if (proc.succeeded) { |
|
return proc.collectedStdout!.trim(); |
|
} else { |
|
return null; |
|
} |
|
} |
|
|
|
/** perform various cleanup tasks on repository */ |
|
async gc(auto = true, prune = '8.hours.ago') { |
|
let argv = ['gc', '--prune=' + prune]; |
|
if (auto) argv.push('--auto'); |
|
let proc = await this.git(argv) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('repository gc'); |
|
} |
|
|
|
/** read object from object database */ |
|
readObject(id: string, type = 'blob'): stream.Readable { |
|
let proc = this.git(['cat-file', type, id]) |
|
.collectStderr(); |
|
|
|
(async () => { |
|
// ensure errors go to the stream |
|
await proc.wait(); |
|
try { |
|
proc.assertSucceeded('reading object'); |
|
} catch (err) { |
|
proc.stdout.emit('error', err); |
|
} |
|
})(); |
|
|
|
return proc.stdout; |
|
} |
|
|
|
/** write object to object database, returning id */ |
|
writeObject(type = 'blob'): [stream.Writable, Promise<string>] { |
|
let proc = this.git(['hash-object', '-t', type, '-w', '--stdin'], { stdin: true }) |
|
.collectStdout() |
|
.collectStderr(); |
|
|
|
return [ |
|
proc.stdin!, |
|
(async () => { |
|
await proc.wait(); |
|
proc.assertSucceeded('writing object'); |
|
return proc.collectedStdout!.trim(); |
|
})() |
|
]; |
|
} |
|
|
|
/** create an empty tree object */ |
|
async createEmptyTree(): Promise<string> { |
|
let tmpIndex = path.join(this.gitdir, 'index.tmp-' + Math.random().toString(36).slice(2)); |
|
try { |
|
// create empty tree object |
|
let proc = await this.git(['write-tree'], { env: { GIT_INDEX_FILE: tmpIndex } }) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('write empty tree'); |
|
return proc.collectedStdout!.trim(); |
|
} finally { |
|
await fsP.unlink(tmpIndex); |
|
} |
|
} |
|
|
|
/** create a commit from a tree object */ |
|
async commitTree(tree: string, parents: string[], message: string, author: GitAuthorInfo): Promise<string> { |
|
let proc = this.git(['commit-tree', ...parents.flatMap(p => ['-p', p]), tree], { |
|
config: { |
|
'author.name': author.authorName, |
|
'author.email': author.authorEmail, |
|
'committer.name': author.committerName ?? author.authorName, |
|
'committer.email': author.committerEmail ?? author.authorEmail |
|
}, |
|
stdin: true |
|
}) |
|
.collectStdout() |
|
.collectStderr(); |
|
|
|
proc.stdin!.write(message); |
|
proc.stdin!.end(); |
|
|
|
await proc.wait(); |
|
proc.assertSucceeded('commit tree'); |
|
return proc.collectedStdout!.trim(); |
|
} |
|
|
|
/** update a ref */ |
|
async updateRef(ref: string, newValue: string, oldValue?: string) { |
|
let argv = ['update-ref', ref, newValue]; |
|
if (oldValue) argv.push(oldValue); |
|
let proc = await this.git(argv) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('updating ref'); |
|
} |
|
|
|
/** delete a ref */ |
|
async deleteRef(ref: string, oldValue?: string) { |
|
let argv = ['update-ref', '-d', ref]; |
|
if (oldValue) argv.push(oldValue); |
|
let proc = await this.git(argv) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('deleting ref'); |
|
} |
|
|
|
/** update multiple refs */ |
|
updateRefs(): UpdateRef { |
|
let proc = this.git(['update-ref', '--stdin', '-z'], { stdin: true }); |
|
return new UpdateRef(proc); |
|
} |
|
|
|
/** list all refs */ |
|
async *listRefs(): AsyncGenerator<{ ref: string, id: string }, void, never> { |
|
let proc = this.git(['show-ref']) |
|
.collectStderr(); |
|
|
|
let lineReader = new LineReader('\n'); |
|
proc.stdout.pipe(lineReader); |
|
|
|
while (true) { |
|
let line = await lineReader.nextLine(); |
|
if (!line) break; |
|
let [id, ref] = line.split(' '); |
|
yield { ref, id }; |
|
} |
|
|
|
await proc.wait(); |
|
proc.assertSucceeded('listing refs'); |
|
} |
|
} |
|
|
|
function expect(result: string, expected: string, what: string) { |
|
if (result !== expected) { |
|
throw new Error(`${what}: unexpected output "${result}"`); |
|
} |
|
} |
|
|
|
export class UpdateRef { |
|
stdoutReader: LineReader; |
|
stderrReader: LineReader; |
|
errorWait: Promise<string | null>; |
|
stdin: stream.Writable; |
|
|
|
constructor(public proc: GitProcess) { |
|
this.stdoutReader = new LineReader('\n'); |
|
proc.stdout.pipe(this.stdoutReader); |
|
this.stderrReader = new LineReader('\n'); |
|
proc.stderr.pipe(this.stderrReader); |
|
|
|
this.errorWait = this.stderrReader.nextLine(); |
|
this.stdin = proc.stdin!; |
|
} |
|
|
|
async command(command: string, expectOutput?: false): Promise<null>; |
|
async command(command: string, expectOutput: true): Promise<string>; |
|
async command(command: string, expectOutput?: boolean): Promise<string | null> { |
|
if (this.proc.exited) throw new Error('already ended'); |
|
await streamWrite(this.stdin, command + '\x00'); |
|
if (expectOutput) { |
|
let [which, output] = await Promise.race([ |
|
this.stdoutReader.nextLine().then(r => ['out', r]), |
|
this.errorWait.then(r => ['err', r]) |
|
]); |
|
if (which === 'out') { |
|
return output; |
|
} else { |
|
throw new Error('command failed: ' + output); |
|
} |
|
} else { |
|
return null; |
|
} |
|
} |
|
|
|
/** start transaction */ |
|
async start() { |
|
let output = await this.command('start', true); |
|
expect(output, 'start: ok', 'update-ref start transaction'); |
|
} |
|
|
|
/** rollback transaction */ |
|
async abort() { |
|
let output = await this.command('abort', true); |
|
expect(output, 'abort: ok', 'update-ref abort transaction'); |
|
} |
|
|
|
/** prepare for commit */ |
|
async prepare() { |
|
let output = await this.command('prepare', true); |
|
expect(output, 'prepare: ok', 'update-ref prepare transaction'); |
|
} |
|
|
|
/** commit transaction */ |
|
async commit() { |
|
let output = await this.command('commit', true); |
|
expect(output, 'commit: ok', 'update-ref commit transaction'); |
|
} |
|
|
|
/** update a ref, with optional old value */ |
|
async update(ref: string, newValue: string, oldValue?: string) { |
|
await this.command(`update ${ref}\x00${newValue}\x00${oldValue ?? ''}`); |
|
} |
|
|
|
/** create a new ref */ |
|
async create(ref: string, value: string) { |
|
await this.command(`create ${ref}\x00${value}`); |
|
} |
|
|
|
/** verify current value of ref */ |
|
async verify(ref: string, value: string | null) { |
|
await this.command(`verify ${ref}\x00${value ?? ''}`); |
|
} |
|
|
|
/** set option for next operation */ |
|
async option(option: string) { |
|
await this.command(`option ${option}`); |
|
} |
|
|
|
/** |
|
* end session |
|
* |
|
* Note: commit must have been run before, or nothing will happen. |
|
*/ |
|
async end() { |
|
if (this.proc.exited) throw new Error('already ended'); |
|
this.stdin.end(); |
|
await EventEmitter.once(this.stdin, 'finish'); |
|
await this.proc.wait(); |
|
this.proc.assertSucceeded('updating ref'); |
|
} |
|
} |
|
|
|
export class Repository extends BaseRepository { |
|
/** open a repository at given path */ |
|
static async open(repoPath: string) { |
|
let proc = await git(['rev-parse', '--absolute-git-dir'], { cwd: repoPath }) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
|
|
if (!proc.succeeded) { |
|
throw new Error('failed to open repository: ' + proc.collectedStderr!.trim()); |
|
} |
|
|
|
let gitdir = proc.collectedStdout!.trim(); |
|
|
|
// ensure config exists, might be needed later |
|
try { |
|
await fsP.stat(path.join(gitdir, 'config')); |
|
} catch (err) { |
|
throw new Error('failed to open repository: could not find config'); |
|
} |
|
|
|
debug(`open repository: ${repoPath} -> ${gitdir}`); |
|
return new Repository(gitdir); |
|
} |
|
|
|
/** initialize a repository at given path */ |
|
static async create(repoPath: string) { |
|
let proc = await git(['init', '--bare'], { gitdir: repoPath }) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('create repository'); |
|
|
|
// clean up sample hooks |
|
let hooksDir = path.join(repoPath, 'hooks'); |
|
let hooks = await fsP.readdir(hooksDir); |
|
for (let hook of hooks) { |
|
if (hook.endsWith('.sample')) { |
|
fsP.unlink(path.join(hooksDir, hook)); |
|
} |
|
} |
|
|
|
// reopen |
|
return this.open(repoPath); |
|
} |
|
|
|
/** create a worktree */ |
|
async createWorktree(name: string, from: string): Promise<Worktree> { |
|
if (name.includes('/')) throw new Error('worktree name cannot contain slash'); |
|
|
|
let fromId = await this.revParse(from); |
|
if (!fromId) throw new Error('invalid rev passed as from'); |
|
|
|
let worktreePath = path.join(this.gitdir, 'worktrees', name); |
|
await fsP.mkdir(worktreePath, { recursive: true }); |
|
try { |
|
// ensure new worktree is locked to prevent gc |
|
await fsP.writeFile( |
|
path.join(worktreePath, 'locked'), |
|
'externally managed' |
|
); |
|
|
|
// set up common directory |
|
await fsP.writeFile( |
|
path.join(worktreePath, 'commondir'), |
|
'../..', |
|
{ flag: 'wx' } |
|
); |
|
} catch (err: any) { |
|
if (err?.code === 'EEXIST') { |
|
throw new Error('worktree already exists'); |
|
} else { |
|
throw err; |
|
} |
|
} |
|
|
|
// point gitdir somewhere |
|
// needed so prune doesn't remove objects used by the worktree |
|
await fsP.writeFile(path.join(worktreePath, 'gitdir'), `[managed: ${name}]`); |
|
// set up HEAD |
|
await fsP.writeFile(path.join(worktreePath, 'HEAD'), fromId); |
|
// set up index |
|
let worktree = new Worktree(worktreePath); |
|
await worktree.readTree(fromId); |
|
|
|
return worktree; |
|
} |
|
|
|
/** remove a worktree */ |
|
async removeWorktree(name: string) { |
|
let worktreePath = path.join(this.gitdir, 'worktrees', name); |
|
await fsP.rm(worktreePath, { recursive: true }); |
|
} |
|
|
|
/** |
|
* open a worktree |
|
* |
|
* If `worktreePath` contains a slash, it is interpreted as a path. Otherwise, |
|
* it is interpreted as a name under .git/worktrees. |
|
*/ |
|
async openWorktree(worktreePath: string | null): Promise<Worktree> { |
|
if (worktreePath === null) { |
|
// open "main" worktree |
|
worktreePath = this.gitdir; |
|
} else if (!worktreePath.includes('/')) { |
|
// open path as name |
|
worktreePath = path.join(this.gitdir, 'worktrees', worktreePath); |
|
} |
|
|
|
let proc = await git(['rev-parse', '--absolute-git-dir'], { cwd: worktreePath }) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
|
|
if (!proc.succeeded) { |
|
throw new Error('failed to open repository: ' + proc.collectedStderr!.trim()); |
|
} |
|
|
|
let gitdir = proc.collectedStdout!.trim(); |
|
return new Worktree(gitdir); |
|
} |
|
} |
|
|
|
export interface IndexEntry { |
|
mode: FileMode, |
|
id: string, |
|
stage: number, |
|
path: string |
|
} |
|
|
|
export interface CheckoutIndexOpts { |
|
/** paths to checkout, or 'all' for all paths */ |
|
paths: string[] | 'all', |
|
/** whether to overwrite existing files if changed in index */ |
|
overwrite?: boolean, |
|
/** destination path */ |
|
destination: string |
|
} |
|
|
|
export class Worktree extends BaseRepository { |
|
/** read tree object to index */ |
|
async readTree(tree: string) { |
|
let proc = await this.git(['read-tree', tree]) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('read tree to index'); |
|
} |
|
|
|
/** write index to tree object */ |
|
async writeTree(): Promise<string> { |
|
let proc = await this.git(['write-tree']) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('write tree to object database'); |
|
return proc.collectedStdout!.trim(); |
|
} |
|
|
|
/** list entries in index */ |
|
async listIndex(): Promise<IndexEntry[]> { |
|
// mode '\x20' id '\x20' stage '\t' path '\x00' |
|
let proc = this.git([ |
|
'ls-files', |
|
'-z', // use null-separated paths instead of quoting |
|
'-c', // list cache |
|
'--full-name', // list all entries with full path |
|
'--stage', // use format described above |
|
]); |
|
proc.collectStderr(); |
|
|
|
let lineReader = new LineReader('\x00'); |
|
proc.stdout.pipe(lineReader); |
|
|
|
let entries: IndexEntry[] = []; |
|
while (true) { |
|
let line = await lineReader.nextLine(); |
|
if (line === null) break; |
|
|
|
let [status, path] = line.split('\t'); |
|
let [mode, id, stage] = status.split(' '); |
|
entries.push({ |
|
mode: +mode, |
|
id, |
|
stage: +stage, |
|
path |
|
}); |
|
} |
|
|
|
// wait for exit |
|
await proc.wait(); |
|
proc.assertSucceeded('read index file'); |
|
|
|
return entries; |
|
} |
|
|
|
/** update the index */ |
|
async updateIndex(entries: IndexEntry[]) { |
|
// same format as listIndex |
|
let proc = this.git(['update-index', '--index-info', '-z'], { stdin: true }) |
|
.collectStdout() |
|
.collectStderr(); |
|
|
|
for (let entry of entries) { |
|
let line = `${entry.mode} ${entry.id} ${entry.stage}\t${entry.path}\x00`; |
|
await streamWrite(proc.stdin!, line); |
|
} |
|
proc.stdin!.end(); |
|
|
|
await proc.wait(); |
|
proc.assertSucceeded('update index entries'); |
|
} |
|
|
|
/** checkout files from index to filesystem */ |
|
async checkoutIndex(opts: CheckoutIndexOpts) { |
|
if (typeof opts.overwrite === 'undefined') { |
|
opts.overwrite = true; |
|
} |
|
|
|
if (opts.paths instanceof Array) { |
|
let argv = ['checkout-index', '--stdin', '-z'] |
|
if (opts.overwrite) argv.push('-f'); |
|
let proc = this.git(argv, { |
|
stdin: true, |
|
worktree: opts.destination |
|
}) |
|
.collectStdout() |
|
.collectStderr(); |
|
|
|
for (let path of opts.paths) { |
|
await streamWrite(proc.stdin!, path); |
|
} |
|
proc.stdin!.end(); |
|
|
|
await proc.wait(); |
|
proc.assertSucceeded('checking out files'); |
|
} else { |
|
let argv = ['checkout-index', '-a']; |
|
if (opts.overwrite) argv.push('-f'); |
|
let proc = await this.git(argv, { worktree: opts.destination }) |
|
.collectStdout() |
|
.collectStderr() |
|
.wait(); |
|
proc.assertSucceeded('checking out files'); |
|
} |
|
} |
|
} |