Skip to content

Commit 653065e

Browse files
authored
feat git-grep (#688)
feat git-grep Add support for `git.grep(searchTerm)` to list files matching a search term / terms.
1 parent 45887dc commit 653065e

File tree

11 files changed

+430
-4
lines changed

11 files changed

+430
-4
lines changed

examples/git-grep.md

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## Git Grep
2+
3+
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).
4+
5+
The simplest version is to search with a single search token:
6+
7+
```typescript
8+
import simpleGit from 'simple-git';
9+
10+
console.log(await simpleGit().grep('search-term'));
11+
```
12+
13+
To search with multiple terms, use the `grepQueryBuilder` helper to construct the remaining arguments:
14+
15+
```typescript
16+
import simpleGit, { grepQueryBuilder } from 'simple-git';
17+
18+
// logs all files that contain `aaa` AND either `bbb` or `ccc`
19+
console.log(
20+
await simpleGit().grep(grepQueryBuilder('aaa').and('bbb', 'ccc'))
21+
);
22+
```
23+
24+
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:
25+
26+
```typescript
27+
console.log(Array.from(grepQueryBuilder('aaa').and('bbb', 'ccc')))
28+
// [ '-e', 'aaa', '--and', '(', '-e', 'bbb', '-e', 'ccc', ')' ]
29+
```
30+
31+
To build your own query instead of using the `grepQueryBuilder`, use the array form of [options](../readme.md#how-to-specify-options):
32+
33+
```typescript
34+
import simpleGit from 'simple-git';
35+
36+
console.log(await simpleGit().grep('search-term', ['-e', 'another search term']));
37+
```
38+
39+
`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.
40+

readme.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Simple Git
22
[![NPM version](https://img.shields.io/npm/v/simple-git.svg)](https://www.npmjs.com/package/simple-git)
3-
[![Build Status](https://travis-ci.org/steveukx/git-js.svg?branch=master)](https://travis-ci.org/steveukx/git-js)
43

54
A lightweight interface for running `git` commands in any [node.js](https://nodejs.org) application.
65

@@ -251,6 +250,11 @@ For type details of the response for each of the tasks, please see the [TypeScri
251250
- `.listConfig()` reads the current configuration and returns a [ConfigListSummary](./src/lib/responses/ConfigList.ts)
252251
- `.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)
253252

253+
## git grep [examples](./examples/git-grep.md)
254+
255+
- `.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
256+
- `.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
257+
254258
## git hash-object
255259

256260
- `.hashObject(filePath, write = false)` computes the object ID value for the contents of the named file (which can be

src/lib/api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TaskConfigurationError } from './errors/task-configuration-error';
66
import { CheckRepoActions } from './tasks/check-is-repo';
77
import { CleanOptions } from './tasks/clean';
88
import { GitConfigScope } from './tasks/config';
9+
import { grepQueryBuilder } from './tasks/grep';
910
import { ResetMode } from './tasks/reset';
1011

1112
const api = {
@@ -18,6 +19,7 @@ const api = {
1819
GitResponseError,
1920
ResetMode,
2021
TaskConfigurationError,
22+
grepQueryBuilder,
2123
}
2224

2325
export default api;

src/lib/simple-git-api.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { SimpleGitBase } from '../../typings';
22
import { taskCallback } from './task-callback';
33
import { changeWorkingDirectoryTask } from './tasks/change-working-directory';
44
import config from './tasks/config';
5+
import grep from './tasks/grep';
56
import { hashObjectTask } from './tasks/hash-object';
67
import { initTask } from './tasks/init';
78
import log from './tasks/log';
@@ -122,4 +123,4 @@ export class SimpleGitApi implements SimpleGitBase {
122123
}
123124
}
124125

125-
Object.assign(SimpleGitApi.prototype, config(), log());
126+
Object.assign(SimpleGitApi.prototype, config(), grep(), log());

