Skip to content
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
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,10 @@ grg --expand-commits "constant_value"
1. **Target Medium:** `rg` searches working directory files on the filesystem. `grg` searches Git objects (`.git/objects/pack/` and loose objects) across commit history without touching the filesystem working tree.
2. **Attribution:** Matches in `grg` include commit provenance (commit SHA, date, author, summary), and repeated identical blobs are deduplicated by default.
3. **Filesystem Flags Excluded:** Flags specific to directory traversal (such as `--follow` for symlinks, `--max-depth`, `--hidden`, `.gitignore` filtering) are omitted because `grg` traverses Git tree structures directly.
4. **Exit Codes:**
- `0`: Match found.
- `1`: No match found.
- `2`: CLI argument or regex syntax error.
- `128`: Repository discovery or Git object read error.
4. **Exit Codes** (ripgrep semantics):
- `0`: Match found and no error occurred (or `-q` found a match).
- `1`: No match found and no error occurred.
- `2`: An error occurred. This covers fatal errors (CLI argument or regex syntax error, repository discovery failure, cancellation) and soft errors: a blob that cannot be read (missing or corrupt object) is skipped with a `grg: warning: skipping blob <oid> (<path>): ...` line on stderr, the search continues and still prints matches from every other blob, and the exit code is 2.

---

Expand Down
146 changes: 146 additions & 0 deletions cmd/grg/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"context"
"errors"
"fmt"
)

// Exit-code error types. Every error returned from runContext is mapped to a process
// exit code by exitCodeForError: 0 on success, 1 when no match was found, and 2 for
// any other failure (CLI, repository, cancellation), mirroring ripgrep.

type exitCoder interface {
ExitCode() int
}

type quietChecker interface {
IsQuiet() bool
}

type repoError struct {
err error
}

func (r repoError) Error() string {
return r.err.Error()
}

func (r repoError) ExitCode() int {
return 2
}

func (r repoError) Unwrap() error {
return r.err
}

type cliError struct {
err error
}

func (c cliError) Error() string {
return c.err.Error()
}

func (c cliError) ExitCode() int {
return 2
}

func (c cliError) Unwrap() error {
return c.err
}

type cancelError struct {
err error
quiet bool
}

func (c cancelError) Error() string {
if c.err != nil {
return c.err.Error()
}
return "operation canceled"
}

func (c cancelError) ExitCode() int {
return 2
}

func (c cancelError) IsQuiet() bool {
return c.quiet
}

func (c cancelError) Unwrap() error {
return c.err
}

type noMatchError struct{}

func (n noMatchError) Error() string {
return "no matches found"
}

func (n noMatchError) ExitCode() int {
return 1
}

// skippedBlobsError records that one or more blobs could not be read and were
// skipped. It follows ripgrep's soft-error semantics: matches (if any) were still
// printed, but the exit code is 2. Each skipped blob was already reported on
// stderr as a warning, so main prints no additional message for this error.
type skippedBlobsError struct {
count int
}

func (s skippedBlobsError) Error() string {
return fmt.Sprintf("skipped %d unreadable blob(s)", s.count)
}

func (s skippedBlobsError) ExitCode() int {
return 2
}

func (s skippedBlobsError) IsQuiet() bool {
return true
}

// withSkippedBlobs folds the number of skipped blobs into the search outcome.
// Fatal errors take precedence. Otherwise any skipped blob forces exit code 2,
// except that --quiet with a match found still exits 0 (ripgrep semantics).
func withSkippedBlobs(err error, skipped int, quiet bool) error {
if skipped == 0 {
return err
}
if err == nil {
if quiet {
return nil
}
return skippedBlobsError{count: skipped}
}
if errors.Is(err, noMatchError{}) {
return skippedBlobsError{count: skipped}
}
return err
}

func isQuietError(err error) bool {
var qc quietChecker
if errors.As(err, &qc) {
return qc.IsQuiet()
}
return false
}

func exitCodeForError(err error) int {
if err == nil {
return 0
}
var ec exitCoder
if errors.As(err, &ec) {
return ec.ExitCode()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return 2
}
return 2
}
135 changes: 38 additions & 97 deletions cmd/grg/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,102 +39,6 @@ func main() {
}
}

type exitCoder interface {
ExitCode() int
}

type quietChecker interface {
IsQuiet() bool
}

type repoError struct {
err error
}

func (r repoError) Error() string {
return r.err.Error()
}

func (r repoError) ExitCode() int {
return 2
}

func (r repoError) Unwrap() error {
return r.err
}

type cliError struct {
err error
}

func (c cliError) Error() string {
return c.err.Error()
}

func (c cliError) ExitCode() int {
return 2
}

func (c cliError) Unwrap() error {
return c.err
}

type cancelError struct {
err error
quiet bool
}

func (c cancelError) Error() string {
if c.err != nil {
return c.err.Error()
}
return "operation canceled"
}

func (c cancelError) ExitCode() int {
return 2
}

func (c cancelError) IsQuiet() bool {
return c.quiet
}

func (c cancelError) Unwrap() error {
return c.err
}

type noMatchError struct{}

func (n noMatchError) Error() string {
return "no matches found"
}

func (n noMatchError) ExitCode() int {
return 1
}

func isQuietError(err error) bool {
var qc quietChecker
if errors.As(err, &qc) {
return qc.IsQuiet()
}
return false
}

