mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(agentgit): use git cli instead of go-git (#22730)
go-git has bugs in gitignore logic. With more complex gitignores, some paths that should be ignored aren't. That caused extra, unexpected files to appear in the git diff panel. If the git cli isn't available in a workspace, the /git/watch endpoint will still allow the frontend to connect, but no git changes will ever be transmitted.
This commit is contained in:
+128
-443
@@ -7,22 +7,14 @@ package agentgit
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5/utils/diff"
|
||||
dmp "github.com/sergi/go-diff/diffmatchpatch"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
@@ -41,20 +33,19 @@ func WithClock(c quartz.Clock) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithGitBinary overrides the git binary path (for testing).
|
||||
func WithGitBinary(path string) Option {
|
||||
return func(h *Handler) {
|
||||
h.gitBin = path
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// scanCooldown is the minimum interval between successive scans.
|
||||
scanCooldown = 1 * time.Second
|
||||
// fallbackPollInterval is the safety-net poll period used when no
|
||||
// filesystem events arrive.
|
||||
fallbackPollInterval = 30 * time.Second
|
||||
// maxFileReadSize is the maximum file size that will be read
|
||||
// into memory. Files larger than this are tracked by status
|
||||
// only, and their diffs show a placeholder message.
|
||||
maxFileReadSize = 2 * 1024 * 1024 // 2 MiB
|
||||
// maxFileDiffSize is the maximum encoded size of a single
|
||||
// file's diff. If an individual file's diff exceeds this
|
||||
// limit, it is replaced with a placeholder stub.
|
||||
maxFileDiffSize = 256 * 1024 // 256 KiB
|
||||
// maxTotalDiffSize is the maximum size of the combined
|
||||
// unified diff for an entire repository sent over the wire.
|
||||
// This must stay under the WebSocket message size limit.
|
||||
@@ -65,6 +56,7 @@ const (
|
||||
type Handler struct {
|
||||
logger slog.Logger
|
||||
clock quartz.Clock
|
||||
gitBin string // path to git binary; empty means "git" (from PATH)
|
||||
|
||||
mu sync.Mutex
|
||||
repoRoots map[string]struct{} // watched repo roots
|
||||
@@ -85,6 +77,7 @@ func NewHandler(logger slog.Logger, opts ...Option) *Handler {
|
||||
h := &Handler{
|
||||
logger: logger,
|
||||
clock: quartz.NewReal(),
|
||||
gitBin: "git",
|
||||
repoRoots: make(map[string]struct{}),
|
||||
lastSnapshots: make(map[string]repoSnapshot),
|
||||
scanTrigger: make(chan struct{}, 1),
|
||||
@@ -92,13 +85,30 @@ func NewHandler(logger slog.Logger, opts ...Option) *Handler {
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
|
||||
// Check if git is available.
|
||||
if _, err := exec.LookPath(h.gitBin); err != nil {
|
||||
h.logger.Warn(context.Background(), "git binary not found, git scanning disabled")
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// gitAvailable returns true if the configured git binary can be found
|
||||
// in PATH.
|
||||
func (h *Handler) gitAvailable() bool {
|
||||
_, err := exec.LookPath(h.gitBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Subscribe processes a subscribe message, resolving paths to git repo
|
||||
// roots and adding new repos to the watch set. Returns true if any new
|
||||
// repo roots were added.
|
||||
func (h *Handler) Subscribe(paths []string) bool {
|
||||
if !h.gitAvailable() {
|
||||
return false
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -109,7 +119,7 @@ func (h *Handler) Subscribe(paths []string) bool {
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
|
||||
root, err := findRepoRoot(p)
|
||||
root, err := findRepoRoot(h.gitBin, p)
|
||||
if err != nil {
|
||||
// Not a git path — silently ignore.
|
||||
continue
|
||||
@@ -135,6 +145,10 @@ func (h *Handler) RequestScan() {
|
||||
// Scan performs a scan of all subscribed repos and computes deltas
|
||||
// against the previously emitted snapshots.
|
||||
func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMessage {
|
||||
if !h.gitAvailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
roots := make([]string, 0, len(h.repoRoots))
|
||||
for r := range h.repoRoots {
|
||||
@@ -158,7 +172,7 @@ func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMes
|
||||
}
|
||||
results := make([]scanResult, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
changes, err := getRepoChanges(ctx, h.logger, root)
|
||||
changes, err := getRepoChanges(ctx, h.logger, h.gitBin, root)
|
||||
results = append(results, scanResult{root: root, changes: changes, err: err})
|
||||
}
|
||||
|
||||
@@ -168,7 +182,7 @@ func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMes
|
||||
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
if isRepoDeleted(res.root) {
|
||||
if isRepoDeleted(h.gitBin, res.root) {
|
||||
// Repo root or .git directory was removed.
|
||||
// Emit a removal entry, then evict from watch set.
|
||||
removal := codersdk.WorkspaceAgentRepoChanges{
|
||||
@@ -276,8 +290,9 @@ func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
|
||||
// 2. The .git entry (directory or file) was removed.
|
||||
// 3. The .git entry is a file (worktree/submodule) whose target
|
||||
// gitdir was removed. In this case .git exists on disk but
|
||||
// git.PlainOpen fails because the referenced directory is gone.
|
||||
func isRepoDeleted(repoRoot string) bool {
|
||||
// `git rev-parse --git-dir` fails because the referenced
|
||||
// directory is gone.
|
||||
func isRepoDeleted(gitBin string, repoRoot string) bool {
|
||||
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
@@ -288,78 +303,77 @@ func isRepoDeleted(repoRoot string) bool {
|
||||
}
|
||||
// If .git is a regular file (worktree or submodule), the actual
|
||||
// git object store lives elsewhere. Validate that the target is
|
||||
// still reachable by attempting to open the repo.
|
||||
// still reachable by running git rev-parse.
|
||||
if err == nil && !fi.IsDir() {
|
||||
if _, openErr := git.PlainOpen(repoRoot); openErr != nil {
|
||||
cmd := exec.CommandContext(context.Background(), gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findRepoRoot walks up from the given path to find a .git directory.
|
||||
func findRepoRoot(p string) (string, error) {
|
||||
// If p is a file, start from its directory.
|
||||
// findRepoRoot uses `git rev-parse --show-toplevel` to find the
|
||||
// repository root for the given path.
|
||||
func findRepoRoot(gitBin string, p string) (string, error) {
|
||||
// If p is a file, start from its parent directory.
|
||||
dir := p
|
||||
for {
|
||||
_, err := git.PlainOpen(dir)
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", xerrors.Errorf("no git repo found for %s", p)
|
||||
}
|
||||
dir = parent
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
cmd := exec.CommandContext(context.Background(), gitBin, "rev-parse", "--show-toplevel")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("no git repo found for %s", p)
|
||||
}
|
||||
root := filepath.FromSlash(strings.TrimSpace(string(out)))
|
||||
// Resolve symlinks and short (8.3) names on Windows so the
|
||||
// returned root matches paths produced by Go's filepath APIs.
|
||||
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
|
||||
root = resolved
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// getRepoChanges reads the current state of a git repository using
|
||||
// go-git. It returns branch, remote origin, and per-file status.
|
||||
func getRepoChanges(ctx context.Context, logger slog.Logger, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
|
||||
repo, err := git.PlainOpen(repoRoot)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentRepoChanges{}, xerrors.Errorf("open repo: %w", err)
|
||||
}
|
||||
|
||||
// the git CLI. It returns branch, remote origin, and a unified diff.
|
||||
func getRepoChanges(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
|
||||
result := codersdk.WorkspaceAgentRepoChanges{
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
// Read branch.
|
||||
headRef, err := repo.Head()
|
||||
if err != nil {
|
||||
// Repo may have no commits yet.
|
||||
// Verify this is still a valid git repository before doing
|
||||
// anything else. This catches deleted repos early.
|
||||
verifyCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
|
||||
if err := verifyCmd.Run(); err != nil {
|
||||
return result, xerrors.Errorf("not a git repository: %w", err)
|
||||
}
|
||||
|
||||
// Read branch name.
|
||||
branchCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "symbolic-ref", "--short", "HEAD")
|
||||
if out, err := branchCmd.Output(); err == nil {
|
||||
result.Branch = strings.TrimSpace(string(out))
|
||||
} else {
|
||||
logger.Debug(ctx, "failed to read HEAD", slog.F("root", repoRoot), slog.Error(err))
|
||||
} else if headRef.Name().IsBranch() {
|
||||
result.Branch = headRef.Name().Short()
|
||||
}
|
||||
|
||||
// Read remote origin URL.
|
||||
cfg, err := repo.Config()
|
||||
if err == nil {
|
||||
if origin, ok := cfg.Remotes["origin"]; ok && len(origin.URLs) > 0 {
|
||||
result.RemoteOrigin = origin.URLs[0]
|
||||
}
|
||||
remoteCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "config", "--get", "remote.origin.url")
|
||||
if out, err := remoteCmd.Output(); err == nil {
|
||||
result.RemoteOrigin = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Get worktree status.
|
||||
wt, err := repo.Worktree()
|
||||
// Compute unified diff.
|
||||
// `git diff HEAD` shows both staged and unstaged changes vs HEAD.
|
||||
// For repos with no commits yet, fall back to showing untracked
|
||||
// files only.
|
||||
diff, err := computeGitDiff(ctx, logger, gitBin, repoRoot)
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("get worktree: %w", err)
|
||||
return result, xerrors.Errorf("compute diff: %w", err)
|
||||
}
|
||||
|
||||
status, err := wt.Status()
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("worktree status: %w", err)
|
||||
}
|
||||
|
||||
worktreeDiff, err := computeWorktreeDiff(repo, repoRoot, status)
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("compute worktree diff: %w", err)
|
||||
}
|
||||
|
||||
result.UnifiedDiff = worktreeDiff.unifiedDiff
|
||||
result.UnifiedDiff = diff
|
||||
if len(result.UnifiedDiff) > maxTotalDiffSize {
|
||||
result.UnifiedDiff = "Total diff too large to show. Size: " + humanize.IBytes(uint64(len(result.UnifiedDiff))) + ". Showing branch and remote only."
|
||||
}
|
||||
@@ -367,390 +381,61 @@ func getRepoChanges(ctx context.Context, logger slog.Logger, repoRoot string) (c
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type worktreeDiffResult struct {
|
||||
unifiedDiff string
|
||||
additions int
|
||||
deletions int
|
||||
}
|
||||
// computeGitDiff produces a unified diff string for the repository by
|
||||
// combining `git diff HEAD` (staged + unstaged changes) with diffs
|
||||
// for untracked files.
|
||||
func computeGitDiff(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (string, error) {
|
||||
var diffParts []string
|
||||
|
||||
type fileSnapshot struct {
|
||||
exists bool
|
||||
content []byte
|
||||
mode filemode.FileMode
|
||||
binary bool
|
||||
tooLarge bool
|
||||
size int64 // actual file size on disk, set even when tooLarge
|
||||
}
|
||||
|
||||
func computeWorktreeDiff(
|
||||
repo *git.Repository,
|
||||
repoRoot string,
|
||||
status git.Status,
|
||||
) (worktreeDiffResult, error) {
|
||||
headTree, err := getHeadTree(repo)
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("get head tree: %w", err)
|
||||
// Check if the repo has any commits.
|
||||
hasCommits := true
|
||||
checkCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "HEAD")
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
hasCommits = false
|
||||
}
|
||||
|
||||
paths := sortedStatusPaths(status)
|
||||
filePatches := make([]fdiff.FilePatch, 0, len(paths))
|
||||
totalAdditions := 0
|
||||
totalDeletions := 0
|
||||
|
||||
for _, path := range paths {
|
||||
fileStatus := status[path]
|
||||
|
||||
fromPath := path
|
||||
if isRenamed(fileStatus) && fileStatus.Extra != "" {
|
||||
fromPath = fileStatus.Extra
|
||||
}
|
||||
toPath := path
|
||||
|
||||
before, err := readHeadFileSnapshot(headTree, fromPath)
|
||||
if hasCommits {
|
||||
// `git diff HEAD` captures both staged and unstaged changes
|
||||
// relative to HEAD in a single unified diff.
|
||||
cmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "HEAD")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("read head file %q: %w", fromPath, err)
|
||||
return "", xerrors.Errorf("git diff HEAD: %w", err)
|
||||
}
|
||||
|
||||
after, err := readWorktreeFileSnapshot(repoRoot, toPath)
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("read worktree file %q: %w", toPath, err)
|
||||
if len(out) > 0 {
|
||||
diffParts = append(diffParts, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
filePatch, additions, deletions := buildFilePatch(fromPath, toPath, before, after)
|
||||
if filePatch == nil {
|
||||
// Show untracked files as diffs too.
|
||||
// `git ls-files --others --exclude-standard` lists untracked,
|
||||
// non-ignored files.
|
||||
lsCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "ls-files", "--others", "--exclude-standard")
|
||||
lsOut, err := lsCmd.Output()
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to list untracked files", slog.F("root", repoRoot), slog.Error(err))
|
||||
return strings.Join(diffParts, ""), nil
|
||||
}
|
||||
|
||||
untrackedFiles := strings.Split(strings.TrimSpace(string(lsOut)), "\n")
|
||||
for _, f := range untrackedFiles {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check whether this single file's diff exceeds the
|
||||
// per-file limit. If so, replace it with a stub.
|
||||
encoded, err := encodeUnifiedDiff([]fdiff.FilePatch{filePatch})
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("encode file diff %q: %w", toPath, err)
|
||||
// Use `git diff --no-index /dev/null <file>` to generate
|
||||
// a unified diff for untracked files.
|
||||
var stdout bytes.Buffer
|
||||
untrackedCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "--no-index", "--", "/dev/null", f)
|
||||
untrackedCmd.Stdout = &stdout
|
||||
// git diff --no-index exits with 1 when files differ,
|
||||
// which is expected. We ignore the error and check for
|
||||
// output instead.
|
||||
_ = untrackedCmd.Run()
|
||||
if stdout.Len() > 0 {
|
||||
diffParts = append(diffParts, stdout.String())
|
||||
}
|
||||
if len(encoded) > maxFileDiffSize {
|
||||
msg := "File diff too large to show. Diff size: " + humanize.IBytes(uint64(len(encoded)))
|
||||
filePatch = buildStubFilePatch(fromPath, toPath, before, after, msg)
|
||||
additions = 0
|
||||
deletions = 0
|
||||
}
|
||||
|
||||
filePatches = append(filePatches, filePatch)
|
||||
totalAdditions += additions
|
||||
totalDeletions += deletions
|
||||
}
|
||||
|
||||
diffText, err := encodeUnifiedDiff(filePatches)
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("encode unified diff: %w", err)
|
||||
}
|
||||
|
||||
return worktreeDiffResult{
|
||||
unifiedDiff: diffText,
|
||||
additions: totalAdditions,
|
||||
deletions: totalDeletions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getHeadTree(repo *git.Repository) (*object.Tree, error) {
|
||||
headRef, err := repo.Head()
|
||||
if err != nil {
|
||||
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commit, err := repo.CommitObject(headRef.Hash())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commit.Tree()
|
||||
}
|
||||
|
||||
func readHeadFileSnapshot(headTree *object.Tree, path string) (fileSnapshot, error) {
|
||||
if headTree == nil {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
|
||||
file, err := headTree.File(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, object.ErrFileNotFound) {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
return fileSnapshot{}, err
|
||||
}
|
||||
|
||||
if file.Size > maxFileReadSize {
|
||||
return fileSnapshot{
|
||||
exists: true,
|
||||
tooLarge: true,
|
||||
size: file.Size,
|
||||
mode: file.Mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
content, err := file.Contents()
|
||||
if err != nil {
|
||||
return fileSnapshot{}, err
|
||||
}
|
||||
|
||||
isBinary, err := file.IsBinary()
|
||||
if err != nil {
|
||||
return fileSnapshot{}, err
|
||||
}
|
||||
|
||||
return fileSnapshot{
|
||||
exists: true,
|
||||
content: []byte(content),
|
||||
mode: file.Mode,
|
||||
binary: isBinary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readWorktreeFileSnapshot(repoRoot string, path string) (fileSnapshot, error) {
|
||||
absPath := filepath.Join(repoRoot, filepath.FromSlash(path))
|
||||
fileInfo, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
return fileSnapshot{}, err
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
|
||||
if fileInfo.Size() > maxFileReadSize {
|
||||
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
|
||||
if err != nil {
|
||||
mode = filemode.Regular
|
||||
}
|
||||
return fileSnapshot{
|
||||
exists: true,
|
||||
tooLarge: true,
|
||||
size: fileInfo.Size(),
|
||||
mode: mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fileSnapshot{}, nil
|
||||
}
|
||||
return fileSnapshot{}, err
|
||||
}
|
||||
|
||||
mode, err := filemode.NewFromOSFileMode(fileInfo.Mode())
|
||||
if err != nil {
|
||||
mode = filemode.Regular
|
||||
}
|
||||
|
||||
return fileSnapshot{
|
||||
exists: true,
|
||||
content: content,
|
||||
mode: mode,
|
||||
binary: isBinaryContent(content),
|
||||
size: fileInfo.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildFilePatch(
|
||||
fromPath string,
|
||||
toPath string,
|
||||
before fileSnapshot,
|
||||
after fileSnapshot,
|
||||
) (fdiff.FilePatch, int, int) {
|
||||
if !before.exists && !after.exists {
|
||||
return nil, 0, 0
|
||||
}
|
||||
|
||||
unchangedContent := bytes.Equal(before.content, after.content)
|
||||
if before.exists &&
|
||||
after.exists &&
|
||||
fromPath == toPath &&
|
||||
before.mode == after.mode &&
|
||||
unchangedContent {
|
||||
return nil, 0, 0
|
||||
}
|
||||
|
||||
// Files that exceed the read size limit get a stub patch
|
||||
// instead of a full diff to avoid OOM.
|
||||
if before.tooLarge || after.tooLarge {
|
||||
sz := max(after.size, 0)
|
||||
//nolint:gosec // sz is guaranteed to fit in uint64
|
||||
msg := "File too large to diff. Current size: " + humanize.IBytes(uint64(sz))
|
||||
return buildStubFilePatch(fromPath, toPath, before, after, msg), 0, 0
|
||||
}
|
||||
|
||||
patch := &workspaceFilePatch{
|
||||
from: snapshotToDiffFile(fromPath, before),
|
||||
to: snapshotToDiffFile(toPath, after),
|
||||
}
|
||||
|
||||
if before.binary || after.binary {
|
||||
patch.binary = true
|
||||
return patch, 0, 0
|
||||
}
|
||||
|
||||
diffs := diff.Do(string(before.content), string(after.content))
|
||||
chunks := make([]fdiff.Chunk, 0, len(diffs))
|
||||
additions := 0
|
||||
deletions := 0
|
||||
|
||||
for _, d := range diffs {
|
||||
var operation fdiff.Operation
|
||||
switch d.Type {
|
||||
case dmp.DiffEqual:
|
||||
operation = fdiff.Equal
|
||||
case dmp.DiffDelete:
|
||||
operation = fdiff.Delete
|
||||
deletions += countChunkLines(d.Text)
|
||||
case dmp.DiffInsert:
|
||||
operation = fdiff.Add
|
||||
additions += countChunkLines(d.Text)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
chunks = append(chunks, workspaceDiffChunk{
|
||||
content: d.Text,
|
||||
op: operation,
|
||||
})
|
||||
}
|
||||
|
||||
patch.chunks = chunks
|
||||
return patch, additions, deletions
|
||||
}
|
||||
|
||||
func buildStubFilePatch(fromPath, toPath string, before, after fileSnapshot, message string) fdiff.FilePatch {
|
||||
return &workspaceFilePatch{
|
||||
from: snapshotToDiffFile(fromPath, before),
|
||||
to: snapshotToDiffFile(toPath, after),
|
||||
chunks: []fdiff.Chunk{
|
||||
workspaceDiffChunk{
|
||||
content: message + "\n",
|
||||
op: fdiff.Add,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotToDiffFile(path string, snapshot fileSnapshot) fdiff.File {
|
||||
if !snapshot.exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
return workspaceDiffFile{
|
||||
path: path,
|
||||
mode: snapshot.mode,
|
||||
hash: plumbing.ComputeHash(plumbing.BlobObject, snapshot.content),
|
||||
}
|
||||
}
|
||||
|
||||
func encodeUnifiedDiff(filePatches []fdiff.FilePatch) (string, error) {
|
||||
if len(filePatches) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
patch := workspaceDiffPatch{filePatches: filePatches}
|
||||
var builder strings.Builder
|
||||
encoder := fdiff.NewUnifiedEncoder(&builder, fdiff.DefaultContextLines)
|
||||
if err := encoder.Encode(patch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func sortedStatusPaths(status git.Status) []string {
|
||||
paths := make([]string, 0, len(status))
|
||||
for path := range status {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func isRenamed(fileStatus *git.FileStatus) bool {
|
||||
return fileStatus.Staging == git.Renamed || fileStatus.Worktree == git.Renamed
|
||||
}
|
||||
|
||||
func countChunkLines(content string) int {
|
||||
if content == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Count(content, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func isBinaryContent(content []byte) bool {
|
||||
return bytes.IndexByte(content, 0) >= 0
|
||||
}
|
||||
|
||||
type workspaceDiffPatch struct {
|
||||
filePatches []fdiff.FilePatch
|
||||
}
|
||||
|
||||
func (p workspaceDiffPatch) FilePatches() []fdiff.FilePatch {
|
||||
return p.filePatches
|
||||
}
|
||||
|
||||
func (workspaceDiffPatch) Message() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type workspaceFilePatch struct {
|
||||
from fdiff.File
|
||||
to fdiff.File
|
||||
chunks []fdiff.Chunk
|
||||
binary bool
|
||||
}
|
||||
|
||||
func (p *workspaceFilePatch) IsBinary() bool {
|
||||
return p.binary
|
||||
}
|
||||
|
||||
func (p *workspaceFilePatch) Files() (fdiff.File, fdiff.File) {
|
||||
return p.from, p.to
|
||||
}
|
||||
|
||||
func (p *workspaceFilePatch) Chunks() []fdiff.Chunk {
|
||||
return p.chunks
|
||||
}
|
||||
|
||||
type workspaceDiffFile struct {
|
||||
path string
|
||||
mode filemode.FileMode
|
||||
hash plumbing.Hash
|
||||
}
|
||||
|
||||
func (f workspaceDiffFile) Hash() plumbing.Hash {
|
||||
return f.hash
|
||||
}
|
||||
|
||||
func (f workspaceDiffFile) Mode() filemode.FileMode {
|
||||
return f.mode
|
||||
}
|
||||
|
||||
func (f workspaceDiffFile) Path() string {
|
||||
return f.path
|
||||
}
|
||||
|
||||
type workspaceDiffChunk struct {
|
||||
content string
|
||||
op fdiff.Operation
|
||||
}
|
||||
|
||||
func (c workspaceDiffChunk) Content() string {
|
||||
return c.content
|
||||
}
|
||||
|
||||
func (c workspaceDiffChunk) Type() fdiff.Operation {
|
||||
return c.op
|
||||
return strings.Join(diffParts, ""), nil
|
||||
}
|
||||
|
||||
+202
-177
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user