src/lib/tasks/grep.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { GrepResult, SimpleGit } from '../../../typings';
2+
import { SimpleGitApi } from '../simple-git-api';
3+
import {
4+
asNumber,
5+
forEachLineWithContent,
6+
getTrailingOptions,
7+
NULL,
8+
prefixedArray,
9+
trailingFunctionArgument
10+
} from '../utils';
11+
12+
import { configurationErrorTask } from './task';
13+
14+
const disallowedOptions = ['-h'];
15+
16+
const Query = Symbol('grepQuery');
17+
18+
export interface GitGrepQuery extends Iterable<string> {
19+
/** Adds one or more terms to be grouped as an "and" to any other terms */
20+
and(...and: string[]): this;
21+
22+
/** Adds one or more search terms - git.grep will "or" this to other terms */
23+
param(...param: string[]): this;
24+
}
25+
26+
class GrepQuery implements GitGrepQuery {
27+
private [Query]: string[] = [];
28+
29+
* [Symbol.iterator]() {
30+
for (const query of this[Query]) {
31+
yield query;
32+
}
33+
}
34+
35+
and(...and: string[]) {
36+
and.length && this[Query].push('--and', '(', ...prefixedArray(and, '-e'), ')');
37+
return this;
38+
}
39+
40+
param(...param: string[]) {
41+
this[Query].push(...prefixedArray(param, '-e'));
42+
return this;
43+
}
44+
}
45+
46+
/**
47+
* Creates a new builder for a `git.grep` query with optional params
48+
*/
49+
export function grepQueryBuilder(...params: string[]): GitGrepQuery {
50+
return new GrepQuery().param(...params);
51+
}
52+
53+
function parseGrep(grep: string): GrepResult {
54+
const paths: GrepResult['paths'] = new Set<string>();
55+
const results: GrepResult['results'] = {};
56+
57+
forEachLineWithContent(grep, (input) => {
58+
const [path, line, preview] = input.split(NULL);
59+
paths.add(path);
60+
(results[path] = results[path] || []).push({
61+
line: asNumber(line),
62+
path,
63+
preview,
64+
});
65+
});
66+
67+
return {
68+
paths,
69+
results,
70+
};
71+
}
72+
73+
export default function (): Pick<SimpleGit, 'grep'> {
74+
return {
75+
grep(this: SimpleGitApi, searchTerm: string | GitGrepQuery) {
76+
const then = trailingFunctionArgument(arguments);
77+
const options = getTrailingOptions(arguments);
78+
79+
for (const option of disallowedOptions) {
80+
if (options.includes(option)) {
81+
return this._runTask(
82+
configurationErrorTask(`git.grep: use of "${option}" is not supported.`),
83+
then,
84+
);
85+
}
86+
}
87+
88+
if (typeof searchTerm === 'string') {
89+
searchTerm = grepQueryBuilder().param(searchTerm);
90+
}
91+
92+
const commands = ['grep', '--null', '-n', '--full-name', ...options, ...searchTerm];
93+
94+
return this._runTask({
95+
commands,
96+
format: 'utf-8',
97+
parser(stdOut) {
98+
return parseGrep(stdOut);
99+
},
100+
}, then);
101+
}
102+
}
103+
}

src/lib/utils/util.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { exists, FOLDER } from '@kwsites/file-exists';
22
import { Maybe } from '../types';
33

4+
export const NULL = '\0';
5+
46
export const NOOP: (...args: any[]) => void = () => {
57
};
68

