Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ github-code-search query "TODO" --org my-org
- **Per-repository aggregation** — results grouped by repo, not as a flat list; fold/unfold each repo to focus on what matters
- **Keyboard-driven TUI** — navigate with arrow keys, toggle selections, filter by file path, confirm with Enter — without leaving the terminal
- **Fine-grained selection** — pick exactly the repos and extracts you want; deselected items are recorded as exclusions in the replay command
- **Structured output** — clean Markdown lists with GitHub links, or machine-readable JSON — ready to paste into docs, issues or scripts
- **Structured output** — Markdown document with a `# Results for` query heading, GitHub deeplinks and the exact matched token per extract; or machine-readable JSON with `matchedText`, `line` and `col` fields — ready to paste into docs, issues or scripts
- **Team-prefix grouping** — group results by team prefix (e.g. `platform/`, `data/`) using `--group-by-team-prefix`
- **Replay command** — every session produces a one-liner you can run in CI to reproduce the exact same selection without the UI
- **Regex search** — use `/pattern/flags` syntax for pattern-based searches; the CLI derives a safe API query and filters results locally
- **Syntax highlighting** — code fragments rendered with language-aware coloring (TypeScript, Python, Go, Rust, YAML, JSON and more)

## Use cases
Expand Down Expand Up @@ -111,6 +112,7 @@ The official [`gh` CLI](https://cli.github.com/) does support `gh search code`,
| Markdown / JSON output | ✗ | ✓ |
| Replay / CI command | ✗ | ✓ |
| Team-prefix grouping | ✗ | ✓ |
| Regex search | ✗ | ✓ |
| Syntax highlighting in terminal | ✗ | ✓ |
| Pagination (up to 1 000 results) | ✓ | ✓ |

Expand Down
12 changes: 7 additions & 5 deletions docs/usage/non-interactive-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,18 @@ $ CI=true github-code-search "useFeatureFlag" --org fulll
```

```text
# Results for "useFeatureFlag"
3 repos · 5 files selected
- **fulll/auth-service** (2 matches)
- [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2)
- [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1)
- [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2): `useFeatureFlag`
- [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1): `useFeatureFlag`
- **fulll/billing-api** (2 matches)
- [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3)
- [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1)
- [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3): `useFeatureFlag`
- [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1): `useFeatureFlag`
- **fulll/frontend-app** (1 match)
- [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1)
- [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1): `useFeatureFlag`
```
<details>
Expand Down
31 changes: 25 additions & 6 deletions docs/usage/output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ github-code-search "useFeatureFlag" --org fulll --format markdown --no-interacti
```

```text
# Results for "useFeatureFlag"

3 repos · 4 files selected

- **fulll/auth-service** (2 matches)
- [ ] [src/middlewares/featureFlags.ts:2:19](...)
- [ ] [tests/unit/featureFlags.test.ts:1:8](...)
- [ ] [src/middlewares/featureFlags.ts:2:19](...): `useFeatureFlag`
- [ ] [tests/unit/featureFlags.test.ts:1:8](...): `useFeatureFlag`
- **fulll/billing-api** (1 match)
- [ ] [src/flags.ts:3:14](...)
- [ ] [src/flags.ts:3:14](...): `useFeatureFlag`
- **fulll/frontend-app** (1 match)
- [ ] [src/hooks/useFeatureFlag.ts:1:1](...)
- [ ] [src/hooks/useFeatureFlag.ts:1:1](...): `useFeatureFlag`
```

::: details replay command
Expand All @@ -30,7 +32,21 @@ github-code-search "useFeatureFlag" --org fulll --no-interactive

:::

Each extract link points directly to the matching line on GitHub.
Each extract link points directly to the matching line on GitHub. When the GitHub API returns the exact matched token, it is appended inline in backticks (e.g. `` : `useFeatureFlag` ``).

## Query title

Every output (both Markdown and repo-only) is prefixed with a `# Results for` heading that identifies the search query. When active qualifiers are present, they are appended after a `·` separator:

```text
# Results for "useFeatureFlag" · including archived · excluding templates
```

In [regex mode](/usage/search-syntax#regex-mode), the pattern is shown in backticks:

```text
# Results for `/useFeatureFlag/i`
```

## `--format json`

Expand All @@ -53,7 +69,8 @@ github-code-search "useFeatureFlag" --org fulll --format json --no-interactive
"path": "src/middlewares/featureFlags.ts",
"url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2",
"line": 2,
"col": 19
"col": 19,
"matchedText": "useFeatureFlag"
}
]
}
Expand Down Expand Up @@ -83,6 +100,8 @@ github-code-search "useFeatureFlag" --org fulll \
```

```text
# Results for "useFeatureFlag"

