Skip to content

feat git-grep #688

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Oct 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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