diff --git a/examples/git-grep.md b/examples/git-grep.md new file mode 100644 index 00000000..7399235f --- /dev/null +++ b/examples/git-grep.md @@ -0,0 +1,40 @@ +## Git Grep + +The official documentation for [git grep](https://git-scm.com/docs/git-grep) gives the full set of options that can be passed to the `simple-git` `git.grep` method as [options](../readme.md#how-to-specify-options) (note that `-h` to hide the file name is disallowed). + +The simplest version is to search with a single search token: + +```typescript +import simpleGit from 'simple-git'; + +console.log(await simpleGit().grep('search-term')); +``` + +To search with multiple terms, use the `grepQueryBuilder` helper to construct the remaining arguments: + +```typescript +import simpleGit, { grepQueryBuilder } from 'simple-git'; + +// logs all files that contain `aaa` AND either `bbb` or `ccc` +console.log( + await simpleGit().grep(grepQueryBuilder('aaa').and('bbb', 'ccc')) +); +``` + +The builder interface is purely there to simplify the many `-e` flags needed to instruct `git` to treat an argument as a search term - the code above translates to: + +```typescript +console.log(Array.from(grepQueryBuilder('aaa').and('bbb', 'ccc'))) + // [ '-e', 'aaa', '--and', '(', '-e', 'bbb', '-e', 'ccc', ')' ] +``` + +To build your own query instead of using the `grepQueryBuilder`, use the array form of [options](../readme.md#how-to-specify-options): + +```typescript +import simpleGit from 'simple-git'; + +console.log(await simpleGit().grep('search-term', ['-e', 'another search term'])); +``` + +`git.grep` will include previews around the matched term in the resulting data, to disable this use options such as `-l` to only show the file name or `-c` to show the number of instances of a match in the file rather than the text that was matched. + diff --git a/readme.md b/readme.md index 50523dae..654003e5 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,5 @@ # Simple Git [![NPM version](https://img.shields.io/npm/v/simple-git.svg)](https://www.npmjs.com/package/simple-git) - [![Build Status](https://travis-ci.org/steveukx/git-js.svg?branch=master)](https://travis-ci.org/steveukx/git-js) A lightweight interface for running `git` commands in any [node.js](https://nodejs.org) application. @@ -251,6 +250,11 @@ For type details of the response for each of the tasks, please see the [TypeScri - `.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) +## git grep [examples](./examples/git-grep.md) + +- `.grep(searchTerm)` searches for a single search term across all files in the working tree, optionally passing a standard [options](#how-to-specify-options) object of additional arguments +- `.grep(grepQueryBuilder(...))` use the `grepQueryBuilder` to create a complex query to search for, optionally passing a standard [options](#how-to-specify-options) object of additional arguments + ## git hash-object - `.hashObject(filePath, write = false)` computes the object ID value for the contents of the named file (which can be diff --git a/src/lib/api.ts b/src/lib/api.ts index e8c182de..c05f1715 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,6 +6,7 @@ import { TaskConfigurationError } from './errors/task-configuration-error'; import { CheckRepoActions } from './tasks/check-is-repo'; import { CleanOptions } from './tasks/clean'; import { GitConfigScope } from './tasks/config'; +import { grepQueryBuilder } from './tasks/grep'; import { ResetMode } from './tasks/reset'; const api = { @@ -18,6 +19,7 @@ const api = { GitResponseError, ResetMode, TaskConfigurationError, + grepQueryBuilder, } export default api; diff --git a/src/lib/simple-git-api.ts b/src/lib/simple-git-api.ts index 68f7e416..67701279 100644 --- a/src/lib/simple-git-api.ts +++ b/src/lib/simple-git-api.ts @@ -2,6 +2,7 @@ import { SimpleGitBase } from '../../typings'; import { taskCallback } from './task-callback'; import { changeWorkingDirectoryTask } from './tasks/change-working-directory'; import config from './tasks/config'; +import grep from './tasks/grep'; import { hashObjectTask } from './tasks/hash-object'; import { initTask } from './tasks/init'; import log from './tasks/log'; @@ -122,4 +123,4 @@ export class SimpleGitApi implements SimpleGitBase { } } -Object.assign(SimpleGitApi.prototype, config(), log()); +Object.assign(SimpleGitApi.prototype, config(), grep(), log()); diff --git a/src/lib/tasks/grep.ts b/src/lib/tasks/grep.ts new file mode 100644 index 00000000..fe0e0544 --- /dev/null +++ b/src/lib/tasks/grep.ts @@ -0,0 +1,103 @@ +import { GrepResult, SimpleGit } from '../../../typings'; +import { SimpleGitApi } from '../simple-git-api'; +import { + asNumber, + forEachLineWithContent, + getTrailingOptions, + NULL, + prefixedArray, + trailingFunctionArgument +} from '../utils'; + +import { configurationErrorTask } from './task'; + +const disallowedOptions = ['-h']; + +const Query = Symbol('grepQuery'); + +export interface GitGrepQuery extends Iterable { + /** Adds one or more terms to be grouped as an "and" to any other terms */ + and(...and: string[]): this; + + /** Adds one or more search terms - git.grep will "or" this to other terms */ + param(...param: string[]): this; +} + +class GrepQuery implements GitGrepQuery { + private [Query]: string[] = []; + + * [Symbol.iterator]() { + for (const query of this[Query]) { + yield query; + } + } + + and(...and: string[]) { + and.length && this[Query].push('--and', '(', ...prefixedArray(and, '-e'), ')'); + return this; + } + + param(...param: string[]) { + this[Query].push(...prefixedArray(param, '-e')); + return this; + } +} + +/** + * Creates a new builder for a `git.grep` query with optional params + */ +export function grepQueryBuilder(...params: string[]): GitGrepQuery { + return new GrepQuery().param(...params); +} + +function parseGrep(grep: string): GrepResult { + const paths: GrepResult['paths'] = new Set(); + const results: GrepResult['results'] = {}; + + forEachLineWithContent(grep, (input) => { + const [path, line, preview] = input.split(NULL); + paths.add(path); + (results[path] = results[path] || []).push({ + line: asNumber(line), + path, + preview, + }); + }); + + return { + paths, + results, + }; +} + +export default function (): Pick { + return { + grep(this: SimpleGitApi, searchTerm: string | GitGrepQuery) { + const then = trailingFunctionArgument(arguments); + const options = getTrailingOptions(arguments); + + for (const option of disallowedOptions) { + if (options.includes(option)) { + return this._runTask( + configurationErrorTask(`git.grep: use of "${option}" is not supported.`), + then, + ); + } + } + + if (typeof searchTerm === 'string') { + searchTerm = grepQueryBuilder().param(searchTerm); + } + + const commands = ['grep', '--null', '-n', '--full-name', ...options, ...searchTerm]; + + return this._runTask({ + commands, + format: 'utf-8', + parser(stdOut) { + return parseGrep(stdOut); + }, + }, then); + } + } +} diff --git a/src/lib/utils/util.ts b/src/lib/utils/util.ts index 38734db3..594b2d37 100644 --- a/src/lib/utils/util.ts +++ b/src/lib/utils/util.ts @@ -1,6 +1,8 @@ import { exists, FOLDER } from '@kwsites/file-exists'; import { Maybe } from '../types'; +export const NULL = '\0'; + export const NOOP: (...args: any[]) => void = () => { }; diff --git a/test/integration/grep.spec.ts b/test/integration/grep.spec.ts new file mode 100644 index 00000000..bb8d5ce7 --- /dev/null +++ b/test/integration/grep.spec.ts @@ -0,0 +1,130 @@ +import { createTestContext, newSimpleGit, SimpleGitTestContext } from '../__fixtures__'; +import { grepQueryBuilder } from '../..'; + +describe('grep', () => { + + let context: SimpleGitTestContext; + + beforeEach(async () => { + context = await createTestContext(); + await setUpFiles(context); + }); + + it('finds tracked files matching a string', async () => { + const result = await newSimpleGit(context.root).grep('foo'); + + expect(result).toEqual({ + paths: new Set(['foo/bar.txt']), + results: { + 'foo/bar.txt': [ + {line: 4, path: 'foo/bar.txt', preview: ' foo/bar'}, + ], + }, + }); + }); + + it('finds tracked files matching multiple strings', async () => { + // finds all instances of `line` when there is also either `one` or `two` on the line + // ie: doesn't find `another line` + const result = await newSimpleGit(context.root).grep( + grepQueryBuilder('line').and('one', 'two') + ); + + expect(result).toEqual({ + paths: new Set(['a/aaa.txt', 'foo/bar.txt']), + results: { + 'a/aaa.txt': [ + {line: 1, path: 'a/aaa.txt', preview: 'something on line one'}, + {line: 2, path: 'a/aaa.txt', preview: 'this is line two'}, + ], + 'foo/bar.txt': [ + {line: 1, path: 'foo/bar.txt', preview: 'something on line one'}, + {line: 2, path: 'foo/bar.txt', preview: 'this is line two'}, + ], + }, + }); + }); + + it('finds multiple tracked files matching a string', async () => { + const result = await newSimpleGit(context.root).grep('something'); + + expect(result).toEqual({ + paths: new Set(['a/aaa.txt', 'foo/bar.txt']), + results: { + 'foo/bar.txt': [ + {line: 1, path: 'foo/bar.txt', preview: 'something on line one'}, + ], + 'a/aaa.txt': [ + {line: 1, path: 'a/aaa.txt', preview: 'something on line one'}, + ], + }, + }); + }); + + it('finds multiple tracked files matching any string', async () => { + const result = await newSimpleGit(context.root).grep( + grepQueryBuilder('something', 'foo') + ); + + expect(result).toEqual({ + paths: new Set(['a/aaa.txt', 'foo/bar.txt']), + results: { + 'foo/bar.txt': [ + {line: 1, path: 'foo/bar.txt', preview: 'something on line one'}, + {line: 4, path: 'foo/bar.txt', preview: ' foo/bar'}, + ], + 'a/aaa.txt': [ + {line: 1, path: 'a/aaa.txt', preview: 'something on line one'}, + ], + }, + }); + }); + + it('can be used to find the matching lines count per file without line detail', async () => { + const result = await newSimpleGit(context.root).grep('line', {'-c': null}); + + expect(result).toEqual({ + paths: new Set(['a/aaa.txt', 'foo/bar.txt']), + results: { + 'foo/bar.txt': [ + {line: 3, path: 'foo/bar.txt'}, + ], + 'a/aaa.txt': [ + {line: 3, path: 'a/aaa.txt'}, + ], + }, + }); + }); + + it('also finds untracked files on request', async () => { + const result = await newSimpleGit(context.root).grep('foo', {'--untracked': null}); + + expect(result).toEqual({ + paths: new Set(['foo/bar.txt', 'foo/baz.txt']), + results: { + 'foo/bar.txt': [ + {line: 4, path: 'foo/bar.txt', preview: ' foo/bar'}, + ], + 'foo/baz.txt': [ + {line: 4, path: 'foo/baz.txt', preview: ' foo/baz'}, + ], + }, + }); + }); + +}); + +async function setUpFiles(context: SimpleGitTestContext) { + const content = `something on line one\nthis is line two\n another line `; + + await context.git.init(); + + // tracked files + await context.file(['foo', 'bar.txt'], `${content}\n foo/bar `); + await context.file(['a', 'aaa.txt'], `${content}\n a/aaa `); + await context.git.add('*'); + + // untracked files + await context.file(['foo', 'baz.txt'], `${content}\n foo/baz `); + await context.file(['a', 'bbb.txt'], `${content}\n a/bbb `); +} diff --git a/test/unit/grep.spec.ts b/test/unit/grep.spec.ts new file mode 100644 index 00000000..01c2bf93 --- /dev/null +++ b/test/unit/grep.spec.ts @@ -0,0 +1,126 @@ +import { promiseError } from '@kwsites/promise-result'; + +import { assertGitError, newSimpleGit } from '../__fixtures__'; +import { assertExecutedCommands, closeWithSuccess } from './__fixtures__'; + +import { grepQueryBuilder, TaskConfigurationError } from '../..'; +import { NULL } from '../../src/lib/utils'; + +describe('grep', () => { + + describe('grepQueryBuilder', () => { + + it('-e NODE -e Unexpected', () => { + expect(Array.from(grepQueryBuilder('NODE', 'Unexpected'))) + .toEqual(['-e', 'NODE', '-e', 'Unexpected']); + }); + + it('-e #define --and ( -e MAX_PATH -e PATH_MAX )', () => { + let query = grepQueryBuilder('#define').and('MAX_PATH', 'PATH_MAX'); + + expect(Array.from(query)).toEqual([ + '-e', '#define', '--and', '(', '-e', 'MAX_PATH', '-e', 'PATH_MAX', ')' + ]); + }); + + }); + + describe('usage', () => { + const callback = jest.fn(); + + afterEach(() => callback.mockReset()); + + it('prevents using -h as an option', async () => { + const result = await promiseError(newSimpleGit().grep('hello', ['-h'])); + assertGitError(result, 'git.grep: use of "-h" is not supported', TaskConfigurationError); + }); + + it('single term with callback', async () => { + const queue = newSimpleGit().grep('foo', callback); + await closeWithSuccess(`file.txt${NULL}2`) + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '-e', 'foo'); + expect(callback).toHaveBeenCalledWith(null, await queue); + }); + + it('single term with options object and callback', async () => { + const queue = newSimpleGit().grep('foo', {'--foo': 'bar'}, callback); + await closeWithSuccess(`file.txt${NULL}2`) + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '--foo=bar', '-e', 'foo'); + expect(callback).toHaveBeenCalledWith(null, await queue); + }); + + it('single term with options array and callback', async () => { + const queue = newSimpleGit().grep('foo', ['boo'], callback); + await closeWithSuccess(`file.txt${NULL}2`) + + assertExecutedCommands('grep', '--null', '-n', '--full-name', 'boo', '-e', 'foo'); + expect(callback).toHaveBeenCalledWith(null, await queue); + }); + + it('awaits single term with options array', async () => { + const queue = newSimpleGit().grep('foo', ['--bar']); + await closeWithSuccess(`file.txt${NULL}2`) + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '--bar', '-e', 'foo'); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + it('awaits single term with options object', async () => { + const queue = newSimpleGit().grep('foo', {'-c': null}); + await closeWithSuccess(`file.txt${NULL}2`); + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '-c', '-e', 'foo'); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + it('awaits single search term', async () => { + const queue = newSimpleGit().grep('foo'); + await closeWithSuccess(` +path/to/file.txt${NULL}2${NULL}some foo content +another/file.txt${NULL}4${NULL}food content + `); + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '-e', 'foo'); + expect(await queue).toEqual({ + paths: new Set(['path/to/file.txt', 'another/file.txt']), + results: { + 'path/to/file.txt': [ + {line: 2, path: 'path/to/file.txt', preview: 'some foo content'}, + ], + 'another/file.txt': [ + {line: 4, path: 'another/file.txt', preview: 'food content'}, + ], + } + }) + }); + + it('awaits multiple search terms', async () => { + const queue = newSimpleGit().grep(grepQueryBuilder('a', 'b')); + await closeWithSuccess(`file.txt${NULL}2${NULL}some foo content`); + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '-e', 'a', '-e', 'b'); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + it('awaits multiple search terms with options object', async () => { + const queue = newSimpleGit().grep(grepQueryBuilder('a', 'b'), {'--c': null}); + await closeWithSuccess(`file.txt${NULL}2${NULL}some foo content`); + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '--c', '-e', 'a', '-e', 'b'); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + it('awaits multiple search terms with options array', async () => { + const queue = newSimpleGit().grep(grepQueryBuilder('a', 'b'), ['--c']); + await closeWithSuccess(`file.txt${NULL}2${NULL}some foo content`); + + assertExecutedCommands('grep', '--null', '-n', '--full-name', '--c', '-e', 'a', '-e', 'b'); + expect(await queue).toHaveProperty('paths', new Set(['file.txt'])); + }); + + }); + + +}) diff --git a/typings/response.d.ts b/typings/response.d.ts index e8fdb68e..27c2efbc 100644 --- a/typings/response.d.ts +++ b/typings/response.d.ts @@ -177,6 +177,16 @@ export interface FetchResult { }[]; } +/** Represents the response to git.grep */ +export interface GrepResult { + paths: Set; + results: Record>; +} + /** * The `InitResult` is returned when (re)initialising a git repo. */ @@ -211,7 +221,7 @@ export interface MoveResult { /** * Array of files moved */ - moves: Array<{from: string, to: string}>; + moves: Array<{ from: string, to: string }>; } export interface PullDetailFileChanges { diff --git a/typings/simple-git.d.ts b/typings/simple-git.d.ts index 2f3e920a..84062948 100644 --- a/typings/simple-git.d.ts +++ b/typings/simple-git.d.ts @@ -387,6 +387,13 @@ export interface SimpleGit extends SimpleGitBase { getRemotes(verbose: true, callback?: types.SimpleGitTaskCallback): Response; + /** + * Search for files matching the supplied search terms + */ + grep(searchTerm: string | types.GitGrepQuery, callback?: types.SimpleGitTaskCallback): Response; + + grep(searchTerm: string | types.GitGrepQuery, options?: types.TaskOptions, callback?: types.SimpleGitTaskCallback): Response; + /** * List remotes by running the `ls-remote` command with any number of arbitrary options * in either array of object form. diff --git a/typings/types.d.ts b/typings/types.d.ts index 6d73cfd3..ff3b894d 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -5,9 +5,10 @@ export { outputHandler, Options, TaskOptions, SimpleGitOptions, SimpleGitProgressEvent, SimpleGitTaskCallback } from '../src/lib/types'; +export { ApplyOptions } from '../src/lib/tasks/apply-patch'; export { CheckRepoActions } from '../src/lib/tasks/check-is-repo'; export { CleanOptions, CleanMode } from '../src/lib/tasks/clean'; export { CloneOptions } from '../src/lib/tasks/clone'; export { GitConfigScope } from '../src/lib/tasks/config'; -export { ApplyOptions } from '../src/lib/tasks/apply-patch'; +export { GitGrepQuery, grepQueryBuilder } from '../src/lib/tasks/grep'; export { ResetOptions, ResetMode } from '../src/lib/tasks/reset';