diff --git a/README.md b/README.md index 45e57ca..7979ef5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,14 @@ github-code-search query "useFeatureFlag" --org my-org --group-by-team-prefix pl Get a team-scoped view of every usage site before refactoring a shared hook or utility. +**Skip template repositories** + +```bash +github-code-search query "TODO" --org my-org --exclude-template-repositories +``` + +Exclude repositories that are marked as GitHub templates, so boilerplate repos don't clutter your results. + **Regex search — pattern-based code audit** ```bash diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index 9e7d08b..ecd4b8f 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -40,6 +40,7 @@ github-code-search completions [--shell ] | `--format ` | `markdown` \| `json` | ❌ | `markdown` | Output format. See [Output formats](/usage/output-formats). | | `--output-type ` | `repo-and-matches` \| `repo-only` | ❌ | `repo-and-matches` | Controls output detail level. `repo-only` lists repository names only, without individual extracts. | | `--include-archived` | boolean (flag) | ❌ | `false` | Include archived repositories in results (excluded by default). | +| `--exclude-template-repositories` | boolean (flag) | ❌ | `false` | Exclude template repositories from results (included by default). See [Filtering](/usage/filtering#--exclude-template-repositories). | | `--group-by-team-prefix ` | string | ❌ | `""` | Comma-separated team-name prefixes for grouping result repos by GitHub team (e.g. `squad-,chapter-`). Requires `read:org` scope. | | `--no-cache` | boolean (flag) | ❌ | `true` (on) | Bypass the 24 h team-list cache and re-fetch teams from GitHub. Cache is **on** by default; pass this flag to disable it. Only applies with `--group-by-team-prefix`. | | `--regex-hint ` | string | ❌ | — | Override the API search term used when the query is a regex (`/pattern/`). Useful when auto-extraction produces a term that is too broad or too narrow. See [Regex queries](/usage/search-syntax#regex-queries). | diff --git a/docs/usage/filtering.md b/docs/usage/filtering.md index b63349b..9fcfd2e 100644 --- a/docs/usage/filtering.md +++ b/docs/usage/filtering.md @@ -1,6 +1,6 @@ # Filtering -`github-code-search` provides three pre-query filtering options so you can exclude noise before results ever appear in the TUI or output. +`github-code-search` provides four result filtering options so you can exclude noise from what appears in the TUI or output. ## `--exclude-repositories` @@ -64,13 +64,26 @@ github-code-search "useFeatureFlag" --org fulll --include-archived Archived repos are silently filtered out in the aggregation step before the TUI is shown. This flag overrides that behaviour. ::: +## `--exclude-template-repositories` + +By default, template repositories are included in results. Pass `--exclude-template-repositories` to filter them out: + +```bash +github-code-search "useFeatureFlag" --org fulll --exclude-template-repositories +``` + +::: info +Template repositories (marked as templates on GitHub) are filtered out in the aggregation step — both in interactive and non-interactive mode. Useful when your organisation uses template repos for boilerplate that should not appear in search results. +::: + ## Combining filters -All three flags can be combined freely: +All four flags can be combined freely: ```bash github-code-search "useFeatureFlag" --org fulll \ --include-archived \ + --exclude-template-repositories \ --exclude-repositories legacy-monolith \ --exclude-extracts billing-api:src/flags.ts:0 ``` diff --git a/github-code-search.ts b/github-code-search.ts index 9f13f00..6c66d6c 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -166,6 +166,11 @@ function addSearchOptions(cmd: Command): Command { "Include archived repositories in results (default: false)", false, ) + .option( + "--exclude-template-repositories", + "Exclude template repositories from results (default: false)", + false, + ) .option( "--group-by-team-prefix ", [ @@ -203,6 +208,7 @@ async function searchAction( format: string; outputType: string; includeArchived: boolean; + excludeTemplateRepositories: boolean; groupByTeamPrefix: string; cache: boolean; regexHint?: string; @@ -219,6 +225,7 @@ async function searchAction( const format: OutputFormat = opts.format === "json" ? "json" : "markdown"; const outputType: OutputType = opts.outputType === "repo-only" ? "repo-only" : "repo-and-matches"; const includeArchived = Boolean(opts.includeArchived); + const excludeTemplates = Boolean(opts.excludeTemplateRepositories); const excludedRepos = new Set( opts.excludeRepositories @@ -305,6 +312,7 @@ async function searchAction( excludedExtractRefs, includeArchived, regexFilter, + excludeTemplates, ); // ─── Team-prefix grouping ───────────────────────────────────────────────── @@ -327,6 +335,7 @@ async function searchAction( console.log( buildOutput(groups, query, org, excludedRepos, excludedExtractRefs, format, outputType, { includeArchived, + excludeTemplates, groupByTeamPrefix: opts.groupByTeamPrefix, regexHint: opts.regexHint, }), @@ -378,6 +387,7 @@ async function searchAction( format, outputType, includeArchived, + excludeTemplates, opts.groupByTeamPrefix, opts.regexHint ?? "", ); diff --git a/src/aggregate.test.ts b/src/aggregate.test.ts index 40081f1..efb78c0 100644 --- a/src/aggregate.test.ts +++ b/src/aggregate.test.ts @@ -40,12 +40,13 @@ describe("extractRef", () => { // ─── aggregate ─────────────────────────────────────────────────────────────── -function makeMatch(repo: string, path: string, archived = false): CodeMatch { +function makeMatch(repo: string, path: string, archived = false, isTemplate = false): CodeMatch { return { path, repoFullName: repo, htmlUrl: `https://github.com/${repo}/blob/main/${path}`, archived, + isTemplate, textMatches: [], }; } @@ -136,6 +137,26 @@ describe("aggregate", () => { const groups = aggregate(matches, new Set(), new Set()); expect(groups).toHaveLength(0); }); + + it("excludes template repos when excludeTemplates = true", () => { + const matches: CodeMatch[] = [ + makeMatch("myorg/repoA", "src/a.ts", false, false), + makeMatch("myorg/templateRepo", "src/b.ts", false, true), + ]; + const groups = aggregate(matches, new Set(), new Set(), false, null, true); + expect(groups).toHaveLength(1); + expect(groups[0].repoFullName).toBe("myorg/repoA"); + }); + + it("includes template repos when excludeTemplates = false (default)", () => { + const matches: CodeMatch[] = [ + makeMatch("myorg/repoA", "src/a.ts", false, false), + makeMatch("myorg/templateRepo", "src/b.ts", false, true), + ]; + const groups = aggregate(matches, new Set(), new Set()); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.repoFullName)).toContain("myorg/templateRepo"); + }); }); // ─── aggregate with regexFilter ─────────────────────────────────────────────── @@ -146,6 +167,7 @@ function makeMatchWithFragments(repo: string, path: string, fragments: string[]) repoFullName: repo, htmlUrl: `https://github.com/${repo}/blob/main/${path}`, archived: false, + isTemplate: false, textMatches: fragments.map((fragment) => ({ fragment, matches: [] })), }; } diff --git a/src/aggregate.ts b/src/aggregate.ts index f1fc662..4a73024 100644 --- a/src/aggregate.ts +++ b/src/aggregate.ts @@ -85,6 +85,7 @@ export function aggregate( excludedExtractRefs: Set, includeArchived = false, regexFilter?: RegExp | null, + excludeTemplates = false, ): RepoGroup[] { // Compile the global regex once per aggregate() call rather than once per // fragment inside recomputeSegments — avoids repeated RegExp construction @@ -97,6 +98,7 @@ export function aggregate( for (const m of matches) { if (excludedRepos.has(m.repoFullName)) continue; if (!includeArchived && m.archived) continue; + if (excludeTemplates && m.isTemplate === true) continue; // Fix: when a regex filter is active, replace each TextMatch's API-provided // segments (which point at the literal search term) with segments derived // from the actual regex match positions — see issue #111 / fix highlight bug diff --git a/src/api.ts b/src/api.ts index 3ee670b..ab7e2b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,7 +18,7 @@ interface RawTextMatch { interface RawCodeItem { path: string; html_url: string; - repository: { full_name: string; archived?: boolean }; + repository: { full_name: string; archived?: boolean; is_template?: boolean }; text_matches?: RawTextMatch[]; } @@ -277,6 +277,7 @@ export async function fetchAllResults( repoFullName: item.repository.full_name, htmlUrl: item.html_url, archived: item.repository.archived === true, + isTemplate: item.repository.is_template === true, textMatches: (item.text_matches ?? []).map((m) => { const fragment: string = m.fragment ?? ""; const fragmentStartLine = fileContent ? computeFragmentStartLine(fileContent, fragment) : 1; diff --git a/src/completions.ts b/src/completions.ts index 5b03447..bf6852f 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -57,6 +57,12 @@ const OPTIONS = [ takesArg: false, values: [], }, + { + flag: "exclude-template-repositories", + description: "Exclude template repositories", + takesArg: false, + values: [], + }, { flag: "no-cache", description: "Bypass the 24 h team-list cache", diff --git a/src/output.test.ts b/src/output.test.ts index 4e1e3a6..028b1c3 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -84,7 +84,7 @@ describe("buildReplayCommand", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); expect(cmd).toContain(`github-code-search`); - expect(cmd).toContain(`--org ${ORG}`); + expect(cmd).toContain(`--org '${ORG}'`); expect(cmd).toContain(`--no-interactive`); }); @@ -117,7 +117,7 @@ describe("buildReplayCommand", () => { }), ]; const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set()); - expect(cmd).toContain("--exclude-extracts repoA:b.ts:1"); + expect(cmd).toContain("--exclude-extracts 'repoA:b.ts:1'"); }); it("does not double-add pre-existing exclusions", () => { @@ -169,11 +169,25 @@ describe("buildReplayCommand", () => { expect(cmd).not.toContain("--include-archived"); }); + it("includes --exclude-template-repositories when excludeTemplates is true", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const opts: ReplayOptions = { excludeTemplates: true }; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).toContain("--exclude-template-repositories"); + }); + + it("does not include --exclude-template-repositories when excludeTemplates is false (default)", () => { + const groups = [makeGroup("myorg/repoA", ["a.ts"])]; + const opts: ReplayOptions = { excludeTemplates: false }; + const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); + expect(cmd).not.toContain("--exclude-template-repositories"); + }); + it("includes --group-by-team-prefix when groupByTeamPrefix is set", () => { const groups = [makeGroup("myorg/repoA", ["a.ts"])]; const opts: ReplayOptions = { groupByTeamPrefix: "squad-,chapter-" }; const cmd = buildReplayCommand(groups, QUERY, ORG, new Set(), new Set(), opts); - expect(cmd).toContain("--group-by-team-prefix squad-,chapter-"); + expect(cmd).toContain("--group-by-team-prefix 'squad-,chapter-'"); }); it("does not include --group-by-team-prefix when groupByTeamPrefix is empty (default)", () => { @@ -567,6 +581,6 @@ describe("buildOutput", () => { groupByTeamPrefix: "squad-", }); const parsed = JSON.parse(out); - expect(parsed.replayCommand).toContain("--group-by-team-prefix squad-"); + expect(parsed.replayCommand).toContain("--group-by-team-prefix 'squad-'"); }); }); diff --git a/src/output.ts b/src/output.ts index 45e73ef..a1dffc5 100644 --- a/src/output.ts +++ b/src/output.ts @@ -32,6 +32,7 @@ export interface ReplayOptions { format?: OutputFormat; outputType?: OutputType; includeArchived?: boolean; + excludeTemplates?: boolean; groupByTeamPrefix?: string; /** When set, appends `--regex-hint ` to the replay command so the * result set from a regex query can be reproduced exactly. */ @@ -49,8 +50,11 @@ export function buildReplayCommand( // Fix: forward all input options so the replay command is fully reproducible — see issue #11 options: ReplayOptions = {}, ): string { - const { format, outputType, includeArchived, groupByTeamPrefix, regexHint } = options; - const parts: string[] = [`github-code-search ${shellQuote(query)} --org ${org} --no-interactive`]; + const { format, outputType, includeArchived, excludeTemplates, groupByTeamPrefix, regexHint } = + options; + const parts: string[] = [ + `github-code-search ${shellQuote(query)} --org ${shellQuote(org)} --no-interactive`, + ]; const excludedReposList: string[] = [...excludedRepos].map((r) => shortRepo(r, org)); for (const group of groups) { @@ -78,7 +82,7 @@ export function buildReplayCommand( } } if (excludedExtractsList.length > 0) { - parts.push(`--exclude-extracts ${excludedExtractsList.join(",")}`); + parts.push(`--exclude-extracts ${shellQuote(excludedExtractsList.join(","))}`); } if (format && format !== "markdown") { @@ -90,8 +94,11 @@ export function buildReplayCommand( if (includeArchived) { parts.push("--include-archived"); } + if (excludeTemplates) { + parts.push("--exclude-template-repositories"); + } if (groupByTeamPrefix) { - parts.push(`--group-by-team-prefix ${groupByTeamPrefix}`); + parts.push(`--group-by-team-prefix ${shellQuote(groupByTeamPrefix)}`); } if (regexHint) { parts.push(`--regex-hint ${shellQuote(regexHint)}`); @@ -264,7 +271,10 @@ export function buildOutput( excludedExtractRefs: Set, format: OutputFormat, outputType: OutputType = "repo-and-matches", - extraOptions: Pick = {}, + extraOptions: Pick< + ReplayOptions, + "includeArchived" | "excludeTemplates" | "groupByTeamPrefix" | "regexHint" + > = {}, ): string { const options: ReplayOptions = { format, outputType, ...extraOptions }; if (format === "json") { diff --git a/src/tui.ts b/src/tui.ts index bb3209c..e2d9655 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -108,6 +108,7 @@ export async function runInteractive( format: OutputFormat, outputType: OutputType = "repo-and-matches", includeArchived = false, + excludeTemplates = false, groupByTeamPrefix = "", regexHint = "", ): Promise { @@ -371,6 +372,7 @@ export async function runInteractive( console.log( buildOutput(groups, query, org, excludedRepos, excludedExtractRefs, format, outputType, { includeArchived, + excludeTemplates, groupByTeamPrefix, regexHint: regexHint || undefined, }), diff --git a/src/types.ts b/src/types.ts index 8445103..3d4d23a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export interface CodeMatch { htmlUrl: string; textMatches: TextMatch[]; archived: boolean; + isTemplate?: boolean; } export interface RepoGroup {