fix(agentgit): use git cli instead of go-git (#22730)

go-git has bugs in gitignore logic. With more complex gitignores, some
paths that should be ignored aren't. That caused extra, unexpected files
to appear in the git diff panel.

If the git cli isn't available in a workspace, the /git/watch endpoint
will still allow the frontend to connect, but no git changes will ever
be transmitted.
This commit is contained in:
Hugo Dutka
2026-03-06 22:52:32 +01:00
committed by GitHub
parent b199ef1b69
commit 4afdfc50a5
4 changed files with 330 additions and 640 deletions
+128 -443
View File
@@ -7,22 +7,14 @@ package agentgit
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/utils/diff"
dmp "github.com/sergi/go-diff/diffmatchpatch"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
@@ -41,20 +33,19 @@ func WithClock(c quartz.Clock) Option {
}
}
// WithGitBinary overrides the git binary path (for testing).
func WithGitBinary(path string) Option {
return func(h *Handler) {
h.gitBin = path
}
}
const (
// scanCooldown is the minimum interval between successive scans.
scanCooldown = 1 * time.Second
// fallbackPollInterval is the safety-net poll period used when no
// filesystem events arrive.
fallbackPollInterval = 30 * time.Second
// maxFileReadSize is the maximum file size that will be read
// into memory. Files larger than this are tracked by status
// only, and their diffs show a placeholder message.
maxFileReadSize = 2 * 1024 * 1024 // 2 MiB
// maxFileDiffSize is the maximum encoded size of a single
// file's diff. If an individual file's diff exceeds this
// limit, it is replaced with a placeholder stub.
maxFileDiffSize = 256 * 1024 // 256 KiB
// maxTotalDiffSize is the maximum size of the combined
// unified diff for an entire repository sent over the wire.
// This must stay under the WebSocket message size limit.
@@ -65,6 +56,7 @@ const (
type Handler struct {
logger slog.Logger
clock quartz.Clock
gitBin string // path to git binary; empty means "git" (from PATH)
mu sync.Mutex
repoRoots map[string]struct{} // watched repo roots
@@ -85,6 +77,7 @@ func NewHandler(logger slog.Logger, opts ...Option) *Handler {
h := &Handler{
logger: logger,
clock: quartz.NewReal(),
gitBin: "git",
repoRoots: make(map[string]struct{}),
lastSnapshots: make(map[string]repoSnapshot),
scanTrigger: make(chan struct{}, 1),
@@ -92,13 +85,30 @@ func NewHandler(logger slog.Logger, opts ...Option) *Handler {
for _, opt := range opts {
opt(h)
}
// Check if git is available.
if _, err := exec.LookPath(h.gitBin); err != nil {
h.logger.Warn(context.Background(), "git binary not found, git scanning disabled")
}
return h
}
// gitAvailable returns true if the configured git binary can be found
// in PATH.
func (h *Handler) gitAvailable() bool {
_, err := exec.LookPath(h.gitBin)
return err == nil
}
// Subscribe processes a subscribe message, resolving paths to git repo
// roots and adding new repos to the watch set. Returns true if any new
// repo roots were added.
func (h *Handler) Subscribe(paths []string) bool {
if !h.gitAvailable() {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
@@ -109,7 +119,7 @@ func (h *Handler) Subscribe(paths []string) bool {
}
p = filepath.Clean(p)
root, err := findRepoRoot(p)
root, err := findRepoRoot(h.gitBin, p)
if err != nil {
// Not a git path — silently ignore.
continue
@@ -135,6 +145,10 @@ func (h *Handler) RequestScan() {
// Scan performs a scan of all subscribed repos and computes deltas
// against the previously emitted snapshots.
func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMessage {
if !h.gitAvailable() {
return nil
}
h.mu.Lock()
roots := make([]string, 0, len(h.repoRoots))
for r := range h.repoRoots {
@@ -158,7 +172,7 @@ func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMes
}
results := make([]scanResult, 0, len(roots))
for _, root := range roots {
changes, err := getRepoChanges(ctx, h.logger, root)
changes, err := getRepoChanges(ctx, h.logger, h.gitBin, root)
results = append(results, scanResult{root: root, changes: changes, err: err})
}
@@ -168,7 +182,7 @@ func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMes
for _, res := range results {
if res.err != nil {
if isRepoDeleted(res.root) {
if isRepoDeleted(h.gitBin, res.root) {
// Repo root or .git directory was removed.
// Emit a removal entry, then evict from watch set.
removal := codersdk.WorkspaceAgentRepoChanges{
@@ -276,8 +290,9 @@ func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
// 2. The .git entry (directory or file) was removed.
// 3. The .git entry is a file (worktree/submodule) whose target
// gitdir was removed. In this case .git exists on disk but
// git.PlainOpen fails because the referenced directory is gone.
func isRepoDeleted(repoRoot string) bool {
// `git rev-parse --git-dir` fails because the referenced
// directory is gone.
func isRepoDeleted(gitBin string, repoRoot string) bool {
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
return true
}
@@ -288,78 +303,77 @@ func isRepoDeleted(repoRoot string) bool {
}
// If .git is a regular file (worktree or submodule), the actual
// git object store lives elsewhere. Validate that the target is
// still reachable by attempting to open the repo.
// still reachable by running git rev-parse.
if err == nil && !fi.IsDir() {
if _, openErr := git.PlainOpen(repoRoot); openErr != nil {
cmd := exec.CommandContext(context.Background(), gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
return true
}
}
return false
}
// findRepoRoot walks up from the given path to find a .git directory.
func findRepoRoot(p string) (string, error) {
// If p is a file, start from its directory.
// findRepoRoot uses `git rev-parse --show-toplevel` to find the
// repository root for the given path.
func findRepoRoot(gitBin string, p string) (string, error) {
// If p is a file, start from its parent directory.
dir := p
for {
_, err := git.PlainOpen(dir)
if err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", xerrors.Errorf("no git repo found for %s", p)
}
dir = parent
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
dir = filepath.Dir(dir)
}
cmd := exec.CommandContext(context.Background(), gitBin, "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", xerrors.Errorf("no git repo found for %s", p)
}
root := filepath.FromSlash(strings.TrimSpace(string(out)))
// Resolve symlinks and short (8.3) names on Windows so the
// returned root matches paths produced by Go's filepath APIs.
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
root = resolved
}
return root, nil
}
// getRepoChanges reads the current state of a git repository using
// go-git. It returns branch, remote origin, and per-file status.
func getRepoChanges(ctx context.Context, logger slog.Logger, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
repo, err := git.PlainOpen(repoRoot)
if err != nil {
return codersdk.WorkspaceAgentRepoChanges{}, xerrors.Errorf("open repo: %w", err)
}
// the git CLI. It returns branch, remote origin, and a unified diff.
func getRepoChanges(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
result := codersdk.WorkspaceAgentRepoChanges{
RepoRoot: repoRoot,
}
// Read branch.
headRef, err := repo.Head()
if err != nil {
// Repo may have no commits yet.
// Verify this is still a valid git repository before doing
// anything else. This catches deleted repos early.
verifyCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
if err := verifyCmd.Run(); err != nil {
return result, xerrors.Errorf("not a git repository: %w", err)
}
// Read branch name.
branchCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "symbolic-ref", "--short", "HEAD")
if out, err := branchCmd.Output(); err == nil {
result.Branch = strings.TrimSpace(string(out))
} else {
logger.Debug(ctx, "failed to read HEAD", slog.F("root", repoRoot), slog.Error(err))
} else if headRef.Name().IsBranch() {
result.Branch = headRef.Name().Short()
}
// Read remote origin URL.
cfg, err := repo.Config()
if err == nil {
if origin, ok := cfg.Remotes["origin"]; ok && len(origin.URLs) > 0 {
result.RemoteOrigin = origin.URLs[0]
}
remoteCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "config", "--get", "remote.origin.url")
if out, err := remoteCmd.Output(); err == nil {
result.RemoteOrigin = strings.TrimSpace(string(out))
}
// Get worktree status.
wt, err := repo.Worktree()
// Compute unified diff.
// `git diff HEAD` shows both staged and unstaged changes vs HEAD.
// For repos with no commits yet, fall back to showing untracked
// files only.
diff, err := computeGitDiff(ctx, logger, gitBin, repoRoot)
if err != nil {
return result, xerrors.Errorf("get worktree: %w", err)
return result, xerrors.Errorf("compute diff: %w", err)
}
status, err := wt.Status()
if err != nil {
return result, xerrors.Errorf("worktree status: %w", err)
}
worktreeDiff, err := computeWorktreeDiff(repo, repoRoot, status)
if err != nil {
return result, xerrors.Errorf("compute worktree diff: %w", err)
}
result.UnifiedDiff = worktreeDiff.unifiedDiff
result.UnifiedDiff = diff
if len(result.UnifiedDiff) > maxTotalDiffSize {
result.UnifiedDiff = "Total diff too large to show. Size: " + humanize.IBytes(uint64(len(result.UnifiedDiff))) + ". Showing branch and remote only."
}
@@ -367,390 +381,61 @@ func getRepoChanges(ctx context.Context, logger slog.Logger, repoRoot string) (c
return result, nil
}
type worktreeDiffResult struct {
unifiedDiff string
additions int
deletions int
}
// computeGitDiff produces a unified diff string for the repository by
// combining `git diff HEAD` (staged + unstaged changes) with diffs
// for untracked files.
func computeGitDiff(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (string, error) {
var diffParts []string
type fileSnapshot struct {
exists bool
content []byte
mode filemode.FileMode
binary bool
tooLarge bool
size int64 // actual file size on disk, set even when tooLarge
}
func computeWorktreeDiff(
repo *git.Repository,
repoRoot string,
status git.Status,
) (worktreeDiffResult, error) {
headTree, err := getHeadTree(repo)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("get head tree: %w", err)
// Check if the repo has any commits.
hasCommits := true
checkCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "HEAD")
if err := checkCmd.Run(); err != nil {
hasCommits = false
}
paths := sortedStatusPaths(status)
filePatches := make([]fdiff.FilePatch, 0, len(paths))
totalAdditions := 0
totalDeletions := 0
for _, path := range paths {
fileStatus := status[path]
fromPath := path
if isRenamed(fileStatus) && fileStatus.Extra != "" {
fromPath = fileStatus.Extra
}
toPath := path
before, err := readHeadFileSnapshot(headTree, fromPath)
if hasCommits {
// `git diff HEAD` captures both staged and unstaged changes
// relative to HEAD in a single unified diff.
cmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "HEAD")
out, err := cmd.Output()
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("read head file %q: %w", fromPath, err)
return "", xerrors.Errorf("git diff HEAD: %w", err)
}
after, err := readWorktreeFileSnapshot(repoRoot, toPath)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("read worktree file %q: %w", toPath, err)
if len(out) > 0 {
diffParts = append(diffParts, string(out))
}
}
filePatch, additions, deletions := buildFilePatch(fromPath, toPath, before, after)
if filePatch == nil {
// Show untracked files as diffs too.
// `git ls-files --others --exclude-standard` lists untracked,
// non-ignored files.
lsCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "ls-files", "--others", "--exclude-standard")
lsOut, err := lsCmd.Output()
if err != nil {
logger.Debug(ctx, "failed to list untracked files", slog.F("root", repoRoot), slog.Error(err))
return strings.Join(diffParts, ""), nil
}
untrackedFiles := strings.Split(strings.TrimSpace(string(lsOut)), "\n")
for _, f := range untrackedFiles {
f = strings.TrimSpace(f)
if f == "" {
continue
}
// Check whether this single file's diff exceeds the
// per-file limit. If so, replace it with a stub.
encoded, err := encodeUnifiedDiff([]fdiff.FilePatch{filePatch})
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("encode file diff %q: %w", toPath, err)
// Use `git diff --no-index /dev/null <file>` to generate
// a unified diff for untracked files.
var stdout bytes.Buffer
untrackedCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "--no-index", "--", "/dev/null", f)
untrackedCmd.Stdout = &stdout
// git diff --no-index exits with 1 when files differ,
// which is expected. We ignore the error and check for
// output instead.
_ = untrackedCmd.Run()
if stdout.Len() > 0 {
diffParts = append(diffParts, stdout.String())
}
if len(encoded) > maxFileDiffSize {
msg := "File diff too large to show. Diff size: " + humanize.IBytes(uint64(len(encoded)))
filePatch = buildStubFilePatch(fromPath, toPath, before, after, msg)
additions = 0
deletions = 0
}
filePatches = append(filePatches, filePatch)
totalAdditions += additions
totalDeletions += deletions
}
diffText, err := encodeUnifiedDiff(filePatches)
if err != nil {
return worktreeDiffResult{}, xerrors.Errorf("encode unified diff: %w", err)
}
return worktreeDiffResult{
unifiedDiff: diffText,
additions: totalAdditions,
deletions: totalDeletions,
}, nil
}
func getHeadTree(repo *git.Repository) (*object.Tree, error) {
headRef, err := repo.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, nil
}
return nil, err
}
commit, err := repo.CommitObject(headRef.Hash())
if err != nil {
return nil, err
}
return commit.Tree()
}
func readHeadFileSnapshot(headTree *object.Tree, path string) (fileSnapshot, error) {
if headTree == nil {
return fileSnapshot{}, nil
}
file, err := headTree.File(path)
if err != nil {
if errors.Is(err, object.ErrFileNotFound) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
if file.Size > maxFileReadSize {
return fileSnapshot{
exists: true,
tooLarge: true,
size: file.Size,
mode: file.Mode,
}, nil
}
content, err := file.Contents()
if err != nil {
return fileSnapshot{}, err
}
isBinary, err := file.IsBinary()
if err != nil {
return fileSnapshot{}, err
}
return fileSnapshot{
exists: true,
content: []byte(content),
mode: file.Mode,
binary: isBinary,
}, nil
}
func readWorktreeFileSnapshot(repoRoot string, path string) (fileSnapshot, error) {
absPath := filepath.Join(repoRoot, filepath.FromSlash(path))
fileInfo, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
if fileInfo.IsDir() {
return fileSnapshot{}, nil
}
if fileInfo.Size() > maxFileReadSize {
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
if err != nil {
mode = filemode.Regular
}
return fileSnapshot{
exists: true,
tooLarge: true,
size: fileInfo.Size(),
mode: mode,
}, nil
}
content, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, err
}
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
if err != nil {
mode = filemode.Regular
}
return fileSnapshot{
exists: true,
content: content,
mode: mode,
binary: isBinaryContent(content),
size: fileInfo.Size(),
}, nil
}
func buildFilePatch(
fromPath string,
toPath string,
before fileSnapshot,
after fileSnapshot,
) (fdiff.FilePatch, int, int) {
if !before.exists && !after.exists {
return nil, 0, 0
}
unchangedContent := bytes.Equal(before.content, after.content)
if before.exists &&
after.exists &&
fromPath == toPath &&
before.mode == after.mode &&
unchangedContent {
return nil, 0, 0
}
// Files that exceed the read size limit get a stub patch
// instead of a full diff to avoid OOM.
if before.tooLarge || after.tooLarge {
sz := max(after.size, 0)
//nolint:gosec // sz is guaranteed to fit in uint64
msg := "File too large to diff. Current size: " + humanize.IBytes(uint64(sz))
return buildStubFilePatch(fromPath, toPath, before, after, msg), 0, 0
}
patch := &workspaceFilePatch{
from: snapshotToDiffFile(fromPath, before),
to: snapshotToDiffFile(toPath, after),
}
if before.binary || after.binary {
patch.binary = true
return patch, 0, 0
}
diffs := diff.Do(string(before.content), string(after.content))
chunks := make([]fdiff.Chunk, 0, len(diffs))
additions := 0
deletions := 0
for _, d := range diffs {
var operation fdiff.Operation
switch d.Type {
case dmp.DiffEqual:
operation = fdiff.Equal
case dmp.DiffDelete:
operation = fdiff.Delete
deletions += countChunkLines(d.Text)
case dmp.DiffInsert:
operation = fdiff.Add
additions += countChunkLines(d.Text)
default:
continue
}
chunks = append(chunks, workspaceDiffChunk{
content: d.Text,
op: operation,
})
}
patch.chunks = chunks
return patch, additions, deletions
}
func buildStubFilePatch(fromPath, toPath string, before, after fileSnapshot, message string) fdiff.FilePatch {
return &workspaceFilePatch{
from: snapshotToDiffFile(fromPath, before),
to: snapshotToDiffFile(toPath, after),
chunks: []fdiff.Chunk{
workspaceDiffChunk{
content: message + "\n",
op: fdiff.Add,
},
},
}
}
func snapshotToDiffFile(path string, snapshot fileSnapshot) fdiff.File {
if !snapshot.exists {
return nil
}
return workspaceDiffFile{
path: path,
mode: snapshot.mode,
hash: plumbing.ComputeHash(plumbing.BlobObject, snapshot.content),
}
}
func encodeUnifiedDiff(filePatches []fdiff.FilePatch) (string, error) {
if len(filePatches) == 0 {
return "", nil
}
patch := workspaceDiffPatch{filePatches: filePatches}
var builder strings.Builder
encoder := fdiff.NewUnifiedEncoder(&builder, fdiff.DefaultContextLines)
if err := encoder.Encode(patch); err != nil {
return "", err
}
return builder.String(), nil
}
func sortedStatusPaths(status git.Status) []string {
paths := make([]string, 0, len(status))
for path := range status {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func isRenamed(fileStatus *git.FileStatus) bool {
return fileStatus.Staging == git.Renamed || fileStatus.Worktree == git.Renamed
}
func countChunkLines(content string) int {
if content == "" {
return 0
}
lines := strings.Count(content, "\n")
if !strings.HasSuffix(content, "\n") {
lines++
}
return lines
}
func isBinaryContent(content []byte) bool {
return bytes.IndexByte(content, 0) >= 0
}
type workspaceDiffPatch struct {
filePatches []fdiff.FilePatch
}
func (p workspaceDiffPatch) FilePatches() []fdiff.FilePatch {
return p.filePatches
}
func (workspaceDiffPatch) Message() string {
return ""
}
type workspaceFilePatch struct {
from fdiff.File
to fdiff.File
chunks []fdiff.Chunk
binary bool
}
func (p *workspaceFilePatch) IsBinary() bool {
return p.binary
}
func (p *workspaceFilePatch) Files() (fdiff.File, fdiff.File) {
return p.from, p.to
}
func (p *workspaceFilePatch) Chunks() []fdiff.Chunk {
return p.chunks
}
type workspaceDiffFile struct {
path string
mode filemode.FileMode
hash plumbing.Hash
}
func (f workspaceDiffFile) Hash() plumbing.Hash {
return f.hash
}
func (f workspaceDiffFile) Mode() filemode.FileMode {
return f.mode
}
func (f workspaceDiffFile) Path() string {
return f.path
}
type workspaceDiffChunk struct {
content string
op fdiff.Operation
}
func (c workspaceDiffChunk) Content() string {
return c.content
}
func (c workspaceDiffChunk) Type() fdiff.Operation {
return c.op
return strings.Join(diffParts, ""), nil
}
+202 -177
View File
@@ -5,13 +5,11 @@ import (
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -25,30 +23,44 @@ import (
"github.com/coder/websocket"
)
// gitCmd runs a git command in the given directory and fails the test
// on error.
func gitCmd(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@test.com",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}
// initTestRepo creates a temporary git repo with an initial commit
// and returns the repo root path.
func initTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
require.NoError(t, err)
// Resolve symlinks and short (8.3) names on Windows so test
// expectations match the canonical paths returned by git.
resolved, err := filepath.EvalSymlinks(dir)
if err == nil {
dir = resolved
}
gitCmd(t, dir, "init")
gitCmd(t, dir, "config", "user.name", "Test")
gitCmd(t, dir, "config", "user.email", "test@test.com")
// Create a file and commit it so the repo has HEAD.
testFile := filepath.Join(dir, "README.md")
require.NoError(t, os.WriteFile(testFile, []byte("# Test\n"), 0o600))
wt, err := repo.Worktree()
require.NoError(t, err)
_, err = wt.Add("README.md")
require.NoError(t, err)
_, err = wt.Commit("initial commit", &git.CommitOptions{
Author: &object.Signature{
Name: "Test",
Email: "test@test.com",
When: time.Now(),
},
})
require.NoError(t, err)
gitCmd(t, dir, "add", "README.md")
gitCmd(t, dir, "commit", "-m", "initial commit")
return dir
}
@@ -139,6 +151,88 @@ func TestScanReturnsRepoChanges(t *testing.T) {
require.Contains(t, repo.UnifiedDiff, "new.go")
}
func TestScanRespectsGitignore(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
logger := slogtest.Make(t, nil)
// Add a .gitignore that ignores *.log files and the build/ directory.
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte("*.log\nbuild/\n"), 0o600))
gitCmd(t, repoDir, "add", ".gitignore")
gitCmd(t, repoDir, "commit", "-m", "add gitignore")
// Create unstaged files: two normal, three matching gitignore patterns.
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package main\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "util.go"), []byte("package util\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "debug.log"), []byte("some log output\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "error.log"), []byte("some error\n"), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "build"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "build", "output.bin"), []byte("binary\n"), 0o600))
h := agentgit.NewHandler(logger)
h.Subscribe([]string{filepath.Join(repoDir, "main.go")})
ctx := context.Background()
msg := h.Scan(ctx)
require.NotNil(t, msg)
require.Len(t, msg.Repositories, 1)
diff := msg.Repositories[0].UnifiedDiff
// The non-ignored files should appear in the diff.
assert.Contains(t, diff, "main.go")
assert.Contains(t, diff, "util.go")
// The gitignored files must not appear in the diff.
assert.NotContains(t, diff, "debug.log")
assert.NotContains(t, diff, "error.log")
assert.NotContains(t, diff, "output.bin")
}
func TestScanRespectsGitignoreNestedNegation(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
logger := slogtest.Make(t, nil)
// Add a .gitignore that ignores node_modules/.
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte("node_modules/\n"), 0o600))
gitCmd(t, repoDir, "add", ".gitignore")
gitCmd(t, repoDir, "commit", "-m", "add gitignore")
// Simulate the tailwindcss stubs directory which contains a nested
// .gitignore with "!*" (negation that un-ignores everything).
// Real git keeps the parent node_modules/ ignore rule, but go-git
// incorrectly lets the child negation override it.
stubsDir := filepath.Join(repoDir, "site", "node_modules", ".pnpm",
"tailwindcss@3.4.18", "node_modules", "tailwindcss", "stubs")
require.NoError(t, os.MkdirAll(stubsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(stubsDir, ".gitignore"), []byte("!*\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(stubsDir, "config.full.js"), []byte("module.exports = {}\n"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(stubsDir, "tailwind.config.js"), []byte("// tw config\n"), 0o600))
// Also create a normal file outside node_modules.
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "main.go"), []byte("package main\n"), 0o600))
h := agentgit.NewHandler(logger)
h.Subscribe([]string{filepath.Join(repoDir, "main.go")})
ctx := context.Background()
msg := h.Scan(ctx)
require.NotNil(t, msg)
require.Len(t, msg.Repositories, 1)
diff := msg.Repositories[0].UnifiedDiff
// The non-ignored file should appear in the diff.
assert.Contains(t, diff, "main.go")
// Files inside node_modules must not appear even though a nested
// .gitignore contains "!*". The parent node_modules/ rule takes
// precedence in real git.
assert.NotContains(t, diff, "config.full.js")
assert.NotContains(t, diff, "tailwind.config.js")
}
func TestScanDeltaEmission(t *testing.T) {
t.Parallel()
@@ -296,24 +390,16 @@ func TestSubscribeNestedGitRepos(t *testing.T) {
// Create an inner repo nested inside the outer one.
innerDir := filepath.Join(outerDir, "subproject")
require.NoError(t, os.MkdirAll(innerDir, 0o700))
innerRepo, err := git.PlainInit(innerDir, false)
require.NoError(t, err)
gitCmd(t, innerDir, "init")
gitCmd(t, innerDir, "config", "user.name", "Test")
gitCmd(t, innerDir, "config", "user.email", "test@test.com")
// Commit a file in the inner repo so it has HEAD.
innerFile := filepath.Join(innerDir, "inner.go")
require.NoError(t, os.WriteFile(innerFile, []byte("package inner\n"), 0o600))
innerWt, err := innerRepo.Worktree()
require.NoError(t, err)
_, err = innerWt.Add("inner.go")
require.NoError(t, err)
_, err = innerWt.Commit("inner commit", &git.CommitOptions{
Author: &object.Signature{
Name: "Test",
Email: "test@test.com",
When: time.Now(),
},
})
require.NoError(t, err)
gitCmd(t, innerDir, "add", "inner.go")
gitCmd(t, innerDir, "commit", "-m", "inner commit")
// Now create a dirty file in the inner repo.
dirtyFile := filepath.Join(innerDir, "dirty.go")
@@ -411,46 +497,16 @@ func TestScanDeletedWorktreeGitdirEmitsRemoved(t *testing.T) {
// Set up a main repo that we'll use as the source for a worktree.
mainRepoDir := initTestRepo(t)
// Create a linked worktree using git.
worktreeDir := t.TempDir()
mainRepo, err := git.PlainOpen(mainRepoDir)
require.NoError(t, err)
// Create a branch for the worktree.
headRef, err := mainRepo.Head()
require.NoError(t, err)
err = mainRepo.Storer.SetReference(
//nolint:revive // plumbing.NewBranchReferenceName is not available.
plumbing.NewHashReference("refs/heads/worktree-branch", headRef.Hash()),
)
require.NoError(t, err)
// Manually construct the worktree linkage:
// 1. Create worktree gitdir inside main repo's worktrees/
// 2. Write a .git file in the worktree dir pointing to that gitdir.
gitdirPath := filepath.Join(mainRepoDir, ".git", "worktrees", "wt")
require.NoError(t, os.MkdirAll(gitdirPath, 0o755))
// The worktree gitdir needs HEAD and commondir files.
require.NoError(t, os.WriteFile(
filepath.Join(gitdirPath, "HEAD"),
[]byte("ref: refs/heads/worktree-branch\n"), 0o600,
))
require.NoError(t, os.WriteFile(
filepath.Join(gitdirPath, "commondir"),
[]byte(filepath.Join(mainRepoDir, ".git")+"\n"), 0o600,
))
// Write the .git file in the worktree directory.
gitFileContent := "gitdir: " + gitdirPath + "\n"
require.NoError(t, os.WriteFile(
filepath.Join(worktreeDir, ".git"),
[]byte(gitFileContent), 0o600,
))
// Verify the worktree is a valid repo before we break it.
_, err = git.PlainOpen(worktreeDir)
require.NoError(t, err, "worktree should be openable before deletion")
// Create a linked worktree using git CLI.
wtBase := t.TempDir()
// Resolve symlinks and short (8.3) names on Windows so test
// expectations match the canonical paths returned by git.
if resolved, err := filepath.EvalSymlinks(wtBase); err == nil {
wtBase = resolved
}
worktreeDir := filepath.Join(wtBase, "wt")
gitCmd(t, mainRepoDir, "branch", "worktree-branch")
gitCmd(t, mainRepoDir, "worktree", "add", worktreeDir, "worktree-branch")
logger := slogtest.Make(t, nil)
h := agentgit.NewHandler(logger)
@@ -468,12 +524,14 @@ func TestScanDeletedWorktreeGitdirEmitsRemoved(t *testing.T) {
require.Len(t, msg1.Repositories, 1)
require.False(t, msg1.Repositories[0].Removed)
// Now delete the target gitdir. The .git file in the worktree
// still exists, but it points to a directory that is gone.
// Now delete the target gitdir inside .git/worktrees/. The .git
// file in the worktree still exists, but it points to a directory
// that is gone.
gitdirPath := filepath.Join(mainRepoDir, ".git", "worktrees", filepath.Base(worktreeDir))
require.NoError(t, os.RemoveAll(gitdirPath))
// Verify the .git file still exists (this is the bug scenario).
_, err = os.Stat(filepath.Join(worktreeDir, ".git"))
_, err := os.Stat(filepath.Join(worktreeDir, ".git"))
require.NoError(t, err, ".git file should still exist")
// Next scan should detect the broken worktree and emit removal.
@@ -775,13 +833,8 @@ func TestGetRepoChangesStagedModifiedDeleted(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("# Modified\n"), 0o600))
// Stage a new file.
repo, err := git.PlainOpen(repoDir)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "staged.go"), []byte("package staged\n"), 0o600))
wt, err := repo.Worktree()
require.NoError(t, err)
_, err = wt.Add("staged.go")
require.NoError(t, err)
gitCmd(t, repoDir, "add", "staged.go")
// Create an untracked file.
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "untracked.txt"), []byte("hello\n"), 0o600))
@@ -791,34 +844,22 @@ func TestGetRepoChangesStagedModifiedDeleted(t *testing.T) {
require.NotNil(t, msg)
require.Len(t, msg.Repositories, 1)
diff := msg.Repositories[0].UnifiedDiff
// README.md was committed then modified in worktree.
require.Contains(t, msg.Repositories[0].UnifiedDiff, "README.md")
require.Contains(t, diff, "README.md")
require.Contains(t, diff, "--- a/README.md")
require.Contains(t, diff, "+++ b/README.md")
require.Contains(t, diff, "-# Test")
require.Contains(t, diff, "+# Modified")
// staged.go was added to the staging area.
require.Contains(t, msg.Repositories[0].UnifiedDiff, "staged.go")
// untracked.txt is untracked.
require.Contains(t, msg.Repositories[0].UnifiedDiff, "untracked.txt")
require.Equal(t, `diff --git a/README.md b/README.md
index 8ae056963b8b4664c9059e30bc8b834151e03950..6c31532bd0a2258bcfa88789d20d50574cfcc3da 100644
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-# Test
+# Modified
diff --git a/staged.go b/staged.go
new file mode 100644
index 0000000000000000000000000000000000000000..98a5a992ed2bc4b17d078d396ba034c8064079b4
--- /dev/null
+++ b/staged.go
@@ -0,0 +1 @@
+package staged
diff --git a/untracked.txt b/untracked.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ce013625030ba8dba906f756967f9e9ca394464a
--- /dev/null
+++ b/untracked.txt
@@ -0,0 +1 @@
+hello
`, msg.Repositories[0].UnifiedDiff)
require.Contains(t, diff, "staged.go")
require.Contains(t, diff, "+package staged")
// untracked.txt is untracked (shown via --no-index diff).
require.Contains(t, diff, "untracked.txt")
require.Contains(t, diff, "+hello")
}
func TestFallbackPollTriggersScan(t *testing.T) {
@@ -912,12 +953,17 @@ func TestScanLargeFileTooLargeToDiff(t *testing.T) {
h := agentgit.NewHandler(logger)
// Create a file larger than maxFileReadSize (2 MiB).
largeContent := make([]byte, 3*1024*1024)
// Create a large text file (1 MiB). The diff produced by git
// CLI will be under maxTotalDiffSize (3 MiB) so it appears in
// the unified diff output.
largeContent := make([]byte, 1*1024*1024)
for i := range largeContent {
largeContent[i] = byte('A' + (i % 26))
if i%80 == 79 {
largeContent[i] = '\n'
}
}
largeFile := filepath.Join(repoDir, "large.bin")
largeFile := filepath.Join(repoDir, "large.txt")
require.NoError(t, os.WriteFile(largeFile, largeContent, 0o600))
h.Subscribe([]string{largeFile})
@@ -930,13 +976,7 @@ func TestScanLargeFileTooLargeToDiff(t *testing.T) {
repo := msg.Repositories[0]
// The large file should appear in the unified diff.
require.Contains(t, repo.UnifiedDiff, "large.bin")
// The unified diff should contain the "too large" message,
// NOT the actual file content.
require.Contains(t, repo.UnifiedDiff, "File too large to diff")
require.NotContains(t, repo.UnifiedDiff, "AAAA",
"actual file content should not appear in diff")
require.Contains(t, repo.UnifiedDiff, "large.txt")
}
func TestScanLargeFileDeltaTracking(t *testing.T) {
@@ -975,45 +1015,6 @@ func TestScanLargeFileDeltaTracking(t *testing.T) {
require.NotContains(t, msg3.Repositories[0].UnifiedDiff, "big.dat")
}
func TestScanFileDiffTooLargeForWire(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
logger := slogtest.Make(t, nil)
h := agentgit.NewHandler(logger)
// Create a single file whose diff exceeds maxFileDiffSize
// (256 KiB) but stays under maxFileReadSize (2 MiB).
content := make([]byte, 512*1024)
for i := range content {
content[i] = byte('A' + (i % 26))
}
bigFile := filepath.Join(repoDir, "big_diff.txt")
require.NoError(t, os.WriteFile(bigFile, content, 0o600))
h.Subscribe([]string{bigFile})
ctx := context.Background()
msg := h.Scan(ctx)
require.NotNil(t, msg)
require.Len(t, msg.Repositories, 1)
repo := msg.Repositories[0]
// The single file diff exceeds 256 KiB, so it should be
// replaced with a per-file stub.
require.Contains(t, repo.UnifiedDiff, "File diff too large to show")
require.Contains(t, repo.UnifiedDiff, "big_diff.txt")
// The stub should NOT contain the actual file content.
require.NotContains(t, repo.UnifiedDiff, "ABCDEFGHIJ",
"actual file content should not appear in diff")
// Branch metadata should still be present.
require.NotEmpty(t, repo.Branch)
}
func TestScanTotalDiffTooLargeForWire(t *testing.T) {
t.Parallel()
@@ -1023,7 +1024,7 @@ func TestScanTotalDiffTooLargeForWire(t *testing.T) {
h := agentgit.NewHandler(logger)
// Create many files whose individual diffs are under 256 KiB
// but whose total exceeds maxTotalDiffSize (4 MiB).
// but whose total exceeds maxTotalDiffSize (3 MiB).
// ~100 files x 50 KiB content each = ~5 MiB of diffs.
var paths []string
for i := range 100 {
@@ -1046,14 +1047,14 @@ func TestScanTotalDiffTooLargeForWire(t *testing.T) {
repo := msg.Repositories[0]
// The total diff exceeds 4 MiB, so we should get the
// The total diff exceeds 3 MiB, so we should get the
// total-diff placeholder.
require.Contains(t, repo.UnifiedDiff, "Total diff too large to show")
// Branch and remote metadata should still be present.
require.NotEmpty(t, repo.Branch, "branch should still be populated")
// The placeholder message should be well under 4 MiB.
// The placeholder message should be well under 3 MiB.
require.Less(t, len(repo.UnifiedDiff), 4*1024*1024,
"placeholder diff should be much smaller than maxTotalDiffSize")
}
@@ -1083,10 +1084,9 @@ func TestScanBinaryFileDiff(t *testing.T) {
// The binary file should appear in the unified diff.
require.Contains(t, repo.UnifiedDiff, "image.png")
// The unified diff should contain the go-git binary marker,
// The unified diff should contain the git binary marker,
// not the raw binary content.
require.Contains(t, repo.UnifiedDiff, "Binary files")
require.Contains(t, repo.UnifiedDiff, "image.png")
require.Contains(t, repo.UnifiedDiff, "Binary")
require.NotContains(t, repo.UnifiedDiff, "\x00",
"raw binary content should not appear in diff")
}
@@ -1095,25 +1095,17 @@ func TestScanBinaryFileModifiedDiff(t *testing.T) {
t.Parallel()
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
require.NoError(t, err)
gitCmd(t, dir, "init")
gitCmd(t, dir, "config", "user.name", "Test")
gitCmd(t, dir, "config", "user.email", "test@test.com")
// Commit a binary file.
binPath := filepath.Join(dir, "data.bin")
require.NoError(t, os.WriteFile(binPath, []byte("v1\x00\x01\x02"), 0o600))
wt, err := repo.Worktree()
require.NoError(t, err)
_, err = wt.Add("data.bin")
require.NoError(t, err)
_, err = wt.Commit("add binary", &git.CommitOptions{
Author: &object.Signature{
Name: "Test",
Email: "test@test.com",
When: time.Now(),
},
})
require.NoError(t, err)
gitCmd(t, dir, "add", "data.bin")
gitCmd(t, dir, "commit", "-m", "add binary")
// Modify the binary file in the worktree.
require.NoError(t, os.WriteFile(binPath, []byte("v2\x00\x03\x04\x05"), 0o600))
@@ -1133,12 +1125,45 @@ func TestScanBinaryFileModifiedDiff(t *testing.T) {
require.Contains(t, repoChanges.UnifiedDiff, "data.bin")
// Diff should show binary marker for modification too.
require.Contains(t, repoChanges.UnifiedDiff, "Binary files")
require.Contains(t, repoChanges.UnifiedDiff, "data.bin")
require.Contains(t, repoChanges.UnifiedDiff, "Binary")
require.NotContains(t, repoChanges.UnifiedDiff, "\x00",
"raw binary content should not appear in diff")
}
func TestScanFileDiffTooLargeForWire(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
logger := slogtest.Make(t, nil)
h := agentgit.NewHandler(logger)
// Create a single file whose diff is large. With git CLI, the
// diff is produced by git itself so per-file size limiting is
// handled by the total diff size check.
content := make([]byte, 512*1024)
for i := range content {
content[i] = byte('A' + (i % 26))
}
bigFile := filepath.Join(repoDir, "big_diff.txt")
require.NoError(t, os.WriteFile(bigFile, content, 0o600))
h.Subscribe([]string{bigFile})
ctx := context.Background()
msg := h.Scan(ctx)
require.NotNil(t, msg)
require.Len(t, msg.Repositories, 1)
repo := msg.Repositories[0]
// The file should appear in the diff output.
require.Contains(t, repo.UnifiedDiff, "big_diff.txt")
// Branch metadata should still be present.
require.NotEmpty(t, repo.Branch)
}
func TestWebSocketLargePathStoreSubscription(t *testing.T) {
t.Parallel()
-7
View File
@@ -491,7 +491,6 @@ require (
github.com/go-git/go-git/v5 v5.17.0
github.com/mark3labs/mcp-go v0.38.0
github.com/openai/openai-go/v3 v3.15.0
github.com/sergi/go-diff v1.4.0
gonum.org/v1/gonum v0.17.0
)
@@ -541,10 +540,8 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyphar/filepath-securejoin v0.5.1 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/esiqveland/notify v0.13.3 // indirect
@@ -565,7 +562,6 @@ require (
github.com/kaptinlin/jsonpointer v0.4.10 // indirect
github.com/kaptinlin/jsonschema v0.6.10 // indirect
github.com/kaptinlin/messageformat-go v0.4.10 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
@@ -576,7 +572,6 @@ require (
github.com/openai/openai-go v1.12.0 // indirect
github.com/openai/openai-go/v2 v2.7.1 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rhysd/actionlint v1.7.10 // indirect
@@ -584,7 +579,6 @@ require (
github.com/samber/lo v1.51.0 // indirect
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sony/gobreaker/v2 v2.3.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
@@ -594,7 +588,6 @@ require (
github.com/urfave/cli/v2 v2.27.5 // indirect
github.com/vektah/gqlparser/v2 v2.5.28 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
-13
View File
@@ -99,7 +99,6 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
@@ -157,8 +156,6 @@ github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloD
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696 h1:7hAl/81gNUjmSCqJYKe1aTIVY4myjapaSALdCko19tI=
github.com/aslilac/afero v0.0.0-20250403163713-f06e86036696/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -514,8 +511,6 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
@@ -934,8 +929,6 @@ github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDC
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/open-policy-agent/opa v1.6.0 h1:/S/cnNQJ2MUMNzizHPbisTWBHowmLkPrugY5jjkPlRQ=
github.com/open-policy-agent/opa v1.6.0/go.mod h1:zFmw4P+W62+CWGYRDDswfVYSCnPo6oYaktQnfIaRFC4=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1 h1:lK/3zr73guK9apbXTcnDnYrC0YCQ25V3CIULYz3k2xU=
@@ -1329,7 +1322,6 @@ golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
@@ -1358,7 +1350,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -1398,7 +1389,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1409,7 +1399,6 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1443,7 +1432,6 @@ golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -1507,7 +1495,6 @@ gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 h1:wScziU1ff6Bnyr8MEyxATPSLJdnLxKz3p6RsA
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0/go.mod h1:ReNBsNfnsjVC7GsCe80zRcykL/n+nxvsNrg3NbjuleM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=