Skip to content

Commit f9a433b

Browse files
committed
[gobby-#316] feat: release gcode 0.9.8 grouped output
1 parent 8ccb517 commit f9a433b

12 files changed

Lines changed: 323 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## [0.9.8] — gcode
13+
14+
### Changed
15+
16+
#### gcode
17+
18+
- **Grouped text output**`gcode grep` and high-volume navigation outputs now
19+
reduce repeated file path prefixes by grouping text results under file or
20+
directory headers, while JSON output remains unchanged.
21+
- **Quiet symbol retrieval**`gcode outline`, `gcode symbol`, and
22+
`gcode symbols` no longer print savings banners to stderr.
23+
1224
## [0.9.7] — gcode
1325

1426
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/gcode/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-code"
3-
version = "0.9.7"
3+
version = "0.9.8"
44
edition = "2024"
55
rust-version = "1.88"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/gcode/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ gcode search --project /path/to/app "q" # By path
167167
--no-freshness # Skip read-time index/source freshness checks
168168
```
169169

170+
`gcode grep` defaults to grouped text output: each matched file is printed once,
171+
followed by line-numbered matches and context. Other high-volume text outputs,
172+
including `tree`, `callers`, `usages`, and `blast-radius`, also group repeated
173+
paths for compact agent-readable output. JSON output keeps the stable structured
174+
shape.
175+
170176
## AI CLI Skill Installation
171177

172178
For non-Gobby-managed projects, `gcode init` installs the bundled `gcode` skill

crates/gcode/assets/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This project is indexed. Use `gcode` via Bash for fast code search and navigatio
1313

1414
## Search
1515

16-
- `gcode grep "pattern" [PATH ...] -m 50` — exact indexed content grep over `code_content_chunks`; defaults to text output for bounded line matches
16+
- `gcode grep "pattern" [PATH ...] -m 50` — exact indexed content grep over `code_content_chunks`; defaults to grouped text output for bounded line matches
1717
- `gcode search "query" [PATH ...]` — hybrid search: pg_search BM25 + semantic + graph boost (best for fuzzy or natural-language queries)
1818
- `gcode search-symbol "name" [PATH ...]` — exact-first symbol lookup with deterministic ranking; add `--with-graph` to include FalkorDB graph neighbors when available
1919
- `gcode search-text "query" [PATH ...]` — pg_search BM25 search on symbol names, signatures, and docstrings
@@ -43,7 +43,7 @@ Search output is intentionally snippet-sized. Broad file reads and wide line ran
4343
## Navigation
4444

4545
- `gcode repo-outline` — high-level project summary with module symbol counts
46-
- `gcode tree` — whole-project file tree with symbol counts per file; it takes no path argument
46+
- `gcode tree` — whole-project file tree with symbol counts per file; text output groups files by directory and it takes no path argument
4747
- `gcode kinds` — list distinct symbol kinds in the index (helps pick `--kind` values)
4848

4949
For directory-focused exploration, use `gcode tree --format text` with shell filtering, or scope search commands with positional paths: `gcode search "query" crates/gcode/src docs/**/*.md`.
@@ -86,4 +86,4 @@ for the UI, but graph sync/read/lifecycle behavior lives in `gcode`.
8686

8787
## Output and global flags
8888

89-
`gcode grep` defaults to text output; use `--format json` when you need structured matches and spans. Other commands support `--format text` for human-readable output where available. Use `--quiet` to suppress warnings, and `--no-freshness` to skip the read-time staleness check (cheaper when you know the index is current).
89+
`gcode grep` defaults to grouped text output; use `--format json` when you need structured matches and spans. High-volume text outputs such as `tree`, `callers`, `usages`, and `blast-radius` group repeated paths under directory or file headers. Other commands support `--format text` for human-readable output where available. Use `--quiet` to suppress warnings, and `--no-freshness` to skip the read-time staleness check (cheaper when you know the index is current).

crates/gcode/src/commands/graph.rs

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
use std::collections::BTreeMap;
2+
13
use crate::config::Context;
24
use crate::db;
35
use crate::graph::code_graph::{
46
self, GraphBlastRadiusTarget, GraphLifecycleAction, GraphLifecycleOutput, GraphPayload,
57
};
68
use crate::graph::report::{ProjectGraphReport, ProjectGraphReportOptions};
9+
use crate::models::GraphResult;
710
use crate::models::PagedResponse;
811
use crate::output::{self, Format};
912
use crate::projection::sync::ProjectionSyncReport;
@@ -451,6 +454,34 @@ fn empty_paged_response<T: Serialize>(
451454
}
452455
}
453456

457+
fn format_grouped_graph_results<F>(results: &[GraphResult], format_line: F) -> String
458+
where
459+
F: Fn(&GraphResult) -> String,
460+
{
461+
let mut grouped: BTreeMap<&str, Vec<&GraphResult>> = BTreeMap::new();
462+
for result in results {
463+
grouped.entry(&result.file_path).or_default().push(result);
464+
}
465+
466+
let mut lines = Vec::new();
467+
for (file_path, mut entries) in grouped {
468+
lines.push(if file_path.is_empty() {
469+
"<unknown>".to_string()
470+
} else {
471+
file_path.to_string()
472+
});
473+
entries.sort_by(|a, b| {
474+
a.line
475+
.cmp(&b.line)
476+
.then_with(|| a.name.cmp(&b.name))
477+
.then_with(|| a.relation.cmp(&b.relation))
478+
.then_with(|| a.distance.cmp(&b.distance))
479+
});
480+
lines.extend(entries.into_iter().map(&format_line));
481+
}
482+
lines.join("\n")
483+
}
484+
454485
/// Resolve user input to a canonical symbol id, printing suggestions on ambiguity.
455486
/// Returns None and prints an error message if no match found.
456487
fn resolve_symbol(ctx: &Context, input: &str) -> Option<ResolvedGraphSymbol> {
@@ -539,12 +570,9 @@ pub fn callers(
539570
} else if results.is_empty() {
540571
eprintln!("No callers at offset {offset} (total {total})");
541572
} else {
542-
for r in &results {
543-
println!(
544-
"{}:{} {} -> {}",
545-
r.file_path, r.line, r.name, symbol.display_name
546-
);
547-
}
573+
output::print_text(&format_grouped_graph_results(&results, |r| {
574+
format!("{} {} -> {}", r.line, r.name, symbol.display_name)
575+
}))?;
548576
if total > offset + results.len() {
549577
eprintln!(
550578
"-- {} of {} results (use --offset {} for more)",
@@ -607,13 +635,10 @@ pub fn usages(
607635
} else if results.is_empty() {
608636
eprintln!("No usages at offset {offset} (total {total})");
609637
} else {
610-
for r in &results {
638+
output::print_text(&format_grouped_graph_results(&results, |r| {
611639
let rel = r.relation.as_deref().unwrap_or("unknown");
612-
println!(
613-
"{}:{} [{}] {} -> {}",
614-
r.file_path, r.line, rel, r.name, symbol.display_name
615-
);
616-
}
640+
format!("{} [{}] {} -> {}", r.line, rel, r.name, symbol.display_name)
641+
}))?;
617642
if total > offset + results.len() {
618643
eprintln!(
619644
"-- {} of {} results (use --offset {} for more)",
@@ -703,10 +728,10 @@ pub fn blast_radius(
703728
println!("No blast radius found for '{}'", symbol.display_name);
704729
print_graph_hint_text(ctx);
705730
} else {
706-
for r in &results {
731+
output::print_text(&format_grouped_graph_results(&results, |r| {
707732
let dist = r.distance.unwrap_or(0);
708-
println!("{}:{} [distance={}] {}", r.file_path, r.line, dist, r.name);
709-
}
733+
format!("{} [distance={}] {}", r.line, dist, r.name)
734+
}))?;
710735
}
711736
Ok(())
712737
}
@@ -738,7 +763,9 @@ mod tests {
738763
fn graph_reads_degrade_when_falkor_missing() {
739764
let ctx = make_ctx_no_falkordb();
740765

741-
imports(&ctx, "src/lib.rs", Format::Text).expect("imports degrade to empty output");
766+
let result = imports(&ctx, "src/lib.rs", Format::Text);
767+
768+
assert!(result.is_ok(), "imports should degrade cleanly: {result:?}");
742769
}
743770

744771
#[test]
@@ -759,6 +786,45 @@ mod tests {
759786
assert!(!text.trim_start().starts_with('#'));
760787
}
761788

789+
#[test]
790+
fn graph_text_groups_by_file_and_sorts_entries() {
791+
let results = vec![
792+
GraphResult {
793+
id: "b".to_string(),
794+
name: "beta".to_string(),
795+
file_path: "src/b.rs".to_string(),
796+
line: 9,
797+
relation: Some("CALLS".to_string()),
798+
distance: None,
799+
metadata: None,
800+
},
801+
GraphResult {
802+
id: "a2".to_string(),
803+
name: "zeta".to_string(),
804+
file_path: "src/a.rs".to_string(),
805+
line: 8,
806+
relation: Some("CALLS".to_string()),
807+
distance: None,
808+
metadata: None,
809+
},
810+
GraphResult {
811+
id: "a1".to_string(),
812+
name: "alpha".to_string(),
813+
file_path: "src/a.rs".to_string(),
814+
line: 3,
815+
relation: Some("CALLS".to_string()),
816+
distance: None,
817+
metadata: None,
818+
},
819+
];
820+
821+
let text = format_grouped_graph_results(&results, |result| {
822+
format!("{} {}", result.line, result.name)
823+
});
824+
825+
assert_eq!(text, "src/a.rs\n3 alpha\n8 zeta\nsrc/b.rs\n9 beta");
826+
}
827+
762828
#[test]
763829
fn report_requires_graph_service() {
764830
let ctx = make_ctx_no_falkordb();

crates/gcode/src/commands/grep.rs

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ fn load_indexed_chunks(
111111
) -> anyhow::Result<Vec<IndexedContentChunk>> {
112112
let rows = conn.query(
113113
"SELECT c.file_path,
114-
c.chunk_index::BIGINT AS chunk_index,
115114
c.line_start::BIGINT AS line_start,
116115
c.content
117116
FROM code_content_chunks c
@@ -328,29 +327,66 @@ fn format_text_matches(matches: &[GrepMatch]) -> String {
328327
let matching_lines: BTreeSet<(String, usize)> =
329328
matches.iter().map(|m| (m.path.clone(), m.line)).collect();
330329
let mut emitted_context = BTreeSet::new();
330+
let mut current_path: Option<&str> = None;
331331
let mut lines = Vec::new();
332332

333333
for item in matches {
334334
for context in &item.before {
335335
let key = (item.path.clone(), context.line);
336336
if !matching_lines.contains(&key) && emitted_context.insert(key) {
337-
lines.push(format!("{}-{}-{}", item.path, context.line, context.text));
337+
push_grouped_grep_line(
338+
&mut lines,
339+
&mut current_path,
340+
&item.path,
341+
context.line,
342+
'-',
343+
&context.text,
344+
);
338345
}
339346
}
340347

341-
lines.push(format!("{}:{}:{}", item.path, item.line, item.text));
348+
push_grouped_grep_line(
349+
&mut lines,
350+
&mut current_path,
351+
&item.path,
352+
item.line,
353+
':',
354+
&item.text,
355+
);
342356

343357
for context in &item.after {
344358
let key = (item.path.clone(), context.line);
345359
if !matching_lines.contains(&key) && emitted_context.insert(key) {
346-
lines.push(format!("{}-{}-{}", item.path, context.line, context.text));
360+
push_grouped_grep_line(
361+
&mut lines,
362+
&mut current_path,
363+
&item.path,
364+
context.line,
365+
'-',
366+
&context.text,
367+
);
347368
}
348369
}
349370
}
350371

351372
lines.join("\n")
352373
}
353374

375+
fn push_grouped_grep_line<'a>(
376+
lines: &mut Vec<String>,
377+
current_path: &mut Option<&'a str>,
378+
path: &'a str,
379+
line: usize,
380+
marker: char,
381+
text: &str,
382+
) {
383+
if *current_path != Some(path) {
384+
lines.push(path.to_string());
385+
*current_path = Some(path);
386+
}
387+
lines.push(format!("{line}{marker}{text}"));
388+
}
389+
354390
fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
355391
value
356392
.try_into()
@@ -385,11 +421,25 @@ mod tests {
385421
}
386422

387423
#[test]
388-
fn text_renders_grep_shape() {
424+
fn text_renders_grouped_grep_shape() {
389425
let chunks = vec![chunk("src/lib.rs", 1, "one\nneedle\nthree")];
390426
let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
391427

392-
assert_eq!(format_text_matches(&result.matches), "src/lib.rs:2:needle");
428+
assert_eq!(format_text_matches(&result.matches), "src/lib.rs\n2:needle");
429+
}
430+
431+
#[test]
432+
fn text_groups_multiple_files() {
433+
let chunks = vec![
434+
chunk("src/a.rs", 1, "needle a"),
435+
chunk("tests/b.rs", 10, "needle b"),
436+
];
437+
let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
438+
439+
assert_eq!(
440+
format_text_matches(&result.matches),
441+
"src/a.rs\n1:needle a\ntests/b.rs\n10:needle b"
442+
);
393443
}
394444

395445
#[test]
@@ -461,7 +511,24 @@ mod tests {
461511
);
462512
assert_eq!(
463513
format_text_matches(&result.matches),
464-
"src/lib.rs-2-two\nsrc/lib.rs:3:needle\nsrc/lib.rs-4-four\nsrc/lib.rs-5-five"
514+
"src/lib.rs\n2-two\n3:needle\n4-four\n5-five"
515+
);
516+
}
517+
518+
#[test]
519+
fn text_suppresses_duplicate_context_lines() {
520+
let chunks = vec![chunk(
521+
"src/lib.rs",
522+
1,
523+
"one\nneedle one\nmiddle\nneedle two\nfive",
524+
)];
525+
let mut opts = options("needle");
526+
opts.context = Some(1);
527+
let result = grep_chunks(&chunks, &opts).expect("grep chunks");
528+
529+
assert_eq!(
530+
format_text_matches(&result.matches),
531+
"src/lib.rs\n1-one\n2:needle one\n3-middle\n4:needle two\n5-five"
465532
);
466533
}
467534

@@ -482,6 +549,10 @@ mod tests {
482549
assert_eq!(result.matches[0].line, 2);
483550
assert_eq!(result.matches[0].before.len(), 1);
484551
assert_eq!(result.matches[0].after.len(), 1);
552+
assert_eq!(
553+
format_text_matches(&result.matches),
554+
"src/lib.rs\n1-before\n2:needle one\n3-middle"
555+
);
485556
}
486557

487558
#[test]

0 commit comments

Comments
 (0)