Repository: steveukx/git-js
Pull request #666 (2021-08-13 17:49:52)

feat: `git.getConfig`
`git.getConfig` add support for getting the current value of a git configuration setting based on its name.

```typescript
// read value from the combined settings git uses
expect(await git.getConfig('user.name')).toBe('My Name');

// read value from a specific scope
expect(await git.getConfig('user.name', 'local')).toBe('My Name In This Repo');
```


Patch:
diff --git a/readme.md b/readme.md
index e6da214..fb00b19 100644
--- a/readme.md
+++ b/readme.md
@@ -240,6 +240,11 @@ For type details of the response for each of the tasks, please see the [TypeScri
   `true` the configuration setting is appended to rather than overwritten in the local config. Use the `scope` argument
   to pick where to save the new configuration setting (use the exported `GitConfigScope` enum, or equivalent string
   values - `worktree | local | global | system`).
+  
+- `.getConfig(key)` get the value(s) for a named key as a [ConfigGetResult](typings/response.d.ts)
+- `.getConfig(key, scope)` get the value(s) for a named key as a [ConfigGetResult](typings/response.d.ts) but limit the
+  scope of the properties searched to a single specified scope (use the exported `GitConfigScope` enum, or equivalent
+  string values - `worktree | local | global | system`)
 
 - `.listConfig()` reads the current configuration and returns a [ConfigListSummary](./src/lib/responses/ConfigList.ts)
 - `.listConfig(scope: GitConfigScope)` as with `listConfig` but returns only those items in a specified scope (note that configuration values are overlaid on top of each other to build the config `git` will actually use - to resolve the configuration you are using use `(await listConfig()).all` without the scope argument)
diff --git a/src/lib/responses/ConfigList.ts b/src/lib/responses/ConfigList.ts
index 62c81a5..12276ff 100644
--- a/src/lib/responses/ConfigList.ts
+++ b/src/lib/responses/ConfigList.ts
@@ -1,4 +1,4 @@
-import { ConfigListSummary, ConfigValues } from '../../../typings';
+import { ConfigGetResult, ConfigListSummary, ConfigValues } from '../../../typings';
 import { last, splitOn } from '../utils';
 
 export class ConfigList implements ConfigListSummary {
@@ -47,18 +47,53 @@ export class ConfigList implements ConfigListSummary {
 
 export function configListParser(text: string): ConfigList {
    const config = new ConfigList();
-   const lines = text.split('\0');
-
-   for (let i = 0, max = lines.length - 1; i < max;) {
-      const file = configFilePath(lines[i++]);
-      const [key, value] = splitOn(lines[i++], '\n');
 
-      config.addValue(file, key, value);
+   for (const item of configParser(text)) {
+      config.addValue(item.file, item.key, item.value);
    }
 
    return config;
 }
 
+export function configGetParser(text: string, key: string): ConfigGetResult {
+   let value: string | null = null;
+   const values: string[] = [];
+   const scopes: Map<string, string[]> = new Map();
+
+   for (const item of configParser(text)) {
+      if (item.key !== key) {
+         continue;
+      }
+
+      values.push(value = item.value);
+
+      if (!scopes.has(item.file)) {
+         scopes.set(item.file, []);
+      }
+
+      scopes.get(item.file)!.push(value);
+   }
+
+   return {
+      key,
+      paths: Array.from(scopes.keys()),
+      scopes,
+      value,
+      values
+   };
+}
+
 function configFilePath(filePath: string): string {
    return filePath.replace(/^(file):/, '');
 }
+
+function* configParser(text: string) {
+   const lines = text.split('\0');
+
+   for (let i = 0, max = lines.length - 1; i < max;) {
+      const file = configFilePath(lines[i++]);
+      const [key, value] = splitOn(lines[i++], '\n');
+
+      yield {file, key, value};
+   }
+}
diff --git a/src/lib/tasks/config.ts b/src/lib/tasks/config.ts
index d46aed9..8f4282e 100644
--- a/src/lib/tasks/config.ts
+++ b/src/lib/tasks/config.ts
@@ -1,5 +1,5 @@
-import { ConfigListSummary, SimpleGit } from '../../../typings';
-import { configListParser } from '../responses/ConfigList';
+import { ConfigGetResult, ConfigListSummary, SimpleGit } from '../../../typings';
+import { configGetParser, configListParser } from '../responses/ConfigList';
 import { SimpleGitApi } from '../simple-git-api';
 import { StringTask } from '../types';
 import { trailingFunctionArgument } from '../utils';
@@ -36,6 +36,22 @@ function addConfigTask(key: string, value: string, append: boolean, scope: GitCo
    }
 }
 
+function getConfigTask(key: string, scope?: GitConfigScope): StringTask<ConfigGetResult> {
+   const commands: string[] = ['config', '--null', '--show-origin', '--get-all', key];
+
+   if (scope) {
+      commands.splice(1, 0, `--${scope}`);
+   }
+
+   return {
+      commands,
+      format: 'utf-8',
+      parser(text) {
+         return configGetParser(text, key);
+      }
+   };
+}
+
 function listConfigTask(scope?: GitConfigScope): StringTask<ConfigListSummary> {
    const commands = ['config', '--list', '--show-origin', '--null'];
 
@@ -46,13 +62,13 @@ function listConfigTask(scope?: GitConfigScope): StringTask<ConfigListSummary> {
    return {
       commands,
       format: 'utf-8',
-      parser(text: string): any {
+      parser(text: string) {
          return configListParser(text);
       },
    }
 }
 
-export default function (): Pick<SimpleGit, 'addConfig' | 'listConfig'> {
+export default function (): Pick<SimpleGit, 'addConfig' | 'getConfig' | 'listConfig'> {
    return {
       addConfig(this: SimpleGitApi, key: string, value: string, ...rest: unknown[]) {
          return this._runTask(
@@ -61,6 +77,13 @@ export default function (): Pick<SimpleGit, 'addConfig' | 'listConfig'> {
          );
       },
 
+      getConfig(this: SimpleGitApi, key: string, scope?: GitConfigScope) {
+         return this._runTask(
+            getConfigTask(key, asConfigScope(scope, undefined)),
+            trailingFunctionArgument(arguments),
+         )
+      },
+
       listConfig(this: SimpleGitApi, ...rest: unknown[]) {
          return this._runTask(
             listConfigTask(asConfigScope(rest[0], undefined)),
diff --git a/typings/response.d.ts b/typings/response.d.ts
index f1575a8..e8fdb68 100644
--- a/typings/response.d.ts
+++ b/typings/response.d.ts
@@ -83,6 +83,28 @@ export interface CommitResult {
    };
 }
 
+/** Represents the response to using `git.getConfig` */
+export interface ConfigGetResult {
+   /** The key that was searched for */
+   key: string;
+
+   /** The single value seen by `git` for this key (equivalent to `git config --get key`) */
+   value: string | null;
+
+   /** All possible values for this key no matter the scope (equivalent to `git config --get-all key`) */
+   values: string[];
+
+   /** The file paths from which configuration was read */
+   paths: string[];
+
+   /**
+    * The full hierarchy of values the property can have had across the
+    * various scopes that were searched (keys in this Map are the strings
+    * also found in the `paths` array).
+    */
+   scopes: Map<string, string[]>;
+}
+
 /**
  * Represents the current git configuration, as defined by the output from `git log`
  */
diff --git a/typings/simple-git.d.ts b/typings/simple-git.d.ts
index a0cea6b..2f3e920 100644
--- a/typings/simple-git.d.ts
+++ b/typings/simple-git.d.ts
@@ -118,7 +118,7 @@ export interface SimpleGit extends SimpleGitBase {
     * Add config to local git instance for the specified `key` (eg: user.name) and value (eg: 'your name').
     * Set `append` to true to append to rather than overwrite the key
     */
-   addConfig(key: string, value: string, append?: boolean, scope?: 'system' | 'global' | 'local' | 'worktree', callback?: types.SimpleGitTaskCallback<string>): Response<string>;
+   addConfig(key: string, value: string, append?: boolean, scope?: keyof typeof types.GitConfigScope, callback?: types.SimpleGitTaskCallback<string>): Response<string>;
 
    addConfig(key: string, value: string, append?: boolean, callback?: types.SimpleGitTaskCallback<string>): Response<string>;
 
@@ -134,7 +134,7 @@ export interface SimpleGit extends SimpleGitBase {
    /**
     * Configuration values visible to git in the current working directory
     */
-   listConfig(scope: types.GitConfigScope | string, callback?: types.SimpleGitTaskCallback<resp.ConfigListSummary>): Response<resp.ConfigListSummary>;
+   listConfig(scope: keyof typeof types.GitConfigScope, callback?: types.SimpleGitTaskCallback<resp.ConfigListSummary>): Response<resp.ConfigListSummary>;
 
    listConfig(callback?: types.SimpleGitTaskCallback<resp.ConfigListSummary>): Response<resp.ConfigListSummary>;
 
@@ -370,6 +370,13 @@ export interface SimpleGit extends SimpleGitBase {
 
    fetch(callback?: types.SimpleGitTaskCallback<resp.FetchResult>): Response<resp.FetchResult>;
 
+   /**
+    * Gets the current value of a configuration property by it key, optionally specify the scope in which
+    * to run the command (omit / set to `undefined` to check in the complete overlaid configuration visible
+    * to the `git` process).
+    */
+   getConfig(key: string, scope?: keyof typeof types.GitConfigScope, callback?: types.SimpleGitTaskCallback<string>): Response<resp.ConfigGetResult>;
+
    /**
     * Gets the currently available remotes, setting the optional verbose argument to true includes additional
     * detail on the remotes themselves.


Test patch:
diff --git a/test/unit/config.spec.ts b/test/unit/config.spec.ts
index f412211..c2adbb5 100644
--- a/test/unit/config.spec.ts
+++ b/test/unit/config.spec.ts
@@ -134,4 +134,66 @@ custom@mydev.co\0`);
       assertExecutedCommands('config', '--list', '--show-origin', '--null', '--system');
    });
 
+   describe('getConfig', () => {
+      it('exposes all values split by scope', async () => {
+         const task = git.getConfig('some.prop');
+         await closeWithSuccess('file:.git/blah\0some.prop\nvalue\0file:.git/config\0some.prop\nvalue1\0file:.git/config\0some.prop\nvalue2\0');
+         const {scopes} = await task;
+
+         expect(scopes).toEqual(new Map([
+            ['.git/blah', ['value']],
+            ['.git/config', ['value1', 'value2']],
+         ]))
+      });
+
+      it('ignores properties with mismatched key', async () => {
+         const task = git.getConfig('some.prop');
+         await closeWithSuccess('file:.git/blah\0other.prop\nvalue\0file:.git/config\0some.prop\nvalue1\0file:.git/config\0other.prop\nvalue2\0');
+         const {value, values, scopes} = await task;
+
+         expect(value).toBe('value1');
+         expect(values).toEqual(['value1']);
+         expect(scopes).toEqual(new Map([
+            ['.git/config', ['value1']],
+         ]))
+      });
+
+      it('gets a single item', async () => {
+         const task = git.getConfig('foo');
+         await closeWithSuccess(`file:/Users/me/.gitconfig\0foo
+bar\0file:.git/config\0foo
+baz\0`);
+
+         expect(await task).toEqual(like({
+            key: 'foo',
+            value: 'baz',
+            values: ['bar', 'baz'],
+            paths: ['/Users/me/.gitconfig', '.git/config'],
+         }));
+         assertExecutedCommands('config', '--null', '--show-origin', '--get-all', 'foo');
+      });
+
+      it('gets a single item in a specific scope', async () => {
+         const task = git.getConfig('user.email', 'local');
+         await closeWithSuccess(`file:.git/config\0user.email
+another@mydev.co\0file:.git/config\0user.email
+final@mydev.co\0`);
+
+         expect(await task).toEqual(like({
+            value: 'final@mydev.co',
+            values: ['another@mydev.co', 'final@mydev.co'],
+            paths: ['.git/config'],
+         }));
+         assertExecutedCommands('config', '--local', '--null', '--show-origin', '--get-all', 'user.email');
+      });
+
+      it('allows callbacks when getting a single item', async () => {
+         const callback = jest.fn();
+         git.getConfig('foo', GitConfigScope.system, callback);
+         await closeWithSuccess(`file:/Users/me/.gitconfig\0foo\nbar\0\n\n`);
+
+         expect(callback).toHaveBeenCalledWith(null, like({value: 'bar'}));
+      });
+   });
+
 });