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=