From 4afdfc50a57ab71ebf4e1b392ead40e107e19e04 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Fri, 6 Mar 2026 22:52:32 +0100 Subject: [PATCH] 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. --- agent/agentgit/agentgit.go | 571 +++++++------------------------- agent/agentgit/agentgit_test.go | 379 +++++++++++---------- go.mod | 7 - go.sum | 13 - 4 files changed, 330 insertions(+), 640 deletions(-) diff --git a/agent/agentgit/agentgit.go b/agent/agentgit/agentgit.go index c602e393eb..6b09f1a4df 100644 --- a/agent/agentgit/agentgit.go +++ b/agent/agentgit/agentgit.go @@ -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 ` 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 } diff --git a/agent/agentgit/agentgit_test.go b/agent/agentgit/agentgit_test.go index 1bc1baf3cf..8d40763ffe 100644 --- a/agent/agentgit/agentgit_test.go +++ b/agent/agentgit/agentgit_test.go @@ -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() diff --git a/go.mod b/go.mod index 1fdb4e0cbe..549de504c9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fc87a3ab2e..5fc66111b9 100644 --- a/go.sum +++ b/go.sum @@ -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=