func exitCodeForError(err error) int {
if err == nil {
return 0
}
var ec exitCoder
if errors.As(err, &ec) {
return ec.ExitCode()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return 2
}
return 2
}

func run(args []string) error {
return runContext(context.Background(), args, os.Stdout, os.Stderr)
}
Expand Down Expand Up @@ -264,7 +168,33 @@ func runContext(ctx context.Context, args []string, stdout, stderr io.Writer) er
return cancelError{err: err, quiet: cfg.Quiet}
}

if len(results) == 0 {
// Warn about unreadable blobs before any match output so stdout stays clean.
skipped := reportSkippedBlobs(stderr, results)

return withSkippedBlobs(emitResults(ctx, cfg, results, stdout), skipped, cfg.Quiet)
}

// reportSkippedBlobs writes one warning line per blob the pipeline could not read
// and returns how many were skipped.
func reportSkippedBlobs(stderr io.Writer, results []*search.BlobResult) int {
skipped := 0
for _, res := range results {
if res == nil {
continue
}
var bre *search.BlobReadError
if errors.As(res.Error, &bre) {
skipped++
fmt.Fprintf(stderr, "grg: warning: skipping blob %s (%s): %v\n", bre.OID, bre.Path, bre.Err)
}
}
return skipped
}

// emitResults aggregates and renders the search results, returning noMatchError
// when nothing matched. Under --quiet nothing is written to stdout.
func emitResults(ctx context.Context, cfg *model.Config, results []*search.BlobResult, stdout io.Writer) error {
if !hasMatches(results) {
return noMatchError{}
}

Expand Down Expand Up @@ -292,6 +222,17 @@ func runContext(ctx context.Context, args []string, stdout, stderr io.Writer) er
return nil
}

// hasMatches reports whether any result carries a text or binary match.
// Results for skipped blobs carry only an error and do not count.
func hasMatches(results []*search.BlobResult) bool {
for _, res := range results {
if res != nil && (len(res.Matches) > 0 || res.IsBinary) {
return true
}
}
return false
}

func buildPathFilter(repo *gitengine.RepoInfo, cfg *model.Config) (func(path string) bool, error) {
var gm *filter.GlobMatcher
if len(cfg.Globs) > 0 {
Expand Down
1 change: 0 additions & 1 deletion cmd/grg/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,4 +442,3 @@ func TestRun_SEC03_SubdirectoryPathAnchoring(t *testing.T) {
t.Errorf("expected match in output when anchored relative to worktree, got: %s", buf.String())
}
}

7 changes: 7 additions & 0 deletions internal/aggregator/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package aggregator

import (
"context"
"errors"
"sort"
"time"

Expand Down Expand Up @@ -122,6 +123,12 @@ func (a *Aggregator) AggregateChannel(ctx context.Context, resultsCh <-chan *sea
}
if res != nil {
if res.Error != nil {
// A blob the pipeline could not read is a soft failure: it carries no
// matches and is skipped so the remaining blobs still aggregate.
var bre *search.BlobReadError
if errors.As(res.Error, &bre) {
continue
}
return nil, res.Error
}
a.processBlobResult(res, fileMap, &fileOrder)
Expand Down
39 changes: 39 additions & 0 deletions internal/aggregator/aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/kryft-dev/grg/internal/gitengine"
"github.com/kryft-dev/grg/internal/model"
"github.com/kryft-dev/grg/internal/search"
)
Expand Down Expand Up @@ -306,3 +307,41 @@ func TestAggregator_AggregateChannel_BlobError(t *testing.T) {
}
}

// A *search.BlobReadError is a soft failure: the result is skipped and the
// remaining results still aggregate (regression #10).
func TestAggregator_AggregateChannel_SkipsBlobReadError(t *testing.T) {
agg := New(&model.Config{})
resultsCh := make(chan *search.BlobResult, 3)
errCh := make(chan error)

t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
resultsCh <- &search.BlobResult{
BlobOID: "good1",
Matches: []model.SearchMatch{{LineNum: 1, LineText: "needle"}},
Occurrences: []model.BlobOccurrence{{Path: "a.txt", CommitSHA: "c1", CommitDate: t1}},
}
resultsCh <- &search.BlobResult{
BlobOID: "bad",
Occurrences: []model.BlobOccurrence{{Path: "gone.txt", CommitSHA: "c1", CommitDate: t1}},
Error: &search.BlobReadError{OID: "bad", Path: "gone.txt", Err: gitengine.ErrObjectNotFound},
}
resultsCh <- &search.BlobResult{
BlobOID: "good2",
Matches: []model.SearchMatch{{LineNum: 2, LineText: "needle again"}},
Occurrences: []model.BlobOccurrence{{Path: "b.txt", CommitSHA: "c1", CommitDate: t1}},
}
close(resultsCh)

out, err := agg.AggregateChannel(context.Background(), resultsCh, errCh)
if err != nil {
t.Fatalf("unreadable blob must not abort aggregation, got: %v", err)
}
if out == nil || out.TotalFiles != 2 || out.TotalMatches != 2 {
t.Fatalf("expected 2 files / 2 matches from the readable blobs, got %+v", out)
}
for _, f := range out.Files {
if f.Path == "gone.txt" {
t.Errorf("skipped blob must not appear in aggregated files")
}
}
}
Loading
Loading