test/integration/grep.spec.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { createTestContext, newSimpleGit, SimpleGitTestContext } from '../__fixtures__';
2+
import { grepQueryBuilder } from '../..';
3+
4+
describe('grep', () => {
5+
6+
let context: SimpleGitTestContext;
7+
8+
beforeEach(async () => {
9+
context = await createTestContext();
10+
await setUpFiles(context);
11+
});
12+
13+
it('finds tracked files matching a string', async () => {
14+
const result = await newSimpleGit(context.root).grep('foo');
15+
16+
expect(result).toEqual({
17+
paths: new Set(['foo/bar.txt']),
18+
results: {
19+
'foo/bar.txt': [
20+
{line: 4, path: 'foo/bar.txt', preview: ' foo/bar'},
21+
],
22+
},
23+
});
24+
});
25+
26+
it('finds tracked files matching multiple strings', async () => {
27+
// finds all instances of `line` when there is also either `one` or `two` on the line
28+
// ie: doesn't find `another line`
29+
const result = await newSimpleGit(context.root).grep(
30+
grepQueryBuilder('line').and('one', 'two')
31+
);
32+
33+
expect(result).toEqual({
34+
paths: new Set(['a/aaa.txt', 'foo/bar.txt']),
35+
results: {
36+
'a/aaa.txt': [
37+
{line: 1, path: 'a/aaa.txt', preview: 'something on line one'},
38+
{line: 2, path: 'a/aaa.txt', preview: 'this is line two'},
39+
],
40+
'foo/bar.txt': [
41+
{line: 1, path: 'foo/bar.txt', preview: 'something on line one'},
42+
{line: 2, path: 'foo/bar.txt', preview: 'this is line two'},
43+
],
44+
},
45+
});
46+
});
47+
48+
it('finds multiple tracked files matching a string', async () => {
49+
const result = await newSimpleGit(context.root).grep('something');
50+
51+
expect(result).toEqual({
52+
paths: new Set(['a/aaa.txt', 'foo/bar.txt']),
53+
results: {
54+
'foo/bar.txt': [
55+
{line: 1, path: 'foo/bar.txt', preview: 'something on line one'},
56+
],
57+
'a/aaa.txt': [
58+
{line: 1, path: 'a/aaa.txt', preview: 'something on line one'},
59+
],
60+
},
61+
});
62+
});
63+
64+
it('finds multiple tracked files matching any string', async () => {
65+
const result = await newSimpleGit(context.root).grep(
66+
grepQueryBuilder('something', 'foo')
67+
);
68+
69+
expect(result).toEqual({
70+
paths: new Set(['a/aaa.txt', 'foo/bar.txt']),
71+
results: {
72+
'foo/bar.txt': [
73+
{line: 1, path: 'foo/bar.txt', preview: 'something on line one'},
74+
{line: 4, path: 'foo/bar.txt', preview: ' foo/bar'},
75+
],
76+
'a/aaa.txt': [
77+
{line: 1, path: 'a/aaa.txt', preview: 'something on line one'},
78+
],
79+
},
80+
});
81+
});
82+
83+
it('can be used to find the matching lines count per file without line detail', async () => {
84+
const result = await newSimpleGit(context.root).grep('line', {'-c': null});
85+
86+
expect(result).toEqual({
87+
paths: new Set(['a/aaa.txt', 'foo/bar.txt']),
88+
results: {
89+
'foo/bar.txt': [
90+
{line: 3, path: 'foo/bar.txt'},
91+
],
92+
'a/aaa.txt': [
93+
{line: 3, path: 'a/aaa.txt'},
94+
],
95+
},
96+
});
97+
});
98+
99+
it('also finds untracked files on request', async () => {
100+
const result = await newSimpleGit(context.root).grep('foo', {'--untracked': null});
101+
102+
expect(result).toEqual({
103+
paths: new Set(['foo/bar.txt', 'foo/baz.txt']),
104+
results: {
105+
'foo/bar.txt': [
106+
{line: 4, path: 'foo/bar.txt', preview: ' foo/bar'},
107+
],
108+
'foo/baz.txt': [
109+
{line: 4, path: 'foo/baz.txt', preview: ' foo/baz'},
110+
],
111+
},
112+
});
113+
});
114+
115+
});
116+
117+
async function setUpFiles(context: SimpleGitTestContext) {
118+
const content = `something on line one\nthis is line two\n another line `;
119+
120+
await context.git.init();
121+
122+
// tracked files
123+
await context.file(['foo', 'bar.txt'], `${content}\n foo/bar `);
124+
await context.file(['a', 'aaa.txt'], `${content}\n a/aaa `);
125+
await context.git.add('*');
126+
127+
// untracked files
128+
await context.file(['foo', 'baz.txt'], `${content}\n foo/baz `);
129+
await context.file(['a', 'bbb.txt'], `${content}\n a/bbb `);
130+
}

0 commit comments

Comments
 (0)