diff --git a/.changeset/config.json b/.changeset/config.json index 5eeee38e..8ef84729 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,5 +6,7 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [ + "@simple-git/test-utils" + ] } diff --git a/.changeset/smooth-roses-laugh.md b/.changeset/smooth-roses-laugh.md new file mode 100644 index 00000000..13f637b2 --- /dev/null +++ b/.changeset/smooth-roses-laugh.md @@ -0,0 +1,5 @@ +--- +'simple-git': minor +--- + +Create a utility to append pathspec / file lists to tasks through the TaskOptions array/object diff --git a/simple-git/readme.md b/simple-git/readme.md index 2eb311f9..c056fd89 100644 --- a/simple-git/readme.md +++ b/simple-git/readme.md @@ -688,10 +688,12 @@ If the `simple-git` api doesn't explicitly limit the scope of the task being run be added, but `git.status()` will run against the entire repo), add a `pathspec` to the command using trailing options: ```typescript +import { simpleGit, pathspec } from "simple-git"; + const git = simpleGit(); const wholeRepoStatus = await git.status(); -const subDirStatusUsingOptArray = await git.status(['--', 'sub-dir']); -const subDirStatusUsingOptObject = await git.status({ '--': null, 'sub-dir': null }); +const subDirStatusUsingOptArray = await git.status([pathspec('sub-dir')]); +const subDirStatusUsingOptObject = await git.status({ 'sub-dir': pathspec('sub-dir') }); ``` ### async await diff --git a/simple-git/src/lib/api.ts b/simple-git/src/lib/api.ts index ddae0ae9..a73e26d3 100644 --- a/simple-git/src/lib/api.ts +++ b/simple-git/src/lib/api.ts @@ -1,3 +1,4 @@ +import { pathspec } from './args/pathspec'; import { GitConstructError } from './errors/git-construct-error'; import { GitError } from './errors/git-error'; import { GitPluginError } from './errors/git-plugin-error'; @@ -20,4 +21,5 @@ export { ResetMode, TaskConfigurationError, grepQueryBuilder, + pathspec, }; diff --git a/simple-git/src/lib/args/pathspec.ts b/simple-git/src/lib/args/pathspec.ts new file mode 100644 index 00000000..abc26c2a --- /dev/null +++ b/simple-git/src/lib/args/pathspec.ts @@ -0,0 +1,16 @@ +const cache = new WeakMap(); + +export function pathspec(...paths: string[]) { + const key = new String(paths); + cache.set(key, paths); + + return key as string; +} + +export function isPathSpec(path: string | unknown): path is string { + return path instanceof String && cache.has(path); +} + +export function toPaths(pathSpec: string): string[] { + return cache.get(pathSpec) || []; +} diff --git a/simple-git/src/lib/git-factory.ts b/simple-git/src/lib/git-factory.ts index a00f6188..8a780e82 100644 --- a/simple-git/src/lib/git-factory.ts +++ b/simple-git/src/lib/git-factory.ts @@ -13,6 +13,7 @@ import { spawnOptionsPlugin, timeoutPlugin, } from './plugins'; +import { suffixPathsPlugin } from './plugins/suffix-paths.plugin'; import { createInstanceConfig, folderExists } from './utils'; import { SimpleGitOptions } from './types'; @@ -57,6 +58,7 @@ export function gitInstanceFactory( } plugins.add(blockUnsafeOperationsPlugin(config.unsafe)); + plugins.add(suffixPathsPlugin()); plugins.add(completionDetectionPlugin(config.completion)); config.abort && plugins.add(abortPlugin(config.abort)); config.progress && plugins.add(progressMonitorPlugin(config.progress)); diff --git a/simple-git/src/lib/plugins/suffix-paths.plugin.ts b/simple-git/src/lib/plugins/suffix-paths.plugin.ts new file mode 100644 index 00000000..1181f487 --- /dev/null +++ b/simple-git/src/lib/plugins/suffix-paths.plugin.ts @@ -0,0 +1,34 @@ +import { SimpleGitPlugin } from './simple-git-plugin'; +import { isPathSpec, toPaths } from '../args/pathspec'; + +export function suffixPathsPlugin(): SimpleGitPlugin<'spawn.args'> { + return { + type: 'spawn.args', + action(data) { + const prefix: string[] = []; + const suffix: string[] = []; + + for (let i = 0; i < data.length; i++) { + const param = data[i]; + + if (isPathSpec(param)) { + suffix.push(...toPaths(param)); + continue; + } + + if (param === '--') { + suffix.push( + ...data + .slice(i + 1) + .flatMap((item) => (isPathSpec(item) && toPaths(item)) || item) + ); + break; + } + + prefix.push(param); + } + + return !suffix.length ? prefix : [...prefix, '--', ...suffix.map(String)]; + }, + }; +} diff --git a/simple-git/src/lib/utils/argument-filters.ts b/simple-git/src/lib/utils/argument-filters.ts index 794c1045..7c81c43f 100644 --- a/simple-git/src/lib/utils/argument-filters.ts +++ b/simple-git/src/lib/utils/argument-filters.ts @@ -1,5 +1,6 @@ import { Maybe, Options, Primitives } from '../types'; import { objectToString } from './util'; +import { isPathSpec } from '../args/pathspec'; export interface ArgumentFilterPredicate { (input: any): input is T; @@ -25,9 +26,11 @@ export function filterPrimitives( input: unknown, omit?: Array<'boolean' | 'string' | 'number'> ): input is Primitives { + const type = isPathSpec(input) ? 'string' : typeof input; + return ( - /number|string|boolean/.test(typeof input) && - (!omit || !omit.includes(typeof input as 'boolean' | 'string' | 'number')) + /number|string|boolean/.test(type) && + (!omit || !omit.includes(type as 'boolean' | 'string' | 'number')) ); } diff --git a/simple-git/src/lib/utils/task-options.ts b/simple-git/src/lib/utils/task-options.ts index 4cb27feb..956ba811 100644 --- a/simple-git/src/lib/utils/task-options.ts +++ b/simple-git/src/lib/utils/task-options.ts @@ -7,6 +7,7 @@ import { } from './argument-filters'; import { asFunction, isUserFunction, last } from './util'; import { Maybe, Options, OptionsValues } from '../types'; +import { isPathSpec } from '../args/pathspec'; export function appendTaskOptions( options: Maybe, @@ -19,7 +20,9 @@ export function appendTaskOptions( return Object.keys(options).reduce((commands: string[], key: string) => { const value: OptionsValues = options[key]; - if (filterPrimitives(value, ['boolean'])) { + if (isPathSpec(value)) { + commands.push(value); + } else if (filterPrimitives(value, ['boolean'])) { commands.push(key + '=' + value); } else { commands.push(key); diff --git a/simple-git/test/integration/grep.spec.ts b/simple-git/test/integration/grep.spec.ts index 6d1d8caa..496aebd5 100644 --- a/simple-git/test/integration/grep.spec.ts +++ b/simple-git/test/integration/grep.spec.ts @@ -1,5 +1,6 @@ import { createTestContext, newSimpleGit, SimpleGitTestContext } from '@simple-git/test-utils'; import { grepQueryBuilder } from '../..'; +import { pathspec } from '../../src/lib/args/pathspec'; describe('grep', () => { let context: SimpleGitTestContext; @@ -92,6 +93,20 @@ describe('grep', () => { }, }); }); + + it('limits within a set of paths', async () => { + const result = await newSimpleGit(context.root).grep('foo', { + '--untracked': null, + 'paths': pathspec('foo/bar.txt'), + }); + + expect(result).toEqual({ + paths: new Set(['foo/bar.txt']), + results: { + 'foo/bar.txt': [{ line: 4, path: 'foo/bar.txt', preview: ' foo/bar' }], + }, + }); + }); }); async function setUpFiles(context: SimpleGitTestContext) { diff --git a/simple-git/test/unit/grep.spec.ts b/simple-git/test/unit/grep.spec.ts index 1eee37a6..ec74b5c5 100644 --- a/simple-git/test/unit/grep.spec.ts +++ b/simple-git/test/unit/grep.spec.ts @@ -9,6 +9,7 @@ import { import { grepQueryBuilder, TaskConfigurationError } from '../..'; import { NULL } from '../../src/lib/utils'; +import { pathspec } from '../../src/lib/args/pathspec'; describe('grep', () => { describe('grepQueryBuilder', () => { @@ -130,5 +131,51 @@ another/file.txt${NULL}4${NULL}food content assertExecutedCommands('grep', '--null', '-n', '--full-name', '--c', '-e', 'a', '-e', 'b'); expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); }); + + it('appends paths provided as a pathspec in array TaskOptions', async () => { + const queue = newSimpleGit().grep(grepQueryBuilder('a', 'b'), [ + pathspec('path/to'), + '--c', + ]); + await closeWithSuccess(`file.txt${NULL}2${NULL}some foo content`); + + assertExecutedCommands( + 'grep', + '--null', + '-n', + '--full-name', + '--c', + '-e', + 'a', + '-e', + 'b', + '--', + 'path/to' + ); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + it('appends paths provided as a pathspec in object TaskOptions', async () => { + const queue = newSimpleGit().grep(grepQueryBuilder('a', 'b'), { + '--c': null, + 'paths': pathspec('path/to'), + }); + await closeWithSuccess(`file.txt${NULL}2${NULL}some foo content`); + + assertExecutedCommands( + 'grep', + '--null', + '-n', + '--full-name', + '--c', + '-e', + 'a', + '-e', + 'b', + '--', + 'path/to' + ); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); }); }); diff --git a/simple-git/test/unit/plugin.pathspec.spec.ts b/simple-git/test/unit/plugin.pathspec.spec.ts new file mode 100644 index 00000000..b7b70bb8 --- /dev/null +++ b/simple-git/test/unit/plugin.pathspec.spec.ts @@ -0,0 +1,56 @@ +import { SimpleGit } from '../../typings'; +import { assertExecutedCommands, closeWithSuccess, newSimpleGit } from './__fixtures__'; +import { pathspec } from '../../src/lib/args/pathspec'; + +describe('suffixPathsPlugin', function () { + let git: SimpleGit; + + beforeEach(() => (git = newSimpleGit())); + + it('moves pathspec to end', async () => { + git.raw(['a', pathspec('b'), 'c']); + await closeWithSuccess(); + + assertExecutedCommands('a', 'c', '--', 'b'); + }); + + it('moves multiple pathspecs to end', async () => { + git.raw(['a', pathspec('b'), 'c', pathspec('d'), 'e']); + await closeWithSuccess(); + + assertExecutedCommands('a', 'c', 'e', '--', 'b', 'd'); + }); + + it('ignores processing after a pathspec split', async () => { + git.raw('a', pathspec('b'), '--', 'c', pathspec('d'), 'e'); + await closeWithSuccess(); + + assertExecutedCommands('a', '--', 'b', 'c', 'd', 'e'); + }); + + it('flattens pathspecs after an explicit splitter', async () => { + git.raw('a', '--', 'b', pathspec('c', 'd'), 'e'); + await closeWithSuccess(); + + assertExecutedCommands('a', '--', 'b', 'c', 'd', 'e'); + }); + + it('accepts multiple paths in one pathspec argument', async () => { + git.raw('a', pathspec('b', 'c'), 'd'); + await closeWithSuccess(); + + assertExecutedCommands('a', 'd', '--', 'b', 'c'); + }); + + it('accepted as value of an option', async () => { + git.pull({ + foo: null, + blah1: pathspec('a', 'b'), + blah2: pathspec('c', 'd'), + bar: null, + }); + + await closeWithSuccess(); + assertExecutedCommands('pull', 'foo', 'bar', '--', 'a', 'b', 'c', 'd'); + }); +}); diff --git a/simple-git/typings/types.d.ts b/simple-git/typings/types.d.ts index 92c5430e..f294c82d 100644 --- a/simple-git/typings/types.d.ts +++ b/simple-git/typings/types.d.ts @@ -10,6 +10,7 @@ export type { SimpleGitTaskCallback, } from '../src/lib/types'; +export { pathspec } from '../src/lib/args/pathspec'; export type { ApplyOptions } from '../src/lib/tasks/apply-patch'; export { CheckRepoActions } from '../src/lib/tasks/check-is-repo'; export { CleanOptions, CleanMode } from '../src/lib/tasks/clean';