From aa9d7bac0cffe8c522a0b6bd696b164627011e57 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sat, 5 Sep 2026 12:13:45 +0500 Subject: [PATCH 1/2] refactor(cli): move exit-code error types into cmd/grg/errors.go Pure move, no behavior change. main.go had grown past the 350-line target in CONTRIBUTING.md and the exit-code mapping is about to gain a case for skipped blobs, so give it its own file. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016ut4mEjEnEXdJZejxFm8gT --- cmd/grg/errors.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/grg/main.go | 96 ----------------------------------------- 2 files changed, 106 insertions(+), 96 deletions(-) create mode 100644 cmd/grg/errors.go diff --git a/cmd/grg/errors.go b/cmd/grg/errors.go new file mode 100644 index 0000000..658482b --- /dev/null +++ b/cmd/grg/errors.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "errors" +) + +// 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 +} + +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 +} diff --git a/cmd/grg/main.go b/cmd/grg/main.go index ba95f2b..b6e8efc 100644 --- a/cmd/grg/main.go +++ b/cmd/grg/main.go @@ -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) } From 59efec1ab0736fdf133975d211eeaad4ec278acb Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sat, 5 Sep 2026 12:18:11 +0500 Subject: [PATCH 2/2] fix(search): skip unreadable blobs with a warning instead of aborting Since 6393c33 a single blob that cannot be read (missing or corrupt loose object, bad zlib stream, bad pack entry) aborted the whole search: workers pushed the read error onto errCh, Pipeline.Execute returned it, and AggregateChannel bailed on the first res.Error. One zero-byte object under .git/objects therefore meant exit 2 with no output. v0.1.0 silently tolerated it, and both git grep and ripgrep warn and keep going. processTask now wraps a reader.ReadObject failure in search.BlobReadError {OID, Path, Err}. The pipeline still delivers that result on resultsCh (so callers can warn) but no longer reports it on errCh, and AggregateChannel skips it instead of returning. Every other error (context cancellation, matcher failures, history walk and discovery errors) is unchanged and stays fatal. The CLI prints one line per skipped blob to stderr before any match output: grg: warning: skipping blob (): Exit codes follow ripgrep: 0 when matches were found and nothing was skipped, 1 when nothing matched and nothing was skipped, 2 when any blob was skipped (matches may still have been printed). --quiet keeps its contract: a match found still exits 0, no match with a skipped blob exits 2. The README exit-code table is updated accordingly (it also claimed 128 for repository errors, which the code never returned). Tests: TestPipelineExecuteContext_ReaderError previously asserted that a read failure reached errCh, i.e. the fatal behaviour this fixes; it now asserts the soft BlobReadError path. New unit tests cover the pipeline and aggregator with a fake ObjectReader, and test/integration exercises a real repo whose loose blob is truncated to zero bytes. Fixes #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016ut4mEjEnEXdJZejxFm8gT --- README.md | 9 +- cmd/grg/errors.go | 40 ++++++++ cmd/grg/main.go | 39 ++++++- cmd/grg/main_test.go | 1 - internal/aggregator/aggregator.go | 7 ++ internal/aggregator/aggregator_test.go | 39 +++++++ internal/search/pipeline.go | 40 +++++++- internal/search/pipeline_test.go | 104 +++++++++++++++++-- test/integration/corrupt_object_test.go | 129 ++++++++++++++++++++++++ 9 files changed, 392 insertions(+), 16 deletions(-) create mode 100644 test/integration/corrupt_object_test.go diff --git a/README.md b/README.md index ec5570b..55f6886 100644 --- a/README.md +++ b/README.md @@ -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 (): ...` line on stderr, the search continues and still prints matches from every other blob, and the exit code is 2. --- diff --git a/cmd/grg/errors.go b/cmd/grg/errors.go index 658482b..6fc6865 100644 --- a/cmd/grg/errors.go +++ b/cmd/grg/errors.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" ) // Exit-code error types. Every error returned from runContext is mapped to a process @@ -83,6 +84,45 @@ 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) { diff --git a/cmd/grg/main.go b/cmd/grg/main.go index b6e8efc..ac627d2 100644 --- a/cmd/grg/main.go +++ b/cmd/grg/main.go @@ -168,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{} } @@ -196,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 { diff --git a/cmd/grg/main_test.go b/cmd/grg/main_test.go index f0349d5..1279e08 100644 --- a/cmd/grg/main_test.go +++ b/cmd/grg/main_test.go @@ -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()) } } - diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index 2b68bd4..41bb1d3 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -2,6 +2,7 @@ package aggregator import ( "context" + "errors" "sort" "time" @@ -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) diff --git a/internal/aggregator/aggregator_test.go b/internal/aggregator/aggregator_test.go index 453f163..02effec 100644 --- a/internal/aggregator/aggregator_test.go +++ b/internal/aggregator/aggregator_test.go @@ -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" ) @@ -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") + } + } +} diff --git a/internal/search/pipeline.go b/internal/search/pipeline.go index e7336db..00ba063 100644 --- a/internal/search/pipeline.go +++ b/internal/search/pipeline.go @@ -2,6 +2,7 @@ package search import ( "context" + "errors" "fmt" "runtime" "sync" @@ -12,6 +13,12 @@ import ( ) // BlobResult contains search matches and all provenance occurrences for a deduplicated blob OID. +// +// Error is set when the blob could not be searched. A *BlobReadError is a soft +// failure: the pipeline still delivers the result (with no matches) on the results +// channel so callers can warn about it, but does not report it on the error channel +// and the search continues. Any other error is fatal and also surfaces on the error +// channel. type BlobResult struct { BlobOID string Occurrences []model.BlobOccurrence @@ -22,6 +29,29 @@ type BlobResult struct { Error error } +// BlobReadError reports that a blob could not be read from the object store +// (missing, truncated, or corrupt object) and was skipped by the search. +// Path is the first occurrence path of the blob, for diagnostics. +type BlobReadError struct { + OID string + Path string + Err error +} + +func (e *BlobReadError) Error() string { + return fmt.Sprintf("failed to read blob %s: %v", e.OID, e.Err) +} + +func (e *BlobReadError) Unwrap() error { + return e.Err +} + +// isBlobReadError reports whether err is (or wraps) a soft per-blob read failure. +func isBlobReadError(err error) bool { + var bre *BlobReadError + return errors.As(err, &bre) +} + // Pipeline coordinates concurrent blob decompression, search matching, and provenance association. type Pipeline struct { reader gitengine.ObjectReader @@ -133,7 +163,9 @@ func (p *Pipeline) ExecuteContext(ctx context.Context, occurrences []model.BlobO continue } res := p.processTask(ctx, task) - if res.Error != nil { + // Soft per-blob read failures are delivered on resultsCh only; every + // other error is fatal and also reported on errCh. + if res.Error != nil && !isBlobReadError(res.Error) { select { case errCh <- res.Error: default: @@ -216,7 +248,11 @@ func (p *Pipeline) processTask(ctx context.Context, task *blobTask) *BlobResult obj, err := p.reader.ReadObject(task.oid) if err != nil { - res.Error = fmt.Errorf("failed to read blob %s: %w", task.oid, err) + var path string + if len(task.occurrences) > 0 { + path = task.occurrences[0].Path + } + res.Error = &BlobReadError{OID: task.oid, Path: path, Err: err} return res } diff --git a/internal/search/pipeline_test.go b/internal/search/pipeline_test.go index e76571f..bc1fb27 100644 --- a/internal/search/pipeline_test.go +++ b/internal/search/pipeline_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha1" "encoding/hex" + "errors" "fmt" "runtime" "testing" @@ -14,11 +15,15 @@ import ( ) type mockSearchReader struct { - objects map[string]*gitengine.Object + objects map[string]*gitengine.Object + failWith map[string]error // OIDs whose ReadObject returns the given error } func newMockSearchReader() *mockSearchReader { - return &mockSearchReader{objects: make(map[string]*gitengine.Object)} + return &mockSearchReader{ + objects: make(map[string]*gitengine.Object), + failWith: make(map[string]error), + } } func (m *mockSearchReader) putBlob(content []byte) string { @@ -36,6 +41,9 @@ func (m *mockSearchReader) putBlob(content []byte) string { } func (m *mockSearchReader) ReadObject(oid string) (*gitengine.Object, error) { + if err, ok := m.failWith[oid]; ok { + return nil, err + } if obj, ok := m.objects[oid]; ok { return obj, nil } @@ -346,6 +354,8 @@ func TestPipelineExecuteContext_Empty(t *testing.T) { } } +// A blob the reader cannot load is a soft failure: it is delivered on resultsCh +// as a *BlobReadError so callers can warn, but never on errCh (regression #10). func TestPipelineExecuteContext_ReaderError(t *testing.T) { reader := newMockSearchReader() // missing blob occurrences := []model.BlobOccurrence{ @@ -365,13 +375,93 @@ func TestPipelineExecuteContext_ReaderError(t *testing.T) { if len(results) != 1 { t.Fatalf("expected 1 result with error, got %d", len(results)) } - if results[0].Error == nil { - t.Errorf("expected blob reading error on result") + var bre *BlobReadError + if !errors.As(results[0].Error, &bre) { + t.Fatalf("expected *BlobReadError on result, got %v", results[0].Error) + } + if bre.OID != "nonexistent_oid" || bre.Path != "missing.txt" { + t.Errorf("unexpected BlobReadError fields: %+v", bre) + } + if !errors.Is(bre, gitengine.ErrObjectNotFound) { + t.Errorf("expected BlobReadError to wrap ErrObjectNotFound, got %v", bre.Err) } - err := <-errCh - if err == nil { - t.Errorf("expected error propagated to errCh") + if err := <-errCh; err != nil { + t.Errorf("blob read failure must not be fatal, got error on errCh: %v", err) } } +// One unreadable blob must not abort the search: every other blob still yields +// its matches and the failing OID is reported as a soft *BlobReadError. +func TestPipelineSkipsUnreadableBlob(t *testing.T) { + tests := []struct { + name string + readErr error + }{ + {name: "object not found", readErr: gitengine.ErrObjectNotFound}, + {name: "corrupt object", readErr: fmt.Errorf("%w: bad zlib stream", gitengine.ErrCorruptObject)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := newMockSearchReader() + good1 := reader.putBlob([]byte("needle in first blob\n")) + bad := reader.putBlob([]byte("needle in unreadable blob\n")) + good2 := reader.putBlob([]byte("needle in third blob\n")) + reader.failWith[bad] = tt.readErr + + occurrences := []model.BlobOccurrence{ + {BlobOID: good1, Path: "a.txt", CommitSHA: "c1", Mode: 0100644}, + {BlobOID: bad, Path: "gone.txt", CommitSHA: "c1", Mode: 0100644}, + {BlobOID: bad, Path: "gone-renamed.txt", CommitSHA: "c2", Mode: 0100644}, + {BlobOID: good2, Path: "b.txt", CommitSHA: "c2", Mode: 0100644}, + } + + cfg := &model.Config{Pattern: "needle"} + matcher, err := NewMatcher(cfg) + if err != nil { + t.Fatalf("NewMatcher failed: %v", err) + } + + results, err := NewPipeline(reader, matcher, cfg).Execute(occurrences) + if err != nil { + t.Fatalf("pipeline must not fail on an unreadable blob, got: %v", err) + } + + byOID := make(map[string]*BlobResult, len(results)) + for _, r := range results { + byOID[r.BlobOID] = r + } + for _, oid := range []string{good1, good2} { + res, ok := byOID[oid] + if !ok { + t.Fatalf("missing result for readable blob %s", oid) + } + if res.Error != nil || len(res.Matches) != 1 { + t.Errorf("readable blob %s: want 1 match and no error, got %d matches, err=%v", oid, len(res.Matches), res.Error) + } + } + + res, ok := byOID[bad] + if !ok { + t.Fatalf("expected a result carrying the read failure for %s", bad) + } + var bre *BlobReadError + if !errors.As(res.Error, &bre) { + t.Fatalf("expected *BlobReadError, got %v", res.Error) + } + if bre.OID != bad { + t.Errorf("BlobReadError.OID = %s, want %s", bre.OID, bad) + } + if bre.Path != "gone.txt" { + t.Errorf("BlobReadError.Path = %q, want first occurrence path %q", bre.Path, "gone.txt") + } + if !errors.Is(bre, tt.readErr) { + t.Errorf("BlobReadError should wrap %v, got %v", tt.readErr, bre.Err) + } + if len(res.Matches) != 0 { + t.Errorf("skipped blob must contribute no matches, got %d", len(res.Matches)) + } + }) + } +} diff --git a/test/integration/corrupt_object_test.go b/test/integration/corrupt_object_test.go new file mode 100644 index 0000000..2986265 --- /dev/null +++ b/test/integration/corrupt_object_test.go @@ -0,0 +1,129 @@ +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// truncateLooseObject zeroes the loose object file for the blob at relPath in +// commit rev, reproducing the "missing blob" corruption git fsck reports. +// Loose objects are written read-only, so the file is made writable first. +func truncateLooseObject(t *testing.T, repo *TestRepo, rev, relPath string) string { + t.Helper() + oid := repo.Git("rev-parse", rev+":"+relPath) + objPath := filepath.Join(repo.Dir, ".git", "objects", oid[:2], oid[2:]) + if err := os.Chmod(objPath, 0600); err != nil { + t.Fatalf("chmod loose object %s: %v", objPath, err) + } + if err := os.Truncate(objPath, 0); err != nil { + t.Fatalf("truncate loose object %s: %v", objPath, err) + } + return oid +} + +// newCorruptRepo builds a repo where gone.txt was committed and later deleted, +// so its blob is only reachable through history (as in issue #10), and then +// corrupts that blob's loose object. It returns the corrupted blob OID. +func newCorruptRepo(t *testing.T) (*TestRepo, string) { + t.Helper() + repo := NewTestRepo(t) + c1 := repo.Commit("Add both files", map[string]string{ + "keep.txt": "NEEDLE in a healthy blob\n", + "gone.txt": "NEEDLE in a soon-corrupt blob\nONLY_IN_GONE\n", + }) + repo.RemoveFile("gone.txt") + repo.Git("commit", "-m", "Remove gone.txt") + + oid := truncateLooseObject(t, repo, c1, "gone.txt") + return repo, oid +} + +// TestCorruptBlob_SkippedWithWarning verifies that a single unreadable blob no +// longer aborts the search (regression #10): matches from other blobs are still +// printed, the failure is reported once on stderr, and the exit code is 2. +func TestCorruptBlob_SkippedWithWarning(t *testing.T) { + repo, oid := newCorruptRepo(t) + + res := repo.Run("--color=never", "NEEDLE") + if res.ExitCode != 2 { + t.Fatalf("expected exit code 2 after a skipped blob, got %d.\nStderr: %s\nStdout: %s", res.ExitCode, res.Stderr, res.Stdout) + } + if !strings.Contains(res.Stdout, "keep.txt") || !strings.Contains(res.Stdout, "NEEDLE in a healthy blob") { + t.Errorf("expected matches from the healthy blob on stdout, got:\n%s", res.Stdout) + } + if strings.Contains(res.Stdout, "gone.txt") { + t.Errorf("skipped blob must not produce matches, stdout:\n%s", res.Stdout) + } + + warning := "grg: warning: skipping blob " + oid + " (gone.txt):" + if !strings.Contains(res.Stderr, warning) { + t.Errorf("expected stderr to contain %q, got:\n%s", warning, res.Stderr) + } + if strings.Count(res.Stderr, "grg: warning:") != 1 { + t.Errorf("expected exactly one warning line, got:\n%s", res.Stderr) + } + if strings.Contains(res.Stderr, "grg: failed to read blob") { + t.Errorf("blob read failure must not be reported as a fatal error, stderr:\n%s", res.Stderr) + } + if strings.Contains(res.Stdout, "grg: warning:") { + t.Errorf("warnings must go to stderr only, stdout:\n%s", res.Stdout) + } +} + +// TestCorruptBlob_NoOtherMatches verifies exit code 2 (not 1) when the only +// potential match lived in the unreadable blob. +func TestCorruptBlob_NoOtherMatches(t *testing.T) { + repo, oid := newCorruptRepo(t) + + res := repo.Run("--color=never", "ONLY_IN_GONE") + if res.ExitCode != 2 { + t.Fatalf("expected exit code 2, got %d.\nStderr: %s\nStdout: %s", res.ExitCode, res.Stderr, res.Stdout) + } + if strings.TrimSpace(res.Stdout) != "" { + t.Errorf("expected no stdout, got:\n%s", res.Stdout) + } + if !strings.Contains(res.Stderr, oid) { + t.Errorf("expected stderr warning naming %s, got:\n%s", oid, res.Stderr) + } +} + +// TestCorruptBlob_Quiet verifies ripgrep's --quiet semantics: a match found +// still exits 0 even though a blob was skipped; no match exits 2. +func TestCorruptBlob_Quiet(t *testing.T) { + repo, _ := newCorruptRepo(t) + + res := repo.Run("-q", "NEEDLE") + if res.ExitCode != 0 { + t.Errorf("-q with a match: expected exit 0, got %d.\nStderr: %s", res.ExitCode, res.Stderr) + } + if res.Stdout != "" { + t.Errorf("-q must not print matches, got:\n%s", res.Stdout) + } + + res = repo.Run("-q", "ONLY_IN_GONE") + if res.ExitCode != 2 { + t.Errorf("-q with no match and a skipped blob: expected exit 2, got %d.\nStderr: %s", res.ExitCode, res.Stderr) + } +} + +// TestCorruptBlob_HealthyRepoUnaffected guards the baseline: without corruption +// the exit codes and stderr stay exactly as before. +func TestCorruptBlob_HealthyRepoUnaffected(t *testing.T) { + repo := NewTestRepo(t) + repo.Commit("Add files", map[string]string{ + "keep.txt": "NEEDLE in a healthy blob\n", + "other.txt": "nothing to see\n", + }) + + res := repo.RunSuccess("--color=never", "NEEDLE") + if res.Stderr != "" { + t.Errorf("expected empty stderr for a healthy repo, got:\n%s", res.Stderr) + } + + res = repo.RunNoMatch("--color=never", "ABSENT_PATTERN_XYZ") + if res.Stderr != "" { + t.Errorf("expected empty stderr for a healthy repo, got:\n%s", res.Stderr) + } +}