Repository: steveukx/git-js
Pull request #620 (2021-05-13 06:35:11)

feat: `git.cwd` can now be configured to affect just the chain rather than root instance.
feat: `git.cwd` can now be configured to affect just the chain rather than root instance.

Adds a new interface for `git.cwd` to supply the `directory` as an object containing `{ path: string, root?: boolean }` where omitting the `root` property or setting it to `false` will change the working directory only for the chain, whereas setting `root` to `true` (or supplying `directory` as a string - for backward compatibility purposes) will change the working directory of the main instance.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01cdb50..e5dc98e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,5 @@
 # Change History & Release Notes
 
-### [2.38.1](https://www.github.com/steveukx/git-js/compare/v2.38.0...v2.38.1) (2021-05-09)
-
-
-### Bug Fixes
-
-* Export `GitPluginError` from the main package. ([2aa7e55](https://www.github.com/steveukx/git-js/commit/2aa7e55216cdf57ca905cd6c23ff6b71002450c6)), closes [#616](https://www.github.com/steveukx/git-js/issues/616)
-
 ## [2.38.0](https://www.github.com/steveukx/git-js/compare/v2.37.0...v2.38.0) (2021-04-14)
 
 
diff --git a/examples/git-change-working-directory.md b/examples/git-change-working-directory.md
new file mode 100644
index 0000000..3e4545b
--- /dev/null
+++ b/examples/git-change-working-directory.md
@@ -0,0 +1,47 @@
+## Changing Working Directory
+
+To change the directory the `git` commands are run in you can either configure the `simple-git` instance
+when it is created by using the `baseDir` property:
+
+```typescript
+import { join } from 'path';
+import simpleGit from 'simple-git';
+
+const git = simpleGit({ baseDir: join(__dirname, 'repos') });
+```
+
+Or explicitly set the working directory at some later time, for example after cloning a repo:
+
+```typescript
+import { join } from 'path';
+import simpleGit, { SimpleGit } from 'simple-git';
+
+const remote = `https://github.com/steveukx/git-js.git`;
+const target = join(__dirname, 'repos', 'git-js');
+
+// repo is now a `SimpleGit` instance operating on the `target` directory
+// having cloned the remote repo then switched into the cloned directory
+const repo: SimpleGit = await simpleGit().clone(remote, target).cwd({ path: target });
+```
+
+In the example above we're using the command chaining feature of `simple-git` where many commands
+are treated as an atomic operation. To rewrite this using separate `async/await` steps would be:
+
+```typescript
+import { join } from 'path';
+import simpleGit, { SimpleGit } from 'simple-git';
+
+const remote = `https://github.com/steveukx/git-js.git`;
+const target = join(__dirname, 'repos', 'git-js');
+
+// create a `SimpleGit` instance 
+const git: SimpleGit = simpleGit();
+
+// use that instance to do the clone
+await git.clone(remote, target);
+
+// then set the working directory of the root instance - you want all future
+// tasks run through `git` to be from the new directory, rather than just tasks
+// chained off this task
+await git.cwd({ path: target, root: true });
+```
diff --git a/package.json b/package.json
index 4c09341..f8c72f3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "simple-git",
   "description": "Simple GIT interface for node.js",
-  "version": "2.38.1",
+  "version": "2.38.0",
   "author": "Steve King <steve@mydev.co>",
   "contributors": [
     {
diff --git a/readme.md b/readme.md
index a378265..57d6c13 100644
--- a/readme.md
+++ b/readme.md
@@ -170,7 +170,6 @@ For type details of the response for each of the tasks, please see the [TypeScri
 | `.commit(message, handlerFn)` | commits changes in the current working directory with the supplied message where the message can be either a single string or array of strings to be passed as separate arguments (the `git` command line interface converts these to be separated by double line breaks) |
 | `.commit(message, [fileA, ...], options, handlerFn)` | commits changes on the named files with the supplied message, when supplied, the optional options object can contain any other parameters to pass to the commit command, setting the value of the property to be a string will add `name=value` to the command string, setting any other type of value will result in just the key from the object being passed (ie: just `name`), an example of setting the author is below |
 | `.customBinary(gitPath)` | sets the command to use to reference git, allows for using a git binary not available on the path environment variable |
-| `.cwd(workingDirectory)` |  Sets the current working directory for all commands after this step in the chain |
 | `.diff(options, handlerFn)` | get the diff of the current repo compared to the last commit with a set of options supplied as a string |
 | `.diff(handlerFn)` | get the diff for all file in the current repo compared to the last commit |
 | `.diffSummary(handlerFn)` | gets a summary of the diff for files in the repo, uses the `git diff --stat` format to calculate changes. Handler is called with a nullable error object and an instance of the [DiffSummary](src/lib/responses/DiffSummary.js) |
@@ -330,6 +329,11 @@ For type details of the response for each of the tasks, please see the [TypeScri
 - `.submoduleInit([options]` Initialises sub modules, the optional [options](#how-to-specify-options) argument can be used to pass extra options to the `git submodule init` command.
 - `.submoduleUpdate(subModuleName, [options])` Updates sub modules, can be called with a sub module name and [options](#how-to-specify-options), just the options or with no arguments
 
+## changing the working directory [examples](examples/git-change-working-directory.md)
+
+- `.cwd(workingDirectory)` Sets the working directory for all future commands - note, this will change the working for the root instance, any chain created from the root will also be changed.
+- `.cwd({ path, root = false })` Sets the working directory for all future commands either in the current chain of commands (where `root` is omitted or set to `false`) or in the main instance (where `root` is `true`). 
+
 # How to Specify Options
 
 Where the task accepts custom options (eg: `pull` or `commit`), these can be supplied as an object, the keys of which
diff --git a/src/git.js b/src/git.js
index 4dcfc9c..457d148 100644
--- a/src/git.js
+++ b/src/git.js
@@ -3,16 +3,14 @@ const {SimpleGitApi} = require('./lib/simple-git-api');
 
 const {Scheduler} = require('./lib/runners/scheduler');
 const {GitLogger} = require('./lib/git-logger');
-const {adhocExecTask, configurationErrorTask} = require('./lib/tasks/task');
+const {configurationErrorTask} = require('./lib/tasks/task');
 const {
-   NOOP,
    asArray,
    filterArray,
    filterPrimitives,
    filterString,
    filterStringOrStringArray,
    filterType,
-   folderExists,
    getTrailingOptions,
    trailingFunctionArgument,
    trailingOptionsArgument
@@ -89,23 +87,6 @@ Git.prototype.env = function (name, value) {
    return this;
 };
 
-/**
- * Sets the working directory of the subsequent commands.
- */
-Git.prototype.cwd = function (workingDirectory) {
-   const task = (typeof workingDirectory !== 'string')
-      ? configurationErrorTask('Git.cwd: workingDirectory must be supplied as a string')
-      : adhocExecTask(() => {
-         if (!folderExists(workingDirectory)) {
-            throw new Error(`Git.cwd: cannot change to non-directory "${ workingDirectory }"`);
-         }
-
-         return (this._executor.cwd = workingDirectory);
-      });
-
-   return this._runTask(task, trailingFunctionArgument(arguments) || NOOP);
-};
-
 /**
  * Sets a handler function to be called whenever a new child process is created, the handler function will be called
  * with the name of the command being run and the stdout & stderr streams used by the ChildProcess.
diff --git a/src/lib/runners/git-executor-chain.ts b/src/lib/runners/git-executor-chain.ts
index f4727c9..6a9d31e 100644
--- a/src/lib/runners/git-executor-chain.ts
+++ b/src/lib/runners/git-executor-chain.ts
@@ -12,13 +12,18 @@ export class GitExecutorChain implements SimpleGitExecutor {
 
    private _chain: Promise<any> = Promise.resolve();
    private _queue = new TasksPendingQueue();
+   private _cwd: string | undefined;
 
    public get binary() {
       return this._executor.binary;
    }
 
    public get cwd() {
-      return this._executor.cwd;
+      return this._cwd || this._executor.cwd;
+   }
+
+   public set cwd(cwd: string) {
+      this._cwd = cwd;
    }
 
    public get env() {
@@ -93,7 +98,7 @@ export class GitExecutorChain implements SimpleGitExecutor {
 
    private async attemptEmptyTask(task: EmptyTask, logger: OutputLogger) {
       logger(`empty task bypassing child process to call to task's parser`);
-      return task.parser();
+      return task.parser(this);
    }
 
    private handleTaskData<R>(
diff --git a/src/lib/simple-git-api.ts b/src/lib/simple-git-api.ts
index ef5c3fd..aa9a2d7 100644
--- a/src/lib/simple-git-api.ts
+++ b/src/lib/simple-git-api.ts
@@ -1,7 +1,8 @@
 import { PushResult, SimpleGit, SimpleGitBase, TaskOptions } from '../../typings';
 import { taskCallback } from './task-callback';
+import { changeWorkingDirectoryTask } from './tasks/change-working-directory';
 import { pushTask } from './tasks/push';
-import { straightThroughStringTask } from './tasks/task';
+import { configurationErrorTask, straightThroughStringTask } from './tasks/task';
 import { SimpleGitExecutor, SimpleGitTask, SimpleGitTaskCallback } from './types';
 import { asArray, filterString, filterType, getTrailingOptions, trailingFunctionArgument } from './utils';
 
@@ -34,6 +35,23 @@ export class SimpleGitApi implements SimpleGitBase {
       );
    }
 
+   cwd(directory: string | { path: string, root?: boolean }) {
+      const next = trailingFunctionArgument(arguments);
+
+      if (typeof directory === 'string') {
+         return this._runTask(changeWorkingDirectoryTask(directory, this._executor), next);
+      }
+
+      if (typeof directory?.path === 'string') {
+         return this._runTask(changeWorkingDirectoryTask(directory.path, directory.root && this._executor || undefined), next);
+      }
+
+      return this._runTask(
+         configurationErrorTask('Git.cwd: workingDirectory must be supplied as a string'),
+         next
+      );
+   }
+
    push(remote?: string, branch?: string, options?: TaskOptions, callback?: SimpleGitTaskCallback<PushResult>): SimpleGit & Promise<PushResult>;
    push(options?: TaskOptions, callback?: SimpleGitTaskCallback<PushResult>): SimpleGit & Promise<PushResult>;
    push(callback?: SimpleGitTaskCallback<PushResult>): SimpleGit & Promise<PushResult>;
diff --git a/src/lib/tasks/change-working-directory.ts b/src/lib/tasks/change-working-directory.ts
new file mode 100644
index 0000000..e3e9b85
--- /dev/null
+++ b/src/lib/tasks/change-working-directory.ts
@@ -0,0 +1,13 @@
+import { folderExists } from '../utils';
+import { SimpleGitExecutor } from '../types';
+import { adhocExecTask } from './task';
+
+export function changeWorkingDirectoryTask (directory: string, root?: SimpleGitExecutor) {
+   return adhocExecTask((instance: SimpleGitExecutor) => {
+      if (!folderExists(directory)) {
+         throw new Error(`Git.cwd: cannot change to non-directory "${ directory }"`);
+      }
+
+      return ((root || instance).cwd = directory);
+   });
+}
diff --git a/src/lib/tasks/task.ts b/src/lib/tasks/task.ts
index 9102fa4..8bc572f 100644
--- a/src/lib/tasks/task.ts
+++ b/src/lib/tasks/task.ts
@@ -1,26 +1,28 @@
 import { TaskConfigurationError } from '../errors/task-configuration-error';
-import { BufferTask, EmptyTaskParser, SimpleGitTask, SimpleGitTaskConfiguration, StringTask } from '../types';
+import { BufferTask, EmptyTaskParser, SimpleGitTask, StringTask } from '../types';
 
 export const EMPTY_COMMANDS: [] = [];
 
-export type EmptyTask<RESPONSE = void> = SimpleGitTaskConfiguration<RESPONSE, 'utf-8', string> & {
+export type EmptyTask = {
    commands: typeof EMPTY_COMMANDS;
-   parser: EmptyTaskParser<RESPONSE>;
+   format: 'empty',
+   parser: EmptyTaskParser;
+   onError?: undefined;
 };
 
 
-export function adhocExecTask<R>(parser: () => R): StringTask<R> {
+export function adhocExecTask(parser: EmptyTaskParser): EmptyTask {
    return {
       commands: EMPTY_COMMANDS,
-      format: 'utf-8',
+      format: 'empty',
       parser,
-   }
+   };
 }
 
 export function configurationErrorTask(error: Error | string): EmptyTask {
    return {
       commands: EMPTY_COMMANDS,
-      format: 'utf-8',
+      format: 'empty',
       parser() {
          throw typeof error === 'string' ? new TaskConfigurationError(error) : error;
       }
@@ -52,5 +54,5 @@ export function isBufferTask<R>(task: SimpleGitTask<R>): task is BufferTask<R> {
 }
 
 export function isEmptyTask<R>(task: SimpleGitTask<R>): task is EmptyTask {
-   return !task.commands.length;
+   return task.format === 'empty' || !task.commands.length;
 }
diff --git a/src/lib/types/tasks.ts b/src/lib/types/tasks.ts
index 5a19a38..8ed2aa0 100644
--- a/src/lib/types/tasks.ts
+++ b/src/lib/types/tasks.ts
@@ -1,4 +1,4 @@
-import { GitExecutorResult } from './index';
+import { GitExecutorResult, SimpleGitExecutor } from './index';
 import { EmptyTask } from '../tasks/task';
 
 export type TaskResponseFormat = Buffer | string;
@@ -7,8 +7,8 @@ export interface TaskParser<INPUT extends TaskResponseFormat, RESPONSE> {
    (stdOut: INPUT, stdErr: INPUT): RESPONSE;
 }
 
-export interface EmptyTaskParser<RESPONSE> {
-   (): RESPONSE;
+export interface EmptyTaskParser {
+   (executor: SimpleGitExecutor): void;
 }
 
 export interface SimpleGitTaskConfiguration<RESPONSE, FORMAT, INPUT extends TaskResponseFormat> {
diff --git a/typings/simple-git.d.ts b/typings/simple-git.d.ts
index 480e481..95dcbe6 100644
--- a/typings/simple-git.d.ts
+++ b/typings/simple-git.d.ts
@@ -18,6 +18,11 @@ export interface SimpleGitBase {
     */
    add(files: string | string[], callback?: types.SimpleGitTaskCallback<string>): Response<string>;
 
+   /**
+    * Sets the working directory of the subsequent commands.
+    */
+   cwd(directory: { path: string, root?: boolean }, callback?: types.SimpleGitTaskCallback<string>): Response<string>;
+   cwd<path extends string>(directory: path, callback?: types.SimpleGitTaskCallback<path>): Response<path>;
 
    /**
     * Pushes the current committed changes to a remote, optionally specify the names of the remote and branch to use
@@ -221,11 +226,6 @@ export interface SimpleGit extends SimpleGitBase {
     */
    customBinary(command: string): this;
 
-   /**
-    * Sets the working directory of the subsequent commands.
-    */
-   cwd<path extends string>(workingDirectory: path, callback?: types.SimpleGitTaskCallback<path>): Response<path>;
-
    /**
     * Delete one local branch. Supply the branchName as a string to return a
     * single `BranchDeletionSummary` instances.


Test patch:
diff --git a/test/__fixtures__/create-test-context.ts b/test/__fixtures__/create-test-context.ts
index 11f9589..94dad34 100644
--- a/test/__fixtures__/create-test-context.ts
+++ b/test/__fixtures__/create-test-context.ts
@@ -32,7 +32,7 @@ const io = {
             return done(path);
          }
 
-         mkdir(path, (err) => err ? fail(err) : done(path));
+         mkdir(path, {recursive: true}, (err) => err ? fail(err) : done(path));
       });
    },
    mkdtemp (): Promise<string> {
diff --git a/test/integration/change-directory.spec.ts b/test/integration/change-directory.spec.ts
index 6badd11..cb11185 100644
--- a/test/integration/change-directory.spec.ts
+++ b/test/integration/change-directory.spec.ts
@@ -1,5 +1,6 @@
 import { promiseError, promiseResult } from '@kwsites/promise-result';
 import { assertGitError, createTestContext, newSimpleGit, SimpleGitTestContext, wait } from '../__fixtures__';
+import { SimpleGit } from '../../typings';
 
 describe('change-directory', () => {
 
@@ -13,6 +14,44 @@ describe('change-directory', () => {
       badDir = await context.path('good', 'bad');
    });
 
+   it('cwd with path config starts new chain by default', async () => {
+      await context.dir('foo', 'bar');
+      await newSimpleGit(context.root).init();
+
+      // root chain with a configured working directory
+      const root = newSimpleGit(await context.path('good'));
+
+      // other chains with their own working directories
+      const foo = root.cwd({ path: await context.path('foo') });
+      const bar = root.cwd({ path: await context.path('foo', 'bar') });
+
+      const offsets  = await Promise.all([
+         showPrefix(foo),
+         showPrefix(bar),
+         showPrefix(root),
+      ]);
+
+      expect(offsets).toEqual(['foo/', 'foo/bar/', 'good/']);
+   });
+
+   it('cwd with path config can act on root instance', async () => {
+      await context.dir('foo', 'bar');
+      await newSimpleGit(context.root).init();
+
+      // root chain with a configured working directory
+      const root = newSimpleGit(await context.path('good'));
+
+      // other chains with their own working directories
+      const foo = root.cwd({ path: await context.path('foo'), root: true });
+
+      const offsets  = await Promise.all([
+         showPrefix(foo),
+         showPrefix(root),
+      ]);
+
+      expect(offsets).toEqual(['foo/', 'foo/']);
+   });
+
    it('switches into new directory - happy path promise', async () => {
       const result = await promiseResult(newSimpleGit(context.root).cwd(goodDir));
       expect(result).toEqual(expect.objectContaining({
@@ -42,4 +81,8 @@ describe('change-directory', () => {
       expect(spies[2]).not.toHaveBeenCalled();
 
    });
+
+   function showPrefix (git: SimpleGit) {
+      return git.raw('rev-parse', '--show-prefix').then(s => String(s).trim());
+   }
 })