fulll/auth-service
fulll/billing-api
fulll/frontend-app
Expand Down
127 changes: 125 additions & 2 deletions src/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildJsonOutput,
buildMarkdownOutput,
buildOutput,
buildQueryTitle,
buildReplayCommand,
buildReplayDetails,
shortExtractRef,
Expand Down Expand Up @@ -266,6 +267,63 @@ describe("buildReplayDetails", () => {
});
});

describe("buildQueryTitle", () => {
it("wraps a plain query in double quotes", () => {
expect(buildQueryTitle("useFlag")).toBe('# Results for "useFlag"');
});

it("wraps a regex query in backticks", () => {
expect(buildQueryTitle("/useFlag/i")).toBe("# Results for `/useFlag/i`");
});

it("appends · including archived when includeArchived is true", () => {
expect(buildQueryTitle("useFlag", { includeArchived: true })).toBe(
'# Results for "useFlag" · including archived',
);
});

it("appends · excluding templates when excludeTemplates is true", () => {
expect(buildQueryTitle("useFlag", { excludeTemplates: true })).toBe(
'# Results for "useFlag" · excluding templates',
);
});

it("appends both qualifiers in order when both are set", () => {
expect(buildQueryTitle("useFlag", { includeArchived: true, excludeTemplates: true })).toBe(
'# Results for "useFlag" · including archived · excluding templates',
);
});

it("omits qualifiers when neither option is set", () => {
expect(buildQueryTitle("useFlag", { includeArchived: false, excludeTemplates: false })).toBe(
'# Results for "useFlag"',
);
});

it("handles a plain query with embedded double quotes without breaking the heading", () => {
const title = buildQueryTitle('from "axios"');
// JSON.stringify escapes the embedded quotes — heading stays on one line
expect(title.startsWith("# Results for ")).toBe(true);
expect(title).not.toContain("\n");
expect(title).toContain("axios");
});

it("handles a plain query with a newline without breaking the heading", () => {
const title = buildQueryTitle("line1\nline2");
// JSON.stringify converts \n to \\n — no actual newline in the heading
expect(title.startsWith("# Results for ")).toBe(true);
expect(title).not.toContain("\n");
});

it("handles a regex query with an embedded backtick without producing malformed inline code", () => {
const title = buildQueryTitle("/foo`bar/i");
expect(title.startsWith("# Results for ")).toBe(true);
expect(title).not.toContain("\n");
// Variable-length fence: at least two consecutive backticks used as delimiter
expect(title).toContain("``");
});
});

describe("buildMarkdownOutput", () => {
it("includes selected repo and file link", () => {
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
Expand All @@ -274,6 +332,12 @@ describe("buildMarkdownOutput", () => {
expect(out).toContain("[src/foo.ts](https://github.com/myorg/repoA/blob/main/src/foo.ts)");
});

it("starts with # Results for query H1 heading", () => {
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
expect(out.split("\n")[0]).toBe(`# Results for "${QUERY}"`);
});

it("renders repo as bold bullet", () => {
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
Expand Down Expand Up @@ -388,12 +452,24 @@ describe("buildMarkdownOutput", () => {
expect(out).toBe("");
});

it("repo-only mode prepends H1 query title before repo list", () => {
const groups = [makeGroup("myorg/repoA", ["src/a.ts"])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only");
expect(out).toContain(`# Results for "${QUERY}"`);
const lines = out.split("\n");
expect(lines[0]).toBe(`# Results for "${QUERY}"`);
expect(out.indexOf(`# Results for "${QUERY}"`)).toBeLessThan(out.indexOf("myorg/repoA"));
});

it("prepends selection summary line in repo-and-matches mode", () => {
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
expect(out).toContain("selected");
const firstLine = out.split("\n")[0];
expect(firstLine).toContain("selected");
const lines = out.split("\n");
// first line is the H1 query title
expect(lines[0]).toMatch(/^# Results for /);
// third line (index 2) is the selection summary
expect(lines[2]).toContain("selected");
});

it("does not prepend selection summary in repo-only mode", () => {
Expand Down Expand Up @@ -502,6 +578,13 @@ describe("buildMarkdownOutput — line/col annotation", () => {
expect(out).not.toMatch(/\[src\/foo\.ts:\d+:\d+\]/);
});

it("appends matched token in backticks when location is available", () => {
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
expect(out).toContain("`snippet`");
expect(out).toContain("#L3): `snippet`");
});

it("does not add location suffix in repo-only mode", () => {
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])];
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only");
Expand All @@ -525,6 +608,46 @@ describe("buildJsonOutput — line/col fields", () => {
expect(parsed.results[0].matches[0].line).toBeUndefined();
expect(parsed.results[0].matches[0].col).toBeUndefined();
});

it("includes matchedText in match when text match is present", () => {
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 2, col: 8 }])];
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
expect(parsed.results[0].matches[0].matchedText).toBe("snippet");
});

it("omits matchedText when no text matches", () => {
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
});

it("omits matchedText when seg.text is an empty string", () => {
// api.ts normalizes missing segment text to "" — matchedText must not be emitted
const groups: RepoGroup[] = [
{
repoFullName: "myorg/repoA",
matches: [
{
path: "src/foo.ts",
repoFullName: "myorg/repoA",
htmlUrl: "https://github.com/myorg/repoA/blob/main/src/foo.ts",
archived: false,
textMatches: [
{
fragment: "some snippet",
matches: [{ text: "", indices: [0, 0], line: 1, col: 1 }],
},
],
},
],
folded: true,
repoSelected: true,
extractSelected: [true],
},
];
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
});
});

// ─── buildOutput dispatcher ──────────────────────────────────────────────────
Expand Down
58 changes: 56 additions & 2 deletions src/output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildSelectionSummary } from "./render.ts";
import { isRegexQuery } from "./regex.ts";
import type { OutputFormat, OutputType, RepoGroup } from "./types.ts";

// ─── Short-form helpers ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -150,6 +151,45 @@ export function buildReplayDetails(
].join("\n");
}

