Skip to content

Commit

Permalink
feat git-grep (#688)
Browse files Browse the repository at this point in the history
feat git-grep Add support for `git.grep(searchTerm)` to list files matching a search term / terms.
  • Loading branch information
steveukx authored Oct 18, 2021
1 parent 45887dc commit 653065e
Show file tree
Hide file tree
Showing 11 changed files with 430 additions and 4 deletions.
40 changes: 40 additions & 0 deletions examples/git-grep.md
Original file line number Diff line number Diff line change
@@ -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.

6 changes: 5 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -18,6 +19,7 @@ const api = {
GitResponseError,
ResetMode,
TaskConfigurationError,
grepQueryBuilder,
}

export default api;
3 changes: 2 additions & 1 deletion src/lib/simple-git-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -122,4 +123,4 @@ export class SimpleGitApi implements SimpleGitBase {
}
}

Object.assign(SimpleGitApi.prototype, config(), log());
Object.assign(SimpleGitApi.prototype, config(), grep(), log());
103 changes: 103 additions & 0 deletions src/lib/tasks/grep.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
/** 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<string>();
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<SimpleGit, 'grep'> {
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);
}
}
}
2 changes: 2 additions & 0 deletions src/lib/utils/util.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
};

Expand Down
130 changes: 130 additions & 0 deletions test/integration/grep.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `);
}
Loading

0 comments on commit 653065e

Please sign in to comment.