Repository: steveukx/git-js
Pull request #591 (2021-03-01 14:06:33)

Feature/timeout plugin


Patch:
diff --git a/docs/PLUGIN-PROGRESS-EVENTS.md b/docs/PLUGIN-PROGRESS-EVENTS.md
new file mode 100644
index 0000000..bdd8f56
--- /dev/null
+++ b/docs/PLUGIN-PROGRESS-EVENTS.md
@@ -0,0 +1,29 @@
+## Progress Events
+
+To receive progress updates, pass a `progress` configuration option to the `simpleGit` instance:
+
+```typescript
+import simpleGit, { SimpleGit, SimpleGitProgressEvent } from 'simple-git';
+
+const progress = ({method, stage, progress}: SimpleGitProgressEvent) => {
+   console.log(`git.${method} ${stage} stage ${progress}% complete`);
+}
+const git: SimpleGit = simpleGit({baseDir: '/some/path', progress});
+
+// pull automatically triggers progress events when the progress plugin is configured
+await git.pull();
+
+// supply the `--progress` option to any other command that supports it to receive
+// progress events into your handler
+await git.raw('pull', '--progress');
+```
+
+The `checkout`, `clone`, 'fetch, `pull`, `push` methods will automatically enable progress events
+when a progress handler has been set. For any other method that _can_ support progress events,
+set `--progress` in the task's `TaskOptions` for example to receive progress events when running
+submodule tasks:
+
+```typescript
+await git.submoduleUpdate('submodule-name', { '--progress': null });
+await git.submoduleUpdate('submodule-name', ['--progress']);
+```
diff --git a/docs/PLUGIN-TIMEOUT.md b/docs/PLUGIN-TIMEOUT.md
new file mode 100644
index 0000000..df364da
--- /dev/null
+++ b/docs/PLUGIN-TIMEOUT.md
@@ -0,0 +1,27 @@
+## Task Timeouts
+
+To handle the case where the underlying `git` processes appear to hang, configure the
+`timeout` plugin with a number of milliseconds to wait after last received content on either
+`stdOut` or `stdErr` streams before sending a `SIGINT` kill message.
+
+```typescript
+import simpleGit, { GitPluginError, SimpleGit, SimpleGitProgressEvent } from 'simple-git';
+
+const git: SimpleGit = simpleGit({
+   baseDir: '/some/path', 
+   timeout: {
+       block: 2000,
+   },
+});
+
+// if the `git pull` process fails to send content to the `stdOut` or `stdErr`
+// streams for 2 seconds, simple-git will kill it with a SIGINT
+try {
+    await git.pull();
+}
+catch (err) {
+    if (err instanceof GitPluginError && err.plugin === 'timeout') {
+        // task failed because of a timeout
+    }
+}
+```
diff --git a/promise.js b/promise.js
index a8aaf34..2bfa5ca 100644
--- a/promise.js
+++ b/promise.js
@@ -1,5 +1,5 @@
 