// ─── Markdown inline-code helper ────────────────────────────────────────────

/**
* Wraps `s` in a Markdown inline-code span using a backtick fence long enough
* to safely contain any backticks already present in `s` (CommonMark §6.1).
* Adds surrounding spaces when `s` starts or ends with a backtick character.
*/
function mdInlineCode(s: string): string {
const runs = [...s.matchAll(/`+/g)].map((m) => m[0].length);
const maxRun = runs.length > 0 ? Math.max(...runs) : 0;
const fence = "`".repeat(maxRun + 1);
const padded = s.startsWith("`") || s.endsWith("`") ? ` ${s} ` : s;
return `${fence}${padded}${fence}`;
}

// ─── Query title ─────────────────────────────────────────────────────────────

/**
* Builds a first-level heading that identifies the query and any active
* qualifiers (e.g. `--include-archived`, `--exclude-template-repositories`).
*
* Examples:
* # Results for "useFlag"
* # Results for `/useFlag/i`
* # Results for "axios" · including archived · excluding templates
*/
export function buildQueryTitle(query: string, options: ReplayOptions = {}): string {
// JSON.stringify handles embedded double quotes and converts newlines to \n
// so the heading always stays on a single line.
// mdInlineCode uses a variable-length backtick fence to safely display regex
// patterns that may themselves contain backtick characters.
const queryDisplay = isRegexQuery(query) ? mdInlineCode(query) : JSON.stringify(query);
const qualifiers: string[] = [];
if (options.includeArchived) qualifiers.push("including archived");
if (options.excludeTemplates) qualifiers.push("excluding templates");
const suffix = qualifiers.length > 0 ? ` · ${qualifiers.join(" · ")}` : "";
return `# Results for ${queryDisplay}${suffix}`;
}

// ─── Selected matches helper ─────────────────────────────────────────────────

function selectedMatches(group: RepoGroup) {
Expand All @@ -174,6 +214,8 @@ export function buildMarkdownOutput(
.map((g) => g.repoFullName);
if (repos.length === 0) return "";
return (
buildQueryTitle(query, options) +
"\n\n" +
repos.join("\n") +
"\n\n" +
buildReplayDetails(groups, query, org, excludedRepos, excludedExtractRefs, options) +
Expand All @@ -183,6 +225,8 @@ export function buildMarkdownOutput(

const lines: string[] = [];

lines.push(buildQueryTitle(query, options));
lines.push("");
lines.push(buildSelectionSummary(groups));
lines.push("");

Expand All @@ -193,6 +237,7 @@ export function buildMarkdownOutput(

// Section header (emitted before the first repo in a new team section)
if (group.sectionLabel !== undefined) {
lines.push("");
lines.push(`## ${group.sectionLabel}`);
lines.push("");
}
Expand All @@ -206,7 +251,10 @@ export function buildMarkdownOutput(
// absolute line numbers).
const seg = m.textMatches[0]?.matches[0];
Comment on lines 248 to 253
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment says the segment position is fragment-relative, but api.ts now attempts to resolve absolute file line numbers by fetching raw content (falling back to fragment-relative when that fails). Updating this comment to reflect the current behavior would prevent confusion when interpreting seg.line/seg.col.

Copilot uses AI. Check for mistakes.
if (seg) {
lines.push(` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})`);
const matchedText = seg.text ? `: ${mdInlineCode(seg.text)}` : "";
lines.push(
` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})${matchedText}`,
);
} else {
lines.push(` - [ ] [${m.path}](${m.htmlUrl})`);
}
Expand Down Expand Up @@ -240,7 +288,13 @@ export function buildJsonOutput(
return {
path: m.path,
url: m.htmlUrl,
...(seg !== undefined ? { line: seg.line, col: seg.col } : {}),
...(seg !== undefined
? {
line: seg.line,
col: seg.col,
...(seg.text ? { matchedText: seg.text } : {}),
}
: {}),
};
});
return { ...base, matches };
Expand Down
Loading