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 { IDisposable, dispose, 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
const diff = difference(await this.getLog(otherChannelName), await this.getLog(), true);
return [...diff];
}
/**
* 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
}