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 new file mode 100644 index 0000000..6fc6865 --- /dev/null +++ b/cmd/grg/errors.go @@ -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 +} diff --git a/cmd/grg/main.go b/cmd/grg/main.go index ba95f2b..ac627d2 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) } @@ -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{} } @@ -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 { 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) + } +}