Skip to content

Commit bc6a37f

Browse files
committed
Closes #4723 adds ref & range to commit search
- Adds a `ref:` search operator to filter commits by specific references (branches, tags, SHAs) or commit ranges (e.g., `main..feature`, `HEAD~5..HEAD`) - Enhances the Git search command with a dedicated quick pick and button for selecting references or inputting ranges - Adds natural language search understanding by documenting the `ref:` operator in AI prompts
1 parent 9927631 commit bc6a37f

File tree

10 files changed

+108
-22
lines changed

10 files changed

+108
-22
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
99
### Added
1010

1111
- Adds a new _Safe Hard Reset_ (`--keep`) option to Git _reset_ command
12+
- Adds support for reference or range commit searches on the _Commit Graph_, _Search & Compare_ view, and in the _Search Commits_ command ([#4723](https://github.com/gitkraken/vscode-gitlens/issues/4723))
13+
- Adds natural language support to allow for more powerful queries
1214

1315
### Changed
1416

src/commands/git/search.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { showContributorsPicker } from '../../quickpicks/contributorsPicker';
1212
import type { QuickPickItemOfT } from '../../quickpicks/items/common';
1313
import { ActionQuickPickItem, createQuickPickSeparator } from '../../quickpicks/items/common';
1414
import { isDirectiveQuickPickItem } from '../../quickpicks/items/directive';
15+
import { ReferencesQuickPickIncludes, showReferencePicker2 } from '../../quickpicks/referencePicker';
1516
import { configuration } from '../../system/-webview/configuration';
1617
import { getContext } from '../../system/-webview/context';
1718
import { first, join, map } from '../../system/iterable';
@@ -53,6 +54,11 @@ const UseFolderPickerQuickInputButton: QuickInputButton = {
5354
tooltip: 'Pick Folder',
5455
};
5556

57+
const UseRefPickerQuickInputButton: QuickInputButton = {
58+
iconPath: new ThemeIcon('git-branch'),
59+
tooltip: 'Pick Reference',
60+
};
61+
5662
interface Context {
5763
container: Container;
5864
repos: Repository[];
@@ -94,6 +100,8 @@ const searchOperatorToTitleMap = new Map<SearchOperators, string>([
94100
['since:', 'Search After Date'],
95101
['before:', 'Search Before Date'],
96102
['until:', 'Search Before Date'],
103+
['^:', 'Search by Reference or Range'],
104+
['ref:', 'Search by Reference or Range'],
97105
]);
98106

99107
type SearchStepState<T extends State = State> = ExcludeSome<StepState<T>, 'repo', string>;
@@ -299,23 +307,30 @@ export class SearchGitCommand extends QuickCommand<State> {
299307
const items: QuickPickItemOfT<Items>[] = [
300308
{
301309
label: searchOperatorToTitleMap.get('')!,
302-
description: `pattern or message: pattern or =: pattern ${GlyphChars.Dash} use quotes to search for phrases`,
310+
description: `<message> or message:<message> or =:<message> ${GlyphChars.Dash} use quotes to search for phrases`,
303311
alwaysShow: true,
304312
item: { type: 'add', operator: 'message:' },
305313
},
306314
{
307315
label: searchOperatorToTitleMap.get('author:')!,
308-
description: 'author: pattern or @: pattern',
316+
description: 'author:<author> or @:<author>',
309317
buttons: [UseAuthorPickerQuickInputButton],
310318
alwaysShow: true,
311319
item: { type: 'add', operator: 'author:' },
312320
},
313321
{
314322
label: searchOperatorToTitleMap.get('commit:')!,
315-
description: 'commit: sha or #: sha',
323+
description: '<sha> or commit:<sha> or #:<sha>',
316324
alwaysShow: true,
317325
item: { type: 'add', operator: 'commit:' },
318326
},
327+
{
328+
label: searchOperatorToTitleMap.get('ref:')!,
329+
description: 'ref:<ref> or ^:<ref> (supports ranges like main..feature)',
330+
buttons: [UseRefPickerQuickInputButton],
331+
alwaysShow: true,
332+
item: { type: 'add', operator: 'ref:' },
333+
},
319334
];
320335

321336
if (!context.hasVirtualFolders) {
@@ -428,6 +443,8 @@ export class SearchGitCommand extends QuickCommand<State> {
428443
state,
429444
context,
430445
);
446+
} else if (button === UseRefPickerQuickInputButton) {
447+
await updateSearchQuery(item.item.operator, { ref: true }, quickpick, step, state, context);
431448
}
432449

433450
return false;
@@ -516,7 +533,7 @@ export class SearchGitCommand extends QuickCommand<State> {
516533

517534
async function updateSearchQuery(
518535
operator: SearchOperatorsLongForm,
519-
usePickers: { author?: boolean; file?: { type: 'file' | 'folder' } },
536+
usePickers: { author?: boolean; file?: { type: 'file' | 'folder' }; ref?: boolean },
520537
quickpick: QuickPick<any>,
521538
step: QuickPickStep,
522539
state: SearchStepState,
@@ -594,9 +611,30 @@ async function updateSearchQuery(
594611
append = true;
595612
}
596613

597-
if (files == null || files.size === 0) {
614+
if (!files?.size) {
598615
ops.delete('file:');
599616
}
617+
} else if (usePickers?.ref && operator === 'ref:') {
618+
using _frozen = step.freeze?.();
619+
620+
const refs = ops.get('ref:');
621+
622+
const pick = await showReferencePicker2(
623+
state.repo.path,
624+
'Search by Reference or Range',
625+
'Choose a reference to search',
626+
{
627+
allowedAdditionalInput: { range: true, rev: false },
628+
include: ReferencesQuickPickIncludes.All,
629+
picked: refs && first(refs),
630+
},
631+
);
632+
633+
if (pick.value != null) {
634+
ops.set('ref:', new Set([pick.value.ref]));
635+
} else {
636+
append = true;
637+
}
600638
} else {
601639
const values = ops.get(operator);
602640
append = !values?.has('');

src/commands/quickCommand.steps.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,7 @@ export function getValidateGitReferenceFn(
525525

526526
if (!getSettledValue(leftResult, false) || !getSettledValue(rightResult, false)) {
527527
quickpick.items = [
528-
createDirectiveQuickPickItem(Directive.Noop, true, {
529-
label: `Invalid Range: ${value}`,
530-
}),
528+
createDirectiveQuickPickItem(Directive.Noop, true, { label: `Invalid Range: ${value}` }),
531529
];
532530
return true;
533531
}

src/constants.search.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type SearchOperatorsShortForm = '' | '=:' | '@:' | '#:' | '?:' | '~:' | 'is:' | '>:' | '<:';
1+
type SearchOperatorsShortForm = '' | '=:' | '@:' | '#:' | '?:' | '~:' | 'is:' | '>:' | '<:' | '^:';
22
export type SearchOperatorsLongForm =
33
| 'message:'
44
| 'author:'
@@ -9,7 +9,8 @@ export type SearchOperatorsLongForm =
99
| 'after:'
1010
| 'since:'
1111
| 'before:'
12-
| 'until:';
12+
| 'until:'
13+
| 'ref:';
1314
export type SearchOperators = SearchOperatorsShortForm | SearchOperatorsLongForm;
1415

1516
export const searchOperators = new Set<string>([
@@ -32,6 +33,8 @@ export const searchOperators = new Set<string>([
3233
'<:',
3334
'before:',
3435
'until:',
36+
'^:',
37+
'ref:',
3538
]);
3639

3740
export const searchOperatorsToLongFormMap = new Map<SearchOperators, SearchOperatorsLongForm>([
@@ -54,10 +57,12 @@ export const searchOperatorsToLongFormMap = new Map<SearchOperators, SearchOpera
5457
['<:', 'before:'],
5558
['before:', 'before:'],
5659
['until:', 'before:'],
60+
['^:', 'ref:'],
61+
['ref:', 'ref:'],
5762
]);
5863

5964
export const searchOperationHelpRegex =
60-
/(?:^|(\b|\s)*)((=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:|is:|type:|>:|after:|since:|<:|before:|until:)(?:"[^"]*"?|\w*))(?:$|(\b|\s))/g;
65+
/(?:^|(\b|\s)*)((=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:|is:|type:|>:|after:|since:|<:|before:|until:|\^:|ref:)(?:"[^"]*"?|[^\s]*))(?:$|(\b|\s))/g;
6166

6267
export interface SearchQuery {
6368
query: string;

src/env/node/git/sub-providers/commits.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,6 @@ export class CommitsGitSubProvider implements GitCommitsSubProvider {
10411041
const similarityThreshold = configuration.get('advanced.similarityThreshold');
10421042
const args = [
10431043
'log',
1044-
10451044
...parser.arguments,
10461045
`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`,
10471046
'--use-mailmap',
@@ -1055,7 +1054,8 @@ export class CommitsGitSubProvider implements GitCommitsSubProvider {
10551054
if (shas?.size) {
10561055
stdin = join(shas, '\n');
10571056
args.push('--no-walk');
1058-
} else {
1057+
} else if (!filters.refs) {
1058+
// Don't include stashes when using ref: filter, as they would add unrelated commits
10591059
// TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes
10601060
({ stdin, stashes } = convertStashesToStdin(
10611061
await this.provider.stash?.getStash(repoPath, undefined, cancellation),

src/env/node/git/sub-providers/graph.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,11 +663,14 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
663663
args.push('--no-walk');
664664

665665
remappedIds = new Map();
666-
} else {
666+
} else if (!filters.refs) {
667+
// Don't include stashes when using ref: filter, as they would add unrelated commits
667668
// TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes
668669
({ stdin, stashes, remappedIds } = convertStashesToStdin(
669670
await this.provider.stash?.getStash(repoPath, undefined, cancellation),
670671
));
672+
} else {
673+
remappedIds = new Map();
671674
}
672675

673676
if (stdin) {

src/git/search.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Source } from '../constants.telemetry';
55
import type { Container } from '../container';
66
import type { NaturalLanguageSearchOptions } from '../plus/search/naturalLanguageSearchProcessor';
77
import { NaturalLanguageSearchProcessor } from '../plus/search/naturalLanguageSearchProcessor';
8+
import { some } from '../system/iterable';
89
import type { GitRevisionReference } from './models/reference';
910
import type { GitUser } from './models/user';
1011
import { isSha, shortenRevision } from './utils/revision.utils';
@@ -185,6 +186,8 @@ export interface SearchQueryFilters {
185186
files: boolean;
186187
/** Specifies whether the search results will be filtered to a specific type, only `stash` is supported */
187188
type?: 'stash';
189+
/** Specifies whether the search results will be filtered to a specific ref or ref range */
190+
refs: boolean;
188191
}
189192

190193
export interface SearchQueryCommand {
@@ -207,6 +210,7 @@ export function parseSearchQueryCommand(search: SearchQuery, currentUser: GitUse
207210
const filters: SearchQueryFilters = {
208211
files: false,
209212
type: undefined,
213+
refs: false,
210214
};
211215

212216
let op;
@@ -225,10 +229,6 @@ export function parseSearchQueryCommand(search: SearchQuery, currentUser: GitUse
225229
shas = searchArgs;
226230
} else {
227231
searchArgs.add('--all');
228-
searchArgs.add(search.matchRegex ? '--extended-regexp' : '--fixed-strings');
229-
if (search.matchRegex && !search.matchCase) {
230-
searchArgs.add('--regexp-ignore-case');
231-
}
232232

233233
for ([op, values] of operations.entries()) {
234234
switch (op) {
@@ -355,6 +355,31 @@ export function parseSearchQueryCommand(search: SearchQuery, currentUser: GitUse
355355

356356
break;
357357
}
358+
359+
case 'ref:':
360+
for (let value of values) {
361+
if (!value) continue;
362+
363+
if (value.startsWith('"') && value.endsWith('"')) {
364+
value = value.slice(1, -1);
365+
if (!value) continue;
366+
}
367+
368+
filters.refs = true;
369+
// Replace --all with the specific ref or ref range
370+
searchArgs.delete('--all');
371+
searchArgs.add(value);
372+
}
373+
374+
break;
375+
}
376+
}
377+
378+
// Add regex/string matching flags if we have (--grep, --author) patterns
379+
if (some(searchArgs.values(), arg => arg.startsWith('--grep=') || arg.startsWith('--author='))) {
380+
searchArgs.add(search.matchRegex ? '--extended-regexp' : '--fixed-strings');
381+
if (search.matchRegex && !search.matchCase) {
382+
searchArgs.add('--regexp-ignore-case');
358383
}
359384
}
360385
}

src/plus/ai/prompts.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,14 +351,18 @@ Available search operators:
351351
- 'file:' - Search by file path (e.g. 'file:"package.json"', 'file:"*.ts"'); maps to \`git log -- <value>\`
352352
- 'change:' - Search by specific code changes using regular expressions (e.g. 'change:"function.*auth"', 'change:"import.*react"'); maps to \`git log -G<value>\`
353353
- 'type:' - Search by type -- only stash is currently supported (e.g. 'type:stash')
354+
- 'ref:' - Search for commits reachable by a reference (branch, tag, commit) or reference range. Supports single refs (e.g. 'ref:main', 'ref:v1.0'), two-dot ranges (e.g. 'ref:main..feature' for commits in feature but not in main), three-dot ranges (e.g. 'ref:main...feature' for symmetric difference), and relative refs (e.g. 'ref:HEAD~5..HEAD'); maps to \`git log <ref>\`
354355
- 'after:' - Search for commits after a certain date or range (e.g. 'after:2023-01-01', 'after:"6 months ago"', 'after:"last Tuesday"', 'after:"noon"', 'after:"1 month 2 days ago"'); maps to \`git log --since=<value>\`
355356
- 'before:' - Search for commits before a certain date or range (e.g. 'before:2023-01-01', 'before:"6 months ago"', 'before:"yesterday"', 'before:"3PM GMT"'); maps to \`git log --until=<value>\`
356357
357-
File and change values should be double-quoted. You can use multiple message, author, file, and change operators at the same time if needed.
358+
File and change values should be double-quoted. You can use multiple message, author, file, change, and ref operators at the same time if needed.
359+
360+
Use 'ref:' when the query involves exploring commit history within or between specific references. Use temporal operators ('after:', 'before:') for date-based filtering. These operators can be combined when appropriate.
361+
362+
IMPORTANT: When "after" or "since" is used with a reference (branch, tag, commit SHA), it refers to commit ancestry, not time. Use ref ranges (e.g., 'ref:v1.0..HEAD' for "commits after tag v1.0"). Only use 'after:' for actual dates or time expressions.
363+
364+
Temporal queries leverage Git's 'approxidate' parser, which understands relative date expressions like "yesterday", "5 minutes ago", "1 month 2 days ago", "last Tuesday", "noon", and explicit timezones like "3PM GMT".
358365
359-
Temporal queries should be converted to appropriate after and/or before operators, leveraging Git's powerful 'approxidate' parser, which understands a wide array of human-centric relative date expressions, including simple terms ("yesterday", "5 minutes ago"), combinations of time units ("1 month 2 days ago"), days of the week ("last Tuesday"), named times ("noon"), and explicit timezones ("3PM GMT").
360-
For specific temporal ranges, e.g. commits made last week, or commits in the last month, use the 'after:' and 'before:' operators with appropriate relative values or calculate absolute dates, using the current date provided below.
361-
For ambiguous time periods like "this week" or "this month", prefer simple relative expressions like "1 week ago" or absolute dates using the current date provided below.
362366
363367
The current date is \${date}
364368
\${context}

src/plus/integrations/providers/github/utils/-webview/search.utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export async function getQueryArgsFromSearchQuery(
6464
case 'type:':
6565
case 'file:':
6666
case 'change:':
67+
case 'ref:':
6768
// Not supported in GitHub search
6869
break;
6970

src/webviews/apps/shared/components/search/search-input.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,11 @@ export class GlSearchInput extends GlElement {
640640
return html`<span>Author: use a user's account, e.g. <code>author:eamodio</code></span>`;
641641
case 'commit:':
642642
return html`<span>Commit: use a full or short Commit SHA, e.g. <code>commit:4ce3a</code></span>`;
643+
case 'ref:':
644+
return html`<span
645+
>Ref: use a reference (branch, tag, etc) or reference range, e.g. <code>ref:main</code> or
646+
<code>ref:main..feature</code></span
647+
>`;
643648
case 'type:':
644649
return html`<span
645650
>Type: use <code>stash</code> to search only stashes, e.g. <code>type:stash</code></span
@@ -710,6 +715,11 @@ export class GlSearchInput extends GlElement {
710715
Commit SHA <small>commit: or #:</small>
711716
</button>
712717
</menu-item>
718+
<menu-item role="none">
719+
<button class="menu-button" type="button" @click="${() => this.handleInsertToken('ref:')}">
720+
Ref <small>ref: or ^:</small>
721+
</button>
722+
</menu-item>
713723
<menu-item role="none">
714724
<button class="menu-button" type="button" @click="${() => this.handleInsertToken('type:stash')}">
715725
Type <small>type:stash or is:stash</small>

0 commit comments

Comments
 (0)