Skip to content

Commit

Permalink
feat: Progress Handler
Browse files Browse the repository at this point in the history
The `SimpleGitOptions` can now be used to supply a `progress` handler, called whenever progress data is received from one of the tasks. Setting the `progress` option will automatically add the `--progress` command to any `checkout` / `clone` or `pull` task when not already supplied in the `TaskOptions`.

For any task that ran with `--progress` as a command (ie: even `git.raw` so long as the `--progress` command was present), monitors the `stdErr` for progress data.
  • Loading branch information
steveukx committed Feb 16, 2021
1 parent 91d6bfd commit 5508bd4
Show file tree
Hide file tree
Showing 11 changed files with 283 additions and 20 deletions.
6 changes: 5 additions & 1 deletion src/git-factory.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const Git = require('./git');

const {GitConstructError} = require('./lib/api');
const {PluginStore} = require("./lib/plugins/plugin-store");
const {commandConfigPrefixingPlugin} = require('./lib/plugins/command-config-prefixing-plugin');
const {progressMonitorPlugin} = require('./lib/plugins/progress-monitor-plugin');
const {createInstanceConfig, folderExists} = require('./lib/utils');

const api = Object.create(null);
Expand Down Expand Up @@ -45,9 +47,11 @@ module.exports.gitInstanceFactory = function gitInstanceFactory (baseDir, option
throw new GitConstructError(config, `Cannot use simple-git on a directory that does not exist`);
}

if (config.config) {
if (Array.isArray(config.config)) {
plugins.add(commandConfigPrefixingPlugin(config.config));
}

config.progress && plugins.add(progressMonitorPlugin(config.progress));

return new Git(config, plugins);
};
9 changes: 6 additions & 3 deletions src/lib/plugins/plugin-store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { SimpleGitPlugin, SimpleGitPluginType, SimpleGitPluginTypes } from './simple-git-plugin';
import { asArray } from '../utils';