-const {esModuleFactory, gitExportFactory} = require('./src/git-factory');
+const {esModuleFactory, gitExportFactory} = require('./src/lib/git-factory');
 const {gitP} = require('./src/lib/runners/promise-wrapped');
 
 module.exports = esModuleFactory(
diff --git a/readme.md b/readme.md
index a3009ef..ed9d301 100644
--- a/readme.md
+++ b/readme.md
@@ -77,86 +77,33 @@ const git: SimpleGit = simpleGit('/some/path', { config: ['http.proxy=someproxy'
 await git.pull();
 ```
 
-## Progress Events
+## Configuring Plugins
 
-To receive progress updates, pass a `progress` configuration option to the `simpleGit` instance:
+- [Progress Events](./docs/PLUGIN-PROGRESS-EVENTS.md)
+  Receive progress events as `git` works through long-running processes.
 
-```typescript
-import simpleGit, { SimpleGit, SimpleGitProgressEvent } from 'simple-git';
-
-const progress = ({method, stage, progress}: SimpleGitProgressEvent) => {
-   console.log(`git.${method} ${stage} stage ${progress}% complete`);
-}
-const git: SimpleGit = simpleGit({baseDir: '/some/path', progress});
-
-// pull automatically triggers progress events when the progress plugin is configured
-await git.pull();
-
-// supply the `--progress` option to any other command that supports it to receive
-// progress events into your handler
-await git.raw('pull', '--progress');
-```
-
-The `checkout`, `clone`, 'fetch, `pull`, `push` methods will automatically enable progress events
-when a progress handler has been set. For any other method that _can_ support progress events,
-set `--progress` in the task's `TaskOptions` for example to receive progress events when running
-submodule tasks:
-
-```typescript
-await git.submoduleUpdate('submodule-name', { '--progress': null });
-await git.submoduleUpdate('submodule-name', ['--progress']);
-```
-
-## Using task callbacks
-
-Each of the methods in the API listing below can be called in a chain and will be called in series after each other.
-The result of each task is sent to a trailing callback argument:
-
-```javascript
-const simpleGit = require('simple-git');
-const git = simpleGit();
-git.init(onInit).addRemote('origin', 'git@github.com:steveukx/git-js.git', onRemoteAdd);
-
-function onInit (err, initResult) { }
-function onRemoteAdd (err, addRemoteResult) { }
-``` 
-
-If any of the steps in the chain result in an error, all pending steps will be cancelled, see the
-[parallel tasks]((#concurrent--parallel-requests)) section for more information on how to run tasks in parallel rather than in series .
+- [Timeout](./docs/PLUGIN-TIMEOUT.md)
+  Automatically kill the wrapped `git` process after a rolling timeout.
 
 ## Using task promises
 
-Based on the same API as the callback API detailed below, but instead of returning the `simpleGit` object for
-chaining, each task returns a promise to be fulfilled when that task is completed.  
+Each task in the API returns the `simpleGit` instance for chaining together multiple tasks, and each
+step in the chain is also a `Promise` that can be `await` ed in an `async` function or returned in a
+`Promise` chain.
 
 ```javascript
 const simpleGit = require('simple-git');
 const git = simpleGit();
 
-// using promises on each task
-git.init()
-  .then(function onInit (initResult) { })
-  .then(() => git.addRemote('origin', 'git@github.com:steveukx/git-js.git'))
-  .then(function onRemoteAdd (addRemoteResult) { })
-  .catch(err => console.error(err));
+// chain together tasks to await final result
+await git.init().addRemote('origin', '...remote.git');
 
-// using a promise at the end of the chain to check for failures in any task
-git.init().addRemote('origin', 'git@github.com:steveukx/git-js.git')
-  .catch(err => console.error(err));
+// or await each step individually
+await git.init();
+await git.addRemote('origin', '...remote.git')
 ``` 
 
-## Using task promises as async/await
-
-Whether in TypeScript or JavaScript in node.js version 8 or above the promise API will work automatically
-with await:
-
-```typescript
-import simpleGit, { SimpleGit } from 'simple-git';
-
-const git: SimpleGit = simpleGit();
-const initResult = await git.init();
-const addRemoteResult = await git.addRemote('origin', 'git@github.com:steveukx/git-js.git');
-``` 
+## Catching errors in async code
 
 To catch errors in async code, either wrap the whole chain in a try/catch:
 
@@ -183,6 +130,22 @@ catch (e) { /* handle all errors here */ }
 function ignoreError () {}
 ```
 
+## Using task callbacks
+
+In addition to returning promise method can be called with a trailing callback argument to handle the result of the task 
+
+```javascript
+const simpleGit = require('simple-git');
+const git = simpleGit();
+git.init(onInit).addRemote('origin', 'git@github.com:steveukx/git-js.git', onRemoteAdd);
+
+function onInit (err, initResult) { }
+function onRemoteAdd (err, addRemoteResult) { }
+``` 
+
+If any of the steps in the chain result in an error, all pending steps will be cancelled, see the
+[parallel tasks]((#concurrent--parallel-requests)) section for more information on how to run tasks in parallel rather than in series .
+
 ## Task Responses
 
 Whether using a trailing callback or a Promise, tasks either return the raw `string` or `Buffer` response from the
@@ -374,7 +337,7 @@ will all be merged as trailing arguments in the command string, or as a simple a
 When the value of the property in the options object is a `string`, that name value
 pair will be included in the command string as `name=value`. For example:
 
-```js
+```javascript
 // results in 'git pull origin master --no-rebase'
 git().pull('origin', 'master', {'--no-rebase': null})
 
diff --git a/src/git-factory.js b/src/git-factory.js
deleted file mode 100644
index e5577be..0000000
--- a/src/git-factory.js
+++ /dev/null
@@ -1,57 +0,0 @@
-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);
-for (let imported = require('./lib/api'), keys = Object.keys(imported), i = 0; i < keys.length; i++) {
-   const name = keys[i];
-   if (/^[A-Z]/.test(name)) {
-      api[name] = imported[name];
-   }
-}
-
-/**
- * Adds the necessary properties to the supplied object to enable it for use as
- * the default export of a module.
- *
- * Eg: `module.exports = esModuleFactory({ something () {} })`
- */
-module.exports.esModuleFactory = function esModuleFactory (defaultExport) {
-   return Object.defineProperties(defaultExport, {
-      __esModule: {value: true},
-      default: {value: defaultExport},
-   });
-}
-
-module.exports.gitExportFactory = function gitExportFactory (factory, extra) {
-   return Object.assign(function () {
-         return factory.apply(null, arguments);
-      },
-      api,
-      extra || {},
-   );
-};
-
-module.exports.gitInstanceFactory = function gitInstanceFactory (baseDir, options) {
-   const plugins = new PluginStore();
-   const config = createInstanceConfig(
-      baseDir && (typeof baseDir === 'string' ? {baseDir} : baseDir),
-      options
-   );
-
-   if (!folderExists(config.baseDir)) {
-      throw new GitConstructError(config, `Cannot use simple-git on a directory that does not exist`);
-   }
-
-   if (Array.isArray(config.config)) {
-      plugins.add(commandConfigPrefixingPlugin(config.config));
-   }
-
-   config.progress && plugins.add(progressMonitorPlugin(config.progress));
-
-   return new Git(config, plugins);
-};
diff --git a/src/index.js b/src/index.js
index 2587f35..cb1ffbd 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,6 @@
 
 const {gitP} = require('./lib/runners/promise-wrapped');
-const {esModuleFactory, gitInstanceFactory, gitExportFactory} = require('./git-factory');
+const {esModuleFactory, gitInstanceFactory, gitExportFactory} = require('./lib/git-factory');
 
 module.exports = esModuleFactory(
    gitExportFactory(gitInstanceFactory, {gitP})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 5e96b74..1b4ee49 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,7 +1,21 @@
-export { CleanOptions } from './tasks/clean';
-export { CheckRepoActions } from './tasks/check-is-repo';
-export { ResetMode } from './tasks/reset';
-export { GitConstructError } from './errors/git-construct-error';
-export { GitError } from './errors/git-error';
-export { GitResponseError } from './errors/git-response-error';
-export { TaskConfigurationError } from './errors/task-configuration-error';
+import { GitConstructError } from './errors/git-construct-error';
+import { GitError } from './errors/git-error';
+import { GitPluginError } from './errors/git-plugin-error';
+import { GitResponseError } from './errors/git-response-error';
+import { TaskConfigurationError } from './errors/task-configuration-error';
+import { CheckRepoActions } from './tasks/check-is-repo';
+import { CleanOptions } from './tasks/clean';
+import { ResetMode } from './tasks/reset';
+
+const api = {
+   CheckRepoActions,
+   CleanOptions,
+   GitConstructError,
+   GitError,
+   GitPluginError,
+   GitResponseError,
+   ResetMode,
+   TaskConfigurationError,
+}
+
+export default api;
diff --git a/src/lib/errors/git-plugin-error.ts b/src/lib/errors/git-plugin-error.ts
new file mode 100644
index 0000000..10c7417
--- /dev/null
+++ b/src/lib/errors/git-plugin-error.ts
@@ -0,0 +1,15 @@
+import { SimpleGitOptions, SimpleGitTask } from '../types';
+import { GitError } from './git-error';
+
+export class GitPluginError extends GitError {
+
+   constructor (
+      public task?: SimpleGitTask<any>,
+      public readonly plugin?: keyof SimpleGitOptions,
+      message?: string,
+   ) {
+      super(task, message);
+      Object.setPrototypeOf(this, new.target.prototype);
+   }
+
+}
diff --git a/src/lib/git-factory.ts b/src/lib/git-factory.ts
new file mode 100644
index 0000000..669a3fd
--- /dev/null
+++ b/src/lib/git-factory.ts
@@ -0,0 +1,51 @@
+const Git = require('../git');
+
+import { SimpleGitFactory } from '../../typings';
+
+import api from './api';
+import { commandConfigPrefixingPlugin, PluginStore, progressMonitorPlugin, timeoutPlugin } from './plugins';
+import { createInstanceConfig, folderExists } from './utils';
+import { SimpleGitOptions } from './types';
+
+/**
+ * Adds the necessary properties to the supplied object to enable it for use as
+ * the default export of a module.
+ *
+ * Eg: `module.exports = esModuleFactory({ something () {} })`
+ */
+export function esModuleFactory<T>(defaultExport: T): T & { __esModule: true, default: T } {
+   return Object.defineProperties(defaultExport, {
+      __esModule: {value: true},
+      default: {value: defaultExport},
+   });
+}
+
+export function gitExportFactory<T = {}>(factory: SimpleGitFactory, extra: T) {
+   return Object.assign(function (...args: Parameters<SimpleGitFactory>) {
+         return factory.apply(null, args);
+      },
+      api,
+      extra || {},
+   );
+}
+
+export function gitInstanceFactory(baseDir?: string | Partial<SimpleGitOptions>, options?: Partial<SimpleGitOptions>) {
+   const plugins = new PluginStore();
+   const config = createInstanceConfig(
+      baseDir && (typeof baseDir === 'string' ? {baseDir} : baseDir) || {},
+      options
+   );
+
+   if (!folderExists(config.baseDir)) {
+      throw new api.GitConstructError(config, `Cannot use simple-git on a directory that does not exist`);
+   }
+
+   if (Array.isArray(config.config)) {
+      plugins.add(commandConfigPrefixingPlugin(config.config));
+   }
+
+   config.progress && plugins.add(progressMonitorPlugin(config.progress));
+   config.timeout && plugins.add(timeoutPlugin(config.timeout));
+
+   return new Git(config, plugins);
+}
diff --git a/src/lib/git-logger.ts b/src/lib/git-logger.ts
index d72fc46..828bcff 100644
--- a/src/lib/git-logger.ts
+++ b/src/lib/git-logger.ts
@@ -26,7 +26,6 @@ export interface OutputLogger extends OutputLoggingHandler {
    step (nextStep?: string): OutputLogger;
    child (name: string): OutputLogger;
    sibling (name: string): OutputLogger;
-   destroy (): void;
 }
 
 function prefixedLogger (to: Debugger, prefix: string, forward?: OutputLoggingHandler): OutputLoggingHandler {
@@ -67,11 +66,6 @@ export function createLogger (label: string, verbose?: string | Debugger, initia
 
    return step(initialStep);
 
-   function destroy() {
-      spawned.forEach(logger => logger.destroy());
-      spawned.length = 0;
-   }
-
    function child(name: string) {
       return append(spawned, createLogger(label, debugDebugger && debugDebugger.extend(name) || name));
    }
@@ -93,7 +87,6 @@ export function createLogger (label: string, verbose?: string | Debugger, initia
          debug,
          info,
          step,
-         destroy,
       });
    }
 }
diff --git a/src/lib/parsers/parse-list-log-summary.ts b/src/lib/parsers/parse-list-log-summary.ts
index f03571b..3cbba40 100644
--- a/src/lib/parsers/parse-list-log-summary.ts
+++ b/src/lib/parsers/parse-list-log-summary.ts
@@ -35,6 +35,6 @@ export function createListLogSummaryParser<T = any> (splitter = SPLITTER, fields
          all,
          latest: all.length && all[0] || null,
          total: all.length,
-      }
+      };
    }
 }
diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts
index bbee2f2..fd14965 100644
--- a/src/lib/plugins/index.ts
+++ b/src/lib/plugins/index.ts
@@ -1,3 +1,5 @@
 export * from './command-config-prefixing-plugin';
 export * from './plugin-store';
+export * from './progress-monitor-plugin';
 export * from './simple-git-plugin';
+export * from './timout-plugin';
diff --git a/src/lib/plugins/plugin-store.ts b/src/lib/plugins/plugin-store.ts
index 590719c..04df682 100644
--- a/src/lib/plugins/plugin-store.ts
+++ b/src/lib/plugins/plugin-store.ts
@@ -1,13 +1,14 @@
 import { SimpleGitPlugin, SimpleGitPluginType, SimpleGitPluginTypes } from './simple-git-plugin';
-import { asArray } from '../utils';
+import { append, asArray } from '../utils';
 
 export class PluginStore {
 
    private plugins: Set<SimpleGitPlugin<SimpleGitPluginType>> = new Set();
 
-   public add<T extends SimpleGitPluginType>(plugin: SimpleGitPlugin<T> | SimpleGitPlugin<T>[]) {
-      const plugins = asArray(plugin);
-      plugins.forEach(plugin => this.plugins.add(plugin));
+   public add<T extends SimpleGitPluginType>(plugin: void | SimpleGitPlugin<T> | SimpleGitPlugin<T>[]) {
+      const plugins: SimpleGitPlugin<T>[] = [];
+
+      asArray(plugin).forEach(plugin => plugin && this.plugins.add(append(plugins, plugin)));
 
       return () => {
          plugins.forEach(plugin => this.plugins.delete(plugin));
diff --git a/src/lib/plugins/simple-git-plugin.ts b/src/lib/plugins/simple-git-plugin.ts
index 6ea8c54..19355c5 100644
--- a/src/lib/plugins/simple-git-plugin.ts
+++ b/src/lib/plugins/simple-git-plugin.ts
@@ -13,7 +13,8 @@ export interface SimpleGitPluginTypes {
    'spawn.after': {
       data: void;
       context: SimpleGitTaskPluginContext & {
-         spawned: ChildProcess,
+         spawned: ChildProcess;
+         kill (reason: Error): void;
       };
    }
 }
diff --git a/src/lib/plugins/timout-plugin.ts b/src/lib/plugins/timout-plugin.ts
new file mode 100644
index 0000000..40a31f0
--- /dev/null
+++ b/src/lib/plugins/timout-plugin.ts
@@ -0,0 +1,43 @@
+import { SimpleGitOptions } from '../types';
+
+import { SimpleGitPlugin } from './simple-git-plugin';
+import { GitPluginError } from '../errors/git-plugin-error';
+
+export function timeoutPlugin({block}: Exclude<SimpleGitOptions['timeout'], undefined>): SimpleGitPlugin<'spawn.after'> | void {
+
+   if (block > 0) {
+      return {
+         type: 'spawn.after',
+         action(_data, context) {
+            let timeout: NodeJS.Timeout;
+
+            function wait() {
+               timeout && clearTimeout(timeout);
+               timeout = setTimeout(kill, block);
+            }
+
+            function stop() {
+               context.spawned.stdout?.off('data', wait);
+               context.spawned.stderr?.off('data', wait);
+               context.spawned.off('exit', stop);
+               context.spawned.off('close', stop);
+            }
+
+            function kill() {
+               stop()
+               context.kill(
+                  new GitPluginError(undefined, 'timeout', `block timeout reached`)
+               );
+            }
+
+            context.spawned.stdout?.on('data', wait);
+            context.spawned.stderr?.on('data', wait);
+            context.spawned.on('exit', stop);
+            context.spawned.on('close', stop);
+
+            wait();
+         }
+      }
+   }
+
+}
diff --git a/src/lib/runners/git-executor-chain.ts b/src/lib/runners/git-executor-chain.ts
index 2a62eba..ddae5b3 100644
--- a/src/lib/runners/git-executor-chain.ts
+++ b/src/lib/runners/git-executor-chain.ts
@@ -1,5 +1,5 @@
 import { spawn, SpawnOptions } from 'child_process';
-import { GitError } from '../api';
+import { GitError } from '../errors/git-error';
 import { OutputLogger } from '../git-logger';
 import { PluginStore } from '../plugins';
 import { EmptyTask, isBufferTask, isEmptyTask, } from '../tasks/task';
@@ -106,14 +106,17 @@ export class GitExecutorChain implements SimpleGitExecutor {
 
    private handleTaskData<R>(
       {onError, concatStdErr}: SimpleGitTask<R>,
-      {exitCode, stdOut, stdErr}: GitExecutorResult, logger: OutputLogger): Promise<GitOutputStreams> {
+      {exitCode, rejection, stdOut, stdErr}: GitExecutorResult, logger: OutputLogger): Promise<GitOutputStreams> {
 
       return new Promise((done, fail) => {
          logger(`Preparing to handle process response exitCode=%d stdOut=`, exitCode);
 
-         if (exitCode && stdErr.length && onError) {
+         const failed = !!(rejection || (exitCode && stdErr.length));
+
+         if (failed && onError) {
             logger.info(`exitCode=%s handling with custom error handler`);
             logger(`concatenate stdErr to stdOut: %j`, concatStdErr);
+
             return onError(
                exitCode,
                Buffer.concat([...(concatStdErr ? stdOut : []), ...stdErr]).toString('utf-8'),
@@ -130,9 +133,9 @@ export class GitExecutorChain implements SimpleGitExecutor {
             );
          }
 
-         if (exitCode && stdErr.length) {
-            logger.info(`exitCode=%s treated as error when then child process has written to stdErr`);
-            return fail(Buffer.concat(stdErr).toString('utf-8'));
+         if (failed) {
+            logger.info(`handling as error: exitCode=%s stdErr=%s rejection=%o`, exitCode, stdErr.length, rejection);
+            return fail(rejection || Buffer.concat(stdErr).toString('utf-8'));
          }
 
          if (concatStdErr) {
@@ -162,19 +165,20 @@ export class GitExecutorChain implements SimpleGitExecutor {
          const stdErr: Buffer[] = [];
 
          let attempted = false;
+         let rejection: Maybe<Error>;
 
          function attemptClose(exitCode: number, event: string = 'retry') {
 
             // closing when there is content, terminate immediately
             if (attempted || stdErr.length || stdOut.length) {
-               logger.info(`exitCode=%s event=%s`, exitCode, event);
+               logger.info(`exitCode=%s event=%s rejection=%o`, exitCode, event, rejection);
                done({
                   stdOut,
                   stdErr,
                   exitCode,
+                  rejection,
                });
                attempted = true;
-               outputLogger.destroy();
             }
 
             // first attempt at closing but no content yet, wait briefly for the close/exit that may follow
@@ -206,6 +210,14 @@ export class GitExecutorChain implements SimpleGitExecutor {
          this._plugins.exec('spawn.after', undefined, {
             ...pluginContext(task, args),
             spawned,
+            kill (reason: Error) {
+               if (spawned.killed) {
+                  return;
+               }
+
+               rejection = reason;
+               spawned.kill('SIGINT');
+            }
          });
 
       });
diff --git a/src/lib/runners/promise-wrapped.ts b/src/lib/runners/promise-wrapped.ts
index 1284259..2ca9737 100644
--- a/src/lib/runners/promise-wrapped.ts
+++ b/src/lib/runners/promise-wrapped.ts
@@ -1,5 +1,7 @@
 import { SimpleGit, SimpleGitOptions } from '../../../typings';
+
 import { GitResponseError } from '../errors/git-response-error';
+import { gitInstanceFactory } from '../git-factory';
 import { SimpleGitTaskCallback } from '../types';
 
 const functionNamesBuilderApi = [
@@ -67,8 +69,6 @@ const functionNamesPromiseApi = [
    'updateServerInfo'
 ];
 
-const {gitInstanceFactory} = require('../../git-factory');
-
 export function gitP(...args: [] | [string] | [Partial<SimpleGitOptions>] | [string, Partial<SimpleGitOptions>]): SimpleGit {
 
    let git: any;
diff --git a/src/lib/runners/tasks-pending-queue.ts b/src/lib/runners/tasks-pending-queue.ts
index fe6aedb..4f8abac 100644
--- a/src/lib/runners/tasks-pending-queue.ts
+++ b/src/lib/runners/tasks-pending-queue.ts
@@ -1,6 +1,6 @@
 import { SimpleGitTask } from '../types';
+import { GitError } from '../errors/git-error';
 import { createLogger, OutputLogger } from '../git-logger';
-import { GitError } from '../api';
 
 type AnySimpleGitTask = SimpleGitTask<any>;
 
@@ -61,7 +61,6 @@ export class TasksPendingQueue {
    complete(task: AnySimpleGitTask) {
       const progress = this.withProgress(task);
       if (progress) {
-         progress.logger.destroy();
          this._queue.delete(task);
       }
    }
diff --git a/src/lib/task-callback.ts b/src/lib/task-callback.ts
index 6f8c7d4..fbe3116 100644
--- a/src/lib/task-callback.ts
+++ b/src/lib/task-callback.ts
@@ -1,4 +1,5 @@
-import { GitError, GitResponseError } from './api';
+import { GitError } from './errors/git-error';
+import { GitResponseError } from './errors/git-response-error';
 import { SimpleGitTask, SimpleGitTaskCallback } from './types';
 import { NOOP } from './utils';
 
diff --git a/src/lib/tasks/merge.ts b/src/lib/tasks/merge.ts
index 70dafdc..a8f1280 100644
--- a/src/lib/tasks/merge.ts
+++ b/src/lib/tasks/merge.ts
@@ -1,5 +1,5 @@
 import { MergeResult } from '../../../typings';
-import { GitResponseError } from '../api';
+import { GitResponseError } from '../errors/git-response-error';
 import { parseMergeResult } from '../parsers/parse-merge';
 import { StringTask } from '../types';
 import { configurationErrorTask, EmptyTask } from './task';
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts
index b434323..a2d5cc3 100644
--- a/src/lib/types/index.ts
+++ b/src/lib/types/index.ts
@@ -59,6 +59,7 @@ export interface GitExecutorResult {
    stdOut: Buffer[];
    stdErr: Buffer[];
    exitCode: number;
+   rejection: Maybe<Error>;
 }
 
 /**
@@ -70,6 +71,9 @@ export interface SimpleGitOptions {
    binary: string;
    maxConcurrentProcesses: number;
    config: string[];
+   timeout?: {
+      block: number;
+   };
 
    progress?(data: SimpleGitProgressEvent): void;
 }
diff --git a/src/lib/utils/simple-git-options.ts b/src/lib/utils/simple-git-options.ts
index ee5fb66..20cda7d 100644
--- a/src/lib/utils/simple-git-options.ts
+++ b/src/lib/utils/simple-git-options.ts
@@ -6,7 +6,7 @@ const defaultOptions: Omit<SimpleGitOptions, 'baseDir'> = {
    config: [],
 };
 
-export function createInstanceConfig(...options: Array<Partial<SimpleGitOptions>>): SimpleGitOptions {
+export function createInstanceConfig(...options: Array<Partial<SimpleGitOptions> | undefined>): SimpleGitOptions {
    const baseDir = process.cwd();
    const config: SimpleGitOptions = Object.assign({baseDir, ...defaultOptions},
       ...(options.filter(o => typeof o === 'object' && o))


Test patch:
diff --git a/test/integration/timeout-plugin.spec.ts b/test/integration/timeout-plugin.spec.ts
new file mode 100644
index 0000000..7e55e65
--- /dev/null
+++ b/test/integration/timeout-plugin.spec.ts
@@ -0,0 +1,25 @@
+import { assertGitError, createTestContext, newSimpleGit, SimpleGitTestContext } from '../__fixtures__';
+import { promiseError } from '@kwsites/promise-result';
+import { GitPluginError } from '../../src/lib/errors/git-plugin-error';
+
+describe('timeout', () => {
+
+   let context: SimpleGitTestContext;
+
+   beforeEach(async () => context = await createTestContext());
+
+   it('kills processes after a timeout', async () => {
+      const upstream = await newSimpleGit(__dirname).revparse('--git-dir');
+
+      const git = newSimpleGit({
+         baseDir: context.root,
+         timeout: {
+            block: 1,
+         }
+      });
+
+      const threw = await promiseError(git.raw('clone', upstream, '.'));
+      assertGitError(threw, 'block timeout reached', GitPluginError);
+   });
+
+})
diff --git a/test/unit/__fixtures__/child-processes.ts b/test/unit/__fixtures__/child-processes.ts
index e337caf..fd40fb0 100644
--- a/test/unit/__fixtures__/child-processes.ts
+++ b/test/unit/__fixtures__/child-processes.ts
@@ -19,6 +19,21 @@ export async function writeToStdErr (data = '') {
    proc.stderr.$emit('data', Buffer.from(data));
 }
 
+export async function writeToStdOut (data = '') {
+   await wait();
+   const proc = mockChildProcessModule.$mostRecent();
+
+   if (!proc) {
+      throw new Error(`writeToStdOut unable to find matching child process`);
+   }
+
+   if (proc.$emitted('exit')) {
+      throw new Error('writeToStdOut: attempting to write to an already closed process');
+   }
+
+   proc.stdout.$emit('data', Buffer.from(data));
+}
+
 export async function closeWithError (stack = 'CLOSING WITH ERROR', code = EXIT_CODE_ERROR) {
    await wait();
    const match = mockChildProcessModule.$mostRecent();
@@ -39,6 +54,14 @@ export async function closeWithSuccess (message = '') {
    await wait();
 }
 
+export function theChildProcess() {
+   if(mockChildProcessModule.$count() !== 1) {
+      throw new Error(`theChildProcess helper found ${mockChildProcessModule.$count()} child processes`);
+   }
+
+   return mockChildProcessModule.$mostRecent();
+}
+
 export function theChildProcessMatching(what: string[] | ((mock: MockChildProcess) => boolean)) {
    const match = mockChildProcessModule.$matchingChildProcess(what);
 
diff --git a/test/unit/__mocks__/mock-child-process.ts b/test/unit/__mocks__/mock-child-process.ts
index d36c5aa..547c8a9 100644
--- a/test/unit/__mocks__/mock-child-process.ts
+++ b/test/unit/__mocks__/mock-child-process.ts
@@ -3,6 +3,7 @@ export type MockEventTarget = {
    $emit (event: string, data: any): void;
    $emitted (event: string): boolean;
    on: jest.Mock;
+   off: jest.Mock;
 }
 
 export type MockChildProcess = MockEventTarget & {
@@ -13,6 +14,8 @@ export type MockChildProcess = MockEventTarget & {
 
    readonly stderr: MockEventTarget;
    readonly stdout: MockEventTarget;
+
+   kill (): void;
 }
 
 type ChildProcessConstructor = [string, string[], any];
@@ -31,9 +34,15 @@ class MockEventTargetImpl implements MockEventTarget {
       this.getHandlers(event).forEach(handler => handler(data));
    }
 
+   public kill = jest.fn();
+
+   public off = jest.fn((event: string, handler: Function) => {
+      this.delHandler(event, handler);
+   });
+
    public on = jest.fn((event: string, handler: Function) => {
       this.addHandler(event, handler);
-   })
+   });
 
    private addHandler (event: string, handler: Function) {
       this.$handlers.set(event, [
@@ -42,6 +51,20 @@ class MockEventTargetImpl implements MockEventTarget {
       ]);
    }
 
+   private delHandler (event: string, handler: Function) {
+      const handlers = this.$handlers.get(event);
+      if (!Array.isArray(handlers)) {
+         return;
+      }
+
+      const index = handlers.indexOf(handler);
+      if (index < 0) {
+         return;
+      }
+
+      handlers.splice(index, 1);
+   }
+
    private getHandlers (event: string) {
       const handlers = this.$handlers.get(event);
       if (!handlers?.length) {
@@ -78,6 +101,10 @@ export const mockChildProcessModule = (function mockChildProcessModule () {
          return children.map(child => child.$args);
       },
 
+      $count () {
+         return children.length;
+      },
+
       $mostRecent () {
          return children[children.length - 1];
       },
diff --git a/test/unit/plugins.spec.ts b/test/unit/plugins.spec.ts
index 6401879..cdef975 100644
--- a/test/unit/plugins.spec.ts
+++ b/test/unit/plugins.spec.ts
@@ -4,7 +4,9 @@ import {
    assertExecutedCommandsContainsOnce,
    closeWithSuccess,
    newSimpleGit,
-   writeToStdErr
+   theChildProcess,
+   writeToStdErr,
+   writeToStdOut
 } from './__fixtures__';
 
 describe('plugins', () => {
@@ -113,4 +115,29 @@ describe('plugins', () => {
       });
    });
 
+   describe('timeout', () => {
+
+      beforeEach(() => jest.useFakeTimers());
+
+      it('waits for some time after a block on stdout', async () => {
+         git = newSimpleGit({ timeout: { block: 2000 } });
+         git.init();
+
+         await Promise.resolve();
+
+         const stdOut = Promise.all([
+            writeToStdOut('first'),
+            writeToStdOut('second'),
+         ]);
+         jest.advanceTimersByTime(1000);
+
+         await stdOut;
+         expect(theChildProcess().kill).not.toHaveBeenCalled();
+
+         jest.advanceTimersByTime(2000);
+         expect(theChildProcess().kill).toHaveBeenCalled();
+      });
+
+   });
+
 });