import * as cp from 'child_process'; import { EventEmitter } from 'events'; import * as iconv from 'iconv-lite-umd'; import * as path from 'path'; import { difference } from 'set-operations'; import { CancellationToken, QuickDiffProvider, TextDocument, TextDocumentContentProvider, Uri, workspace } from 'vscode'; import { Resource, ResourceStatus } from './resource'; import { dispose, IDisposable, toDisposable } from './utils/disposableUtils'; import { onceEvent } from './utils/eventUtils'; import { createResourceUri } from './utils/fileUtils'; /** * Interface representing the information needed to create an instance * of the Pijul class. */ export interface IPijulOptions { path: string version: string } /** * The Pijul class holds information about the Pijul installation * in use by the extension which it uses for executing commands with * the Pijul CLI. All interactions with the CLI come through this class. */ export class Pijul { readonly path: string; readonly version: string; private readonly _onOutput = new EventEmitter(); /** * Accessor for the onOutput event */ get onOutput (): EventEmitter { return this._onOutput; } /** * Create a new instance of a Pijul object * @param options The path to a Pijul installation and its version. */ constructor (options: IPijulOptions) { this.path = options.path; this.version = options.version; } /** * Opens an existing Pijul repository at the given location * @param repository The path to the root of the repository */ open (repository: string): Repository { return new Repository(this, repository); } /** * Runs the command to initialize a new pijul repository * @param repository The location where the new repository will be created */ async init (repository: string): Promise<void> { await this.exec(repository, ['init']); } /** * Executes a new Pijul command in the given working directory. * @param cwd The current working directory in which the command will be run * @param args The arguments that will be passed to the command * @param options The options for the spawned child process */ async exec (cwd: string, args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> { // If on Windows, need to remove starting forward slash provided in the CWD by vscode if (process.platform === 'win32' && cwd) { const driveLetterRegExp = new RegExp('[A-Za-z]:'); if (cwd.charAt(0) === '/' && driveLetterRegExp.test(cwd.substr(1, 2))) { cwd = cwd.substr(1); } } options = Object.assign({ cwd }, options); return await this._exec(args, options); } /** * Executes a new pijul command with the given arguments and decodes the execution result from a buffer into a string * @param args The arguments that will be passed to the command * @param options The options for the spawned child process */ private async _exec (args: string[], options: SpawnOptions = {}): Promise<IExecutionResult<string>> { const child = this.spawn(args, options); if (options.onSpawn) { options.onSpawn(child); } if (options.input) { child.stdin!.end(options.input, 'utf8'); } const bufferResult = await exec(child, options.cancellationToken); if (options.log !== false && bufferResult.stderr.length > 0) { this.log(`${bufferResult.stderr}\n`); } let encoding = options.encoding ?? 'utf8'; encoding = iconv.encodingExists(encoding) ? encoding : 'utf8'; const result: IExecutionResult<string> = { exitCode: bufferResult.exitCode, stdout: iconv.decode(bufferResult.stdout, encoding), stderr: bufferResult.stderr }; if (bufferResult.exitCode) { return await Promise.reject<IExecutionResult<string>>(new Error('Error executing pijul command: "' + bufferResult.stderr + '"')); } return result; } /** * Spawn a Pijul process using the given arguments to utilize the Pijul CLI * @param args The arguments that will be passed to the child process * @param options The options that will be used to spawn the new process */ private spawn (args: string[], options: SpawnOptions = {}): cp.ChildProcess { if (!this.path) { throw new Error('Pijul could not be found in the system.'); } if (!options.stdio && !options.input) { options.stdio = ['ignore', null, null]; // Unless provided, ignore stdin and leave default streams for stdout and stderr } if (!options.log) { this.log(`> pijul ${args.join(' ')}\n`); } return cp.spawn(this.path, args, options); } /** * Emit a log message as an event * @param message The message to be emitted */ private log (message: string): void { this._onOutput.emit('log', message); } } /** * The repository class represents a single Pijul repository. * It holds a reference to the Pijul installation which it uses * for interacting with the Pijul CLI. The Repository class exposes * functions for all of the Pijul CLI commands which require an * existing repository to run. */ export class Repository implements TextDocumentContentProvider, QuickDiffProvider { /** * A dictionary for holding a cache of change hashes to avoid recalculating them each time. * Persists between refreshes. */ private readonly changeCache: Record<string, PijulChange> = {}; // TODO: Create a number of sets which hold the state of the channels in memory. // Instead of each log, remote, or channel listing operation interacting with the CLI, // the state of the repository will be loaded each refresh and then accessed through the cache. /** * Create a new repository instance * @param _pijul The Pijul instance use to execute commands with the CLI * @param repositoryRoot The root directory of the repository */ constructor ( private readonly _pijul: Pijul, private readonly repositoryRoot: string ) { } /** * Accessor for the underlying Pijul instance */ get pijul (): Pijul { return this._pijul; } /** * Accessor for the path to the root directory of the repository */ get root (): string { return this.repositoryRoot; } /** * Implementation of QuickDiffProvider, sets the scheme of a given URI to 'pijul' so that VS Code can access the last recorded copy of a file * @param uri The URI of the real file which VS Code is trying to quick diff * @param _token An unused cancellation token */ async provideOriginalResource (uri: Uri, _token: CancellationToken): Promise<Uri> { return uri.with({ scheme: 'pijul' }); } /** * Implementation of the TextDocumentContentProvider. Depending on the scheme of the given URI, either returns * a the last recorded version of a file using `pijul reset` or the stdout of the `pijul change` command. * @param uri The URI, with scheme 'pijul' or 'pijul-change', which will be used to determine which text document should be returned. * @param token A cancellation token for stopping the asynchronous child process execution */ async provideTextDocumentContent (uri: Uri, token: CancellationToken): Promise<string> { if (uri.scheme === 'pijul-change') { return await this.getChangeToml(uri.path, token); } else { // return stdout of a pijul reset dry run, which is the last recorded version of the file return (await this._pijul.exec(this.repositoryRoot, ['reset', uri.fsPath, '--dry-run'], { cancellationToken: token })).stdout; } } /** * Get the files in the Repository which Pijul is aware of, the results of `pijul ls` */ async getTrackedFiles (): Promise<Uri[]> { return (await this.pijul.exec(this.repositoryRoot, ['ls'])).stdout.split('\n').map(f => createResourceUri(f)); } /** * Get all the files in the repository, including those which haven't been added to Pijul */ async getNotIgnoredFiles (): Promise<Uri[]> { // TODO: More robust handling of ignore files let ignoreFile: TextDocument | undefined; try { ignoreFile = await workspace.openTextDocument(path.join(this.repositoryRoot, '.ignore')); } catch (err) { try { ignoreFile = await workspace.openTextDocument(path.join(this.repositoryRoot, '.pijulignore')); } catch (err) { // No ignore file exists, continue } } const ignoreGlobs: string[] = []; if (ignoreFile) { for (let i = 0; i < ignoreFile.lineCount; i++) { const line = ignoreFile.lineAt(i).text; if (!line.startsWith('#')) { ignoreGlobs.push(line); } } } // Make sure files in the .pijul folder aren't being tracked ignoreGlobs.push('**/.pijul/**'); const fullIgnoreGlob = '{' + ignoreGlobs.join(',') + '}'; return await workspace.findFiles('**', fullIgnoreGlob); } /** * Calculate the difference between the tracked files and the full set of files in the repository * to determine which of the files are untracked. * * TODO: Maybe consider adding this functionality to Pijul? */ async getUntrackedFiles (): Promise<Resource[]> { const output = (await this.pijul.exec(this.repositoryRoot, ['diff', '--json', '--untracked'])).stdout; const files: string[] = JSON.parse(output); const resources = files.map(file => new Resource(createResourceUri(file), ResourceStatus.FileAdd)); return resources; } /** * Get the list of file URIs which have unrecorded changes using `pijul diff` * * TODO: Fix handling of file deletions */ async getChangedFiles (): Promise<Resource[]> { const changedFiles: Resource[] = []; const output = (await this.pijul.exec(this.repositoryRoot, ['diff', '--json'])).stdout; if (output !== '') { const pijulDiff = JSON.parse(output); for (const file in pijulDiff) { const fileChanges = pijulDiff[file] as IPijulFileDiff[]; changedFiles.push(new Resource(createResourceUri(file), fileChanges[0].operation as ResourceStatus)); } } return changedFiles; } /** * Get the TOML document describing a specific change * @param hash The hash of the change * @param cancellationToken A token for cancelling the CLI interaction */ async getChangeToml (hash: string, cancellationToken?: CancellationToken): Promise<string> { return (await this._pijul.exec(this.repositoryRoot, ['change', hash], { cancellationToken })).stdout; } /** * Get the Pijul changes which the given change is dependent on * @param change The change to get the dependencies of \ */ async getChangeDependencies (change: PijulChange): Promise<PijulChange[]> { const changeToml = await this.getChangeToml(change.hash); const dependencyPattern = /[0-9A-Z]{53}/g; const dependencies: Set<PijulChange> = new Set(); let match = dependencyPattern.exec(changeToml); while (match) { dependencies.add(this.changeCache[match[0]]); match = dependencyPattern.exec(changeToml); } return [...dependencies]; } /** * Gets a list of the files which were altered in a change * @param change The change to retrieve the altered files for */ async getChangeFiles (change: PijulChange): Promise<PijulFileChange[]> { const changeToml = await this.getChangeToml(change.hash); // Big complicated regex needs to match both the 'Edit in src/repository.ts' format // and the 'File addition: "yarn.lock" format. const changePattern = /\d\.\s(?:(?:([A-Za-z]+)\sin\s)(.*):|(.*):\s"(\S+)")/g; const fileOperations: Record<string, Set<string>> = {}; let match = changePattern.exec(changeToml); while (match) { // Since their are two formats the operation and path can be in, there are four // capture groups for two properties. These two statements take whichever is defined. const operation = match[1] ?? match[3]; const path = match[2] ?? match[4]; if (fileOperations[path]) { fileOperations[path].add(operation); } else { fileOperations[path] = new Set([operation]); } match = changePattern.exec(changeToml); } // TODO: cache results const files: PijulFileChange[] = []; for (const path in fileOperations) { files.push(new PijulFileChange(createResourceUri(path), [...fileOperations[path]])); } return files; } /** * Record all diffs in the given files as a new change * @param files The files that will be included in the new change * @param message The message for the new change */ async recordChanges (files: Uri[], message: string): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['record', ...files.map(f => f.fsPath), '-a', '-m', message]); } /** * Unrecord a change * @param change The change that will be unrecorded * @param options The options for unrecording a change, indicating if the changes should be reset */ async unrecordChange (change: PijulChange, options: IUnrecordOptions = {}): Promise<void> { // TODO: Consider working around issues with the reset flag const optionArray: string[] = options.reset ? ['--reset'] : []; await this._pijul.exec(this.repositoryRoot, ['unrecord', change.hash, ...optionArray]); } /** * Apply a change to a given channel or the current channel if no channel name is provided * @param change The change to apply to a channel * @param channelName The name of the channel the change will be applied to */ async applyChange (change: PijulChange, channelName?: string): Promise<void> { const additionalArgs: string[] = []; if (channelName) { additionalArgs.push('--channel', channelName); } await this._pijul.exec(this.repositoryRoot, ['apply', change.hash, ...additionalArgs]); } /** * Record all diffs from the pristine as a new change * @param message The message for the amended change */ async recordAllChanges (message: string): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['record', '-a', '-m', message]); } /** * Record all diffs from the pristine, amending the most recent change instead of creating a new one * @param message An updated message for the change */ async amendAllChanges (message?: string): Promise<void> { if (message) { await this._pijul.exec(this.repositoryRoot, ['record', '-a', '--amend', '-m', message]); } else { await this._pijul.exec(this.repositoryRoot, ['record', '-a', '--amend']); } } /** * Add a specific file to the repository for tracking * @param path The URI of the file to add */ async addFile (path: Uri): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['add', path.fsPath]); } /** * Reset all files in the repository by undoing unrecorded changes */ async resetAll (): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['reset']); } /** * Reset specific files in the repository by undoing unrecorded */ async reset (files: Uri[]): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['reset', ...files.map(f => f.fsPath)]); } /** * Rename a channel in the repository. * @param channel The channel to rename * @param newName The new name that will be given to the channel */ async renameChannel (channel: PijulChannel, newName: string): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['channel', 'rename', channel.name, newName]); } /** * Switch to the given channel from the current channel * @param targetChannel The channel that will be switched to */ async switchChannel (channel: PijulChannel): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['channel', 'switch', channel.name]); } /** * Delete a channel * @param channel The channel that will be deleted */ async deleteChannel (channel: PijulChannel): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['channel', 'delete', channel.name]); } /** * Fork a new channel from the current one * @param channelName The name of the new channel */ async forkChannel (channelName: string): Promise<void> { await this._pijul.exec(this.repositoryRoot, ['fork', channelName]); } /** * Use the `pijul log` command and parse the results * to generate a log of pijul changes. * @param channel optional name of a channel to get the log for */ async getLog (channel?: string): Promise<PijulChange[]> { const changes: PijulChange[] = []; const additionalArgs: string[] = []; if (channel) { additionalArgs.push('--channel', channel); } // TODO: Test how this scales to repositories with thousands or hundreds of thousands of changes const result = await this._pijul.exec(this.repositoryRoot, ['log', ...additionalArgs]); const parsePattern = /Change\s([0-9A-Z]{53})\nAuthor:\s(.*)\nDate:\s(.*)\n\n\s+(.*)/gm; do { const match = parsePattern.exec(result.stdout); if (match === null) break; // Skip the first item, which is the entire match and select the individual capture groups const [, hash, author, date, message] = match; const cachedChange = this.changeCache[hash]; if (cachedChange) { changes.push(cachedChange); } else { const change = new PijulChange(hash, message.trim(), parsePijulChangeAuthor(author), new Date(date)); this.changeCache[change.hash] = change; changes.push(change); } } while (true); return changes; } /** * Use the `pijul channel` command and parse the results * to generate the list of channels in this repository. */ async getChannels (): Promise<PijulChannel[]> { // TODO: Test how this scales to repositories with thousands or hundreds of thousands of changes const result = await this._pijul.exec(this.repositoryRoot, ['channel']); const lines = result.stdout.split(/\r?\n/); const channels = lines.filter(line => line.length > 0).map((line) => { return new PijulChannel(line.substring(2), line.startsWith('*')); }); return channels; } /** * Compare the changes in another channel against the main channel * @param otherChannelName The name of the other channel to compare against */ async compareChannelChanges (otherChannelName: string): Promise<PijulChange[]> { // TODO: Caching and other easy optimizations return difference(await this.getLog(otherChannelName), await this.getLog(), true); } /** * Get the remotes of this repository with `pijul remote` */ async getRemotes (): Promise<PijulRemote[]> { const remotes = (await this._pijul.exec(this.repositoryRoot, ['remote'])).stdout; const result = []; for (const l of remotes.split(/\r?\n/)) { if (l !== '') { const i = l.search(':'); result.push(new PijulRemote(l.slice(i + 1).trim())); } } return result; } } /** * Handles the execution of a child process and the capture of its results, cancelling it if necessary * @param child The child process being executed * @param cancellationToken A cancellation token that can be used to cancel the child process */ async function exec (child: cp.ChildProcess, cancellationToken?: CancellationToken): Promise<IExecutionResult<Buffer>> { if (!child.stdout || !child.stderr) { throw new Error('Failed to get stdout or stderr from git process.'); } if (cancellationToken?.isCancellationRequested) { throw new Error('Cancelled'); } const disposables: IDisposable[] = []; // Create handles for stdout and stderr events const once = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void): void => { ee.once(name, fn); disposables.push(toDisposable(() => ee.removeListener(name, fn))); }; const on = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void): void => { ee.on(name, fn); disposables.push(toDisposable(() => ee.removeListener(name, fn))); }; // Create a promise representing all the different results of the child process let result = Promise.all<any>([ new Promise<number>((resolve, reject) => { once(child, 'error', (err) => (reject(err))); once(child, 'exit', resolve); }), new Promise<Buffer>(resolve => { const buffers: Buffer[] = []; on(child.stdout!, 'data', (b: Buffer) => buffers.push(b)); once(child.stdout!, 'close', () => resolve(Buffer.concat(buffers))); }), new Promise<string>(resolve => { const buffers: Buffer[] = []; on(child.stderr!, 'data', (b: Buffer) => buffers.push(b)); once(child.stderr!, 'close', () => resolve(Buffer.concat(buffers).toString('utf8'))); }) ]) as Promise<[number, Buffer, string]>; if (cancellationToken) { // eslint-disable-next-line promise/param-names const cancellationPromise = new Promise<[number, Buffer, string]>((_resolve, reject) => { onceEvent(cancellationToken.onCancellationRequested)(() => { try { child.kill(); } catch (err) { // Do nothing } reject(new Error('Cancelled')); }); }); result = Promise.race([result, cancellationPromise]); } try { const [exitCode, stdout, stderr] = await result; return { exitCode, stdout, stderr }; } finally { dispose(disposables); } } /** * Interface for representing the results of the execution of a child process */ export interface IExecutionResult<T extends string | Buffer> { exitCode: number stdout: T stderr: string } /** * Interface for representing the options for spawning a child process */ export interface SpawnOptions extends cp.SpawnOptions { input?: string encoding?: string log?: boolean cancellationToken?: CancellationToken onSpawn?: (childProcess: cp.ChildProcess) => void } export interface IPijulFileDiff { operation: string line: number } /** * Class representing a single change in the repository. */ export class PijulChange { /** * Create a new instance of a Pijul change object. * @param hash The change hash * @param message The message for the change * @param author The author of the change * @param date The date the change was made */ constructor ( public readonly hash: string, public readonly message: string, public readonly author: PijulChangeAuthor, public readonly date: Date ) { } } /** * Class representing a change that was made to a file */ export class PijulFileChange { /** * Create a new instance of a pijul file change * @param path The path of the file that was changed * @param operations The operations that were performed on the file (Edit, Replacement, etc.) */ constructor ( public readonly path: Uri, public readonly operations: string[] ) { } } /** * Creates a new PijulChangeAuthor. This is mostly redundant now and can be removed now that * the author field in the pijul change log no longer has the Rust debug format. * @param authorString The name or key of the author */ export function parsePijulChangeAuthor (authorString: string): PijulChangeAuthor { // Now only dis return new PijulChangeAuthor(authorString); } /** * Class representing the Author of a change * TODO: Handle multiple authors */ export class PijulChangeAuthor { /** * Creates a new PijulChangeAuthor instance */ constructor ( public readonly name: string, public readonly fullName?: string, public readonly email?: string ) { } } /** * Class representing a channel in the repository. */ export class PijulChannel { /** * Create a new instance of a Pijul change object. * @param name The change hash * @param isCurrent Indicates if the channel is the default channel */ constructor ( public readonly name: string, public readonly isCurrent: boolean ) { } } /** * Class representing a remote repository */ export class PijulRemote { /** * Create a new instance of a PijulRemote object * @param url The url of the remote */ constructor ( public readonly url: string ) { } } /** * Options for unrecording a change */ export interface IUnrecordOptions { reset?: boolean }