export class PluginStore {

private plugins: Set<SimpleGitPlugin<SimpleGitPluginType>> = new Set();

public add<T extends SimpleGitPluginType>(plugin: SimpleGitPlugin<T>) {
this.plugins.add(plugin);
public add<T extends SimpleGitPluginType>(plugin: SimpleGitPlugin<T> | SimpleGitPlugin<T>[]) {
const plugins = asArray(plugin);
plugins.forEach(plugin => this.plugins.add(plugin));

return () => {
this.plugins.delete(plugin);
plugins.forEach(plugin => this.plugins.delete(plugin));
};
}

Expand Down
43 changes: 43 additions & 0 deletions src/lib/plugins/progress-monitor-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { SimpleGitOptions } from '../types';
import { asNumber, including } from '../utils';

import { SimpleGitPlugin } from './simple-git-plugin';

export function progressMonitorPlugin(progress: Exclude<SimpleGitOptions['progress'], void>) {
const progressCommand = '--progress';
const progressMethods = ['checkout', 'clone', 'pull'];

const onProgress: SimpleGitPlugin<'spawn.after'> = {
type: 'spawn.after',
action(_data, context) {
if (!context.commands.includes(progressCommand)) {
return;
}

context.spawned.stderr?.on('data', (chunk: Buffer) => {
const message = /Receiving objects:\s*(\d+)% \((\d+)\/(\d+)\)/.exec(chunk.toString('utf8'));
if (message) {
progress({
method: context.method,
progress: asNumber(message[1]),
received: asNumber(message[2]),
total: asNumber(message[3]),
});
}
});
}
};

const onArgs: SimpleGitPlugin<'spawn.args'> = {
type: 'spawn.args',
action(args, context) {
if (!progressMethods.includes(context.method)) {
return args;
}

return including(args, progressCommand);
}
}

return [onArgs, onProgress];
}
15 changes: 14 additions & 1 deletion src/lib/plugins/simple-git-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { ChildProcess } from 'child_process';

type SimpleGitTaskPluginContext = {
readonly method: string;
readonly commands: string[];
}

export interface SimpleGitPluginTypes {
'spawn.args': {
data: string[];
context: {};
context: SimpleGitTaskPluginContext & {};
};
'spawn.after': {
data: void;
context: SimpleGitTaskPluginContext & {
spawned: ChildProcess,
};
}
}

export type SimpleGitPluginType = keyof SimpleGitPluginTypes;
Expand Down
25 changes: 19 additions & 6 deletions src/lib/runners/git-executor-chain.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { spawn, SpawnOptions } from 'child_process';
import { GitError } from '../api';
import { OutputLogger } from '../git-logger';
import { PluginStore } from '../plugins';
import { EmptyTask, isBufferTask, isEmptyTask, } from '../tasks/task';
import { Scheduler } from './scheduler';
import { TasksPendingQueue } from './tasks-pending-queue';
import {
GitExecutorResult,
Maybe,
Expand All @@ -13,8 +12,9 @@ import {
SimpleGitTask,
TaskResponseFormat
} from '../types';
import { callTaskParser, GitOutputStreams, objectToString } from '../utils';
import { PluginStore } from '../plugins/plugin-store';
import { callTaskParser, first, GitOutputStreams, objectToString } from '../utils';
import { Scheduler } from './scheduler';
import { TasksPendingQueue } from './tasks-pending-queue';

export class GitExecutorChain implements SimpleGitExecutor {

Expand Down Expand Up @@ -82,9 +82,10 @@ export class GitExecutorChain implements SimpleGitExecutor {
}

private async attemptRemoteTask<R>(task: RunnableTask<R>, logger: OutputLogger) {
const args = this._plugins.exec('spawn.args', task.commands, {});
const args = this._plugins.exec('spawn.args', [...task.commands], pluginContext(task, task.commands));

const raw = await this.gitResponse(
task,
this.binary, args, this.outputHandler, logger.step('SPAWN'),
);
const outputStreams = await this.handleTaskData(task, raw, logger.step('HANDLE'));
Expand Down Expand Up @@ -148,7 +149,7 @@ export class GitExecutorChain implements SimpleGitExecutor {
});
}

private async gitResponse(command: string, args: string[], outputHandler: Maybe<outputHandler>, logger: OutputLogger): Promise<GitExecutorResult> {
private async gitResponse<R>(task: SimpleGitTask<R>, command: string, args: string[], outputHandler: Maybe<outputHandler>, logger: OutputLogger): Promise<GitExecutorResult> {
const outputLogger = logger.sibling('output');
const spawnOptions: SpawnOptions = {
cwd: this.cwd,
Expand Down Expand Up @@ -202,11 +203,23 @@ export class GitExecutorChain implements SimpleGitExecutor {
outputHandler(command, spawned.stdout!, spawned.stderr!, [...args]);
}

this._plugins.exec('spawn.after', undefined, {
...pluginContext(task, args),
spawned,
});

});
}

}

function pluginContext<R>(task: SimpleGitTask<R>, commands: string[]) {
return {
method: first(task.commands) || '',
commands,
}
}

function onErrorReceived(target: Buffer[], logger: OutputLogger) {
return (err: Error) => {
logger(`[ERROR] child process exception %o`, err);
Expand Down
11 changes: 8 additions & 3 deletions src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ export type outputHandler = (
export type GitExecutorEnv = NodeJS.ProcessEnv | undefined;





/**
* Public interface of the Executor
*/
Expand All @@ -50,6 +47,7 @@ export interface SimpleGitExecutor {
cwd: string;

chain(): SimpleGitExecutor;

push<R>(task: SimpleGitTask<R>): Promise<R>;
}

Expand All @@ -71,6 +69,13 @@ export interface SimpleGitOptions {
binary: string;
maxConcurrentProcesses: number;
config: string[];

progress?(data: {
method: string;
progress: number;
received: number;
total: number;
}): void;
}

export type Maybe<T> = T | undefined;
Expand Down
13 changes: 12 additions & 1 deletion src/lib/utils/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function folderExists(path: string): boolean {
}

/**
* Adds `item` into the `target` `Array` or `Set` when it is not already present.
* Adds `item` into the `target` `Array` or `Set` when it is not already present and returns the `item`.
*/
export function append<T>(target: T[] | Set<T>, item: T): typeof item {
if (Array.isArray(target)) {
Expand All @@ -88,6 +88,17 @@ export function append<T>(target: T[] | Set<T>, item: T): typeof item {
return item;
}

/**
* Adds `item` into the `target` `Array` when it is not already present and returns the `target`.
*/
export function including<T>(target: T[], item: T): typeof target {
if (Array.isArray(target) && !target.includes(item)) {
target.push(item);
}

return target;
}

export function remove<T>(target: Set<T> | T[], item: T): T {
if (Array.isArray(target)) {
const index = target.indexOf(item);
Expand Down
40 changes: 40 additions & 0 deletions test/integration/progress-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createTestContext, newSimpleGit, SimpleGitTestContext } from '../__fixtures__';
import { SimpleGitOptions } from '../../src/lib/types';

describe('progress-monitor', () => {

const upstream = 'https://github.com/steveukx/git-js.git';

let context: SimpleGitTestContext;

beforeEach(async () => context = await createTestContext());

it('emits progress events', async () => {
const progress = jest.fn();
const opt: Partial<SimpleGitOptions> = {
baseDir: context.root,
progress,
};

await newSimpleGit(opt).clone(upstream);

const count = progress.mock.calls.length;
const last = progress.mock.calls[count - 1];

expect(count).toBeGreaterThan(0);
expect(last[0]).toEqual({
method: 'clone',
progress: 100,
received: last[0].total,
total: expect.any(Number),
});

progress.mock.calls.reduce((previous, [{progress, method}]) => {
expect(method).toBe('clone');
expect(progress).toBeGreaterThanOrEqual(previous);
return progress;
}, 0);

});

})
15 changes: 15 additions & 0 deletions test/unit/__fixtures__/child-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import { wait } from '../../__fixtures__';
const EXIT_CODE_SUCCESS = 0;
const EXIT_CODE_ERROR = 1;

export async function writeToStdErr (data = '') {
await wait();
const proc = mockChildProcessModule.$mostRecent();

if (!proc) {
throw new Error(`writeToStdErr unable to find matching child process`);
}

if (proc.$emitted('exit')) {
throw new Error('writeToStdErr: attempting to write to an already closed process');
}

proc.stderr.$emit('data', Buffer.from(data));
}

export async function closeWithError (stack = 'CLOSING WITH ERROR', code = EXIT_CODE_ERROR) {
await wait();
const match = mockChildProcessModule.$mostRecent();
Expand Down
Loading

0 comments on commit 5508bd4

Please sign in to comment.