mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: agents git watch backend (#22565)
Adds real-time git status watching for workspace agents, so the frontend
can subscribe over WebSocket and show
git file changes in near real-time.
1. Subscription is scoped to a **chat** via `GET
/api/experimental/chats/{chat}/git/watch`.
2. The workspace agent automatically determines which paths to watch
based on tool calls made by the chat (and its ancestor chats).
3. Workspace agent polls subscribed repo working trees on a 30s
interval, on tools calls, and on explicit `refresh` from the client.
4. Scans are rate-limited to at most once per second.
5. Edited paths are tracked **in-memory** inside the workspace agent.
There is no database persistence — state is lost on agent restart. This
will be addresses in a future PR.
6. Messages sent over WebSocket include a full-repo snapshot (unified
diff, branch, origin). A new message is emitted only when the snapshot
changes.
This PR was implemented with AI with me closely controlling what it's
doing. The code follows a plan file that was updated continuously during
implementation. Here's the file if you'd like to see it:
[project.md](https://gist.github.com/hugodutka/8722cf80c92f8a56555f7bc595b770e2).
It reflects the current state of the PR.
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
// Package agentgit provides a WebSocket-based service for watching git
|
||||
// repository changes on the agent. It is mounted at /api/v0/git/watch
|
||||
// and allows clients to subscribe to file paths, triggering scans of
|
||||
// the corresponding git repositories.
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"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"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// Option configures the git watch service.
|
||||
type Option func(*Handler)
|
||||
|
||||
// WithClock sets a controllable clock for testing. Defaults to
|
||||
// quartz.NewReal().
|
||||
func WithClock(c quartz.Clock) Option {
|
||||
return func(h *Handler) {
|
||||
h.clock = c
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
maxTotalDiffSize = 3 * 1024 * 1024 // 3 MiB
|
||||
)
|
||||
|
||||
// Handler manages per-connection git watch state.
|
||||
type Handler struct {
|
||||
logger slog.Logger
|
||||
clock quartz.Clock
|
||||
|
||||
mu sync.Mutex
|
||||
repoRoots map[string]struct{} // watched repo roots
|
||||
lastSnapshots map[string]repoSnapshot // last emitted snapshot per repo
|
||||
lastScanAt time.Time // when the last scan completed
|
||||
scanTrigger chan struct{} // buffered(1), poked by triggers
|
||||
}
|
||||
|
||||
// repoSnapshot captures the last emitted state for delta comparison.
|
||||
type repoSnapshot struct {
|
||||
branch string
|
||||
remoteOrigin string
|
||||
unifiedDiff string
|
||||
}
|
||||
|
||||
// NewHandler creates a new git watch handler.
|
||||
func NewHandler(logger slog.Logger, opts ...Option) *Handler {
|
||||
h := &Handler{
|
||||
logger: logger,
|
||||
clock: quartz.NewReal(),
|
||||
repoRoots: make(map[string]struct{}),
|
||||
lastSnapshots: make(map[string]repoSnapshot),
|
||||
scanTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// 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 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
added := false
|
||||
for _, p := range paths {
|
||||
if !filepath.IsAbs(p) {
|
||||
continue
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
|
||||
root, err := findRepoRoot(p)
|
||||
if err != nil {
|
||||
// Not a git path — silently ignore.
|
||||
continue
|
||||
}
|
||||
if _, ok := h.repoRoots[root]; ok {
|
||||
continue
|
||||
}
|
||||
h.repoRoots[root] = struct{}{}
|
||||
added = true
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// RequestScan pokes the scan trigger so the run loop performs a scan.
|
||||
func (h *Handler) RequestScan() {
|
||||
select {
|
||||
case h.scanTrigger <- struct{}{}:
|
||||
default:
|
||||
// Already pending.
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
h.mu.Lock()
|
||||
roots := make([]string, 0, len(h.repoRoots))
|
||||
for r := range h.repoRoots {
|
||||
roots = append(roots, r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := h.clock.Now().UTC()
|
||||
var repos []codersdk.WorkspaceAgentRepoChanges
|
||||
|
||||
// Perform all I/O outside the lock to avoid blocking
|
||||
// AddPaths/GetPaths/Subscribe callers during disk-heavy scans.
|
||||
type scanResult struct {
|
||||
root string
|
||||
changes codersdk.WorkspaceAgentRepoChanges
|
||||
err error
|
||||
}
|
||||
results := make([]scanResult, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
changes, err := getRepoChanges(ctx, h.logger, root)
|
||||
results = append(results, scanResult{root: root, changes: changes, err: err})
|
||||
}
|
||||
|
||||
// Re-acquire the lock only to commit snapshot updates.
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
if isRepoDeleted(res.root) {
|
||||
// Repo root or .git directory was removed.
|
||||
// Emit a removal entry, then evict from watch set.
|
||||
removal := codersdk.WorkspaceAgentRepoChanges{
|
||||
RepoRoot: res.root,
|
||||
Removed: true,
|
||||
}
|
||||
delete(h.repoRoots, res.root)
|
||||
delete(h.lastSnapshots, res.root)
|
||||
repos = append(repos, removal)
|
||||
} else {
|
||||
// Transient error — log and skip without
|
||||
// removing the repo from the watch set.
|
||||
h.logger.Warn(ctx, "scan repo failed",
|
||||
slog.F("root", res.root),
|
||||
slog.Error(res.err),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
prev, hasPrev := h.lastSnapshots[res.root]
|
||||
if hasPrev &&
|
||||
prev.branch == res.changes.Branch &&
|
||||
prev.remoteOrigin == res.changes.RemoteOrigin &&
|
||||
prev.unifiedDiff == res.changes.UnifiedDiff {
|
||||
// No change in this repo since last emit.
|
||||
continue
|
||||
}
|
||||
|
||||
// Update snapshot.
|
||||
h.lastSnapshots[res.root] = repoSnapshot{
|
||||
branch: res.changes.Branch,
|
||||
remoteOrigin: res.changes.RemoteOrigin,
|
||||
unifiedDiff: res.changes.UnifiedDiff,
|
||||
}
|
||||
|
||||
repos = append(repos, res.changes)
|
||||
}
|
||||
|
||||
h.lastScanAt = now
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
||||
ScannedAt: &now,
|
||||
Repositories: repos,
|
||||
}
|
||||
}
|
||||
|
||||
// RunLoop runs the main event loop that listens for refresh requests
|
||||
// and fallback poll ticks. It calls scanFn whenever a scan should
|
||||
// happen (rate-limited to scanCooldown). It blocks until ctx is
|
||||
// canceled.
|
||||
func (h *Handler) RunLoop(ctx context.Context, scanFn func()) {
|
||||
fallbackTicker := h.clock.NewTicker(fallbackPollInterval)
|
||||
defer fallbackTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-h.scanTrigger:
|
||||
h.rateLimitedScan(ctx, scanFn)
|
||||
|
||||
case <-fallbackTicker.C:
|
||||
h.rateLimitedScan(ctx, scanFn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
|
||||
h.mu.Lock()
|
||||
elapsed := h.clock.Since(h.lastScanAt)
|
||||
if elapsed < scanCooldown {
|
||||
h.mu.Unlock()
|
||||
|
||||
// Wait for cooldown then scan.
|
||||
remaining := scanCooldown - elapsed
|
||||
timer := h.clock.NewTimer(remaining)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
scanFn()
|
||||
return
|
||||
}
|
||||
h.mu.Unlock()
|
||||
scanFn()
|
||||
}
|
||||
|
||||
// isRepoDeleted returns true when the repo root directory or its .git
|
||||
// entry no longer represents a valid git repository. This
|
||||
// distinguishes a genuine repo deletion from a transient scan error
|
||||
// (e.g. lock contention).
|
||||
//
|
||||
// It handles three deletion cases:
|
||||
// 1. The repo root directory itself was removed.
|
||||
// 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 {
|
||||
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
gitPath := filepath.Join(repoRoot, ".git")
|
||||
fi, err := os.Stat(gitPath)
|
||||
if os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
// 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.
|
||||
if err == nil && !fi.IsDir() {
|
||||
if _, openErr := git.PlainOpen(repoRoot); openErr != 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
result := codersdk.WorkspaceAgentRepoChanges{
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
// Read branch.
|
||||
headRef, err := repo.Head()
|
||||
if err != nil {
|
||||
// Repo may have no commits yet.
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
// Get worktree status.
|
||||
wt, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("get worktree: %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
|
||||
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."
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type worktreeDiffResult struct {
|
||||
unifiedDiff string
|
||||
additions int
|
||||
deletions int
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("read head file %q: %w", fromPath, err)
|
||||
}
|
||||
|
||||
after, err := readWorktreeFileSnapshot(repoRoot, toPath)
|
||||
if err != nil {
|
||||
return worktreeDiffResult{}, xerrors.Errorf("read worktree file %q: %w", toPath, err)
|
||||
}
|
||||
|
||||
filePatch, additions, deletions := buildFilePatch(fromPath, toPath, before, after)
|
||||
if filePatch == nil {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// API exposes the git watch HTTP routes for the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
opts []Option
|
||||
pathStore *PathStore
|
||||
}
|
||||
|
||||
// NewAPI creates a new git watch API.
|
||||
func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
|
||||
return &API{
|
||||
logger: logger,
|
||||
pathStore: pathStore,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router for mounting at /api/v0/git.
|
||||
func (a *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/watch", a.handleWatch)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionNoContextTakeover,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to accept WebSocket.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4 MiB read limit — subscribe messages with many paths can exceed the
|
||||
// default 32 KB limit. Matches the SDK/proxy side.
|
||||
conn.SetReadLimit(1 << 22)
|
||||
|
||||
stream := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
](conn, websocket.MessageText, websocket.MessageText, a.logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go httpapi.HeartbeatClose(ctx, a.logger, cancel, conn)
|
||||
|
||||
handler := NewHandler(a.logger, a.opts...)
|
||||
|
||||
// scanAndSend performs a scan and sends results if there are
|
||||
// changes.
|
||||
scanAndSend := func() {
|
||||
msg := handler.Scan(ctx)
|
||||
if msg != nil {
|
||||
if err := stream.Send(*msg); err != nil {
|
||||
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a chat_id query parameter is provided and the PathStore is
|
||||
// available, subscribe to path updates for this chat.
|
||||
chatIDStr := r.URL.Query().Get("chat_id")
|
||||
if chatIDStr != "" && a.pathStore != nil {
|
||||
chatID, parseErr := uuid.Parse(chatIDStr)
|
||||
if parseErr == nil {
|
||||
// Load any paths that are already tracked for this chat.
|
||||
existingPaths := a.pathStore.GetPaths(chatID)
|
||||
if len(existingPaths) > 0 {
|
||||
handler.Subscribe(existingPaths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
// Subscribe to future path updates.
|
||||
notifyCh, unsubscribe := a.pathStore.Subscribe(chatID)
|
||||
defer unsubscribe()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-notifyCh:
|
||||
paths := a.pathStore.GetPaths(chatID)
|
||||
handler.Subscribe(paths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Start the main run loop in a goroutine.
|
||||
go handler.RunLoop(ctx, scanAndSend)
|
||||
|
||||
// Read client messages.
|
||||
updates := stream.Chan()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = stream.Close(websocket.StatusGoingAway)
|
||||
return
|
||||
case msg, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case codersdk.WorkspaceAgentGitClientMessageTypeRefresh:
|
||||
handler.RequestScan()
|
||||
default:
|
||||
if err := stream.Send(codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeError,
|
||||
Message: "unknown message type",
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// ExtractChatContext reads chat identity headers from the request.
|
||||
// Returns zero values if headers are absent (non-chat request).
|
||||
func ExtractChatContext(r *http.Request) (chatID uuid.UUID, ancestorIDs []uuid.UUID, ok bool) {
|
||||
raw := r.Header.Get(workspacesdk.CoderChatIDHeader)
|
||||
if raw == "" {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
chatID, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
rawAncestors := r.Header.Get(workspacesdk.CoderAncestorChatIDsHeader)
|
||||
if rawAncestors != "" {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(rawAncestors), &ids); err == nil {
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
ancestorIDs = append(ancestorIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chatID, ancestorIDs, true
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package agentgit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
func TestExtractChatContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
ancestor1 := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
ancestor2 := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string // empty means header not set
|
||||
setChatID bool // whether to set the chat ID header at all
|
||||
ancestors string // empty means header not set
|
||||
setAncestors bool // whether to set the ancestor header at all
|
||||
wantChatID uuid.UUID
|
||||
wantAncestorIDs []uuid.UUID
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "NoHeadersPresent",
|
||||
setChatID: false,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_NoAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_ValidAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "MalformedChatID",
|
||||
chatID: "not-a-uuid",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_MalformedAncestorJSON",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: `{this is not json}`,
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Only valid UUIDs in the array are returned; invalid
|
||||
// entries are silently skipped.
|
||||
name: "ValidChatID_PartialValidAncestorUUIDs",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
"bad-uuid",
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Header is explicitly set to an empty string, which
|
||||
// Header.Get returns as "".
|
||||
name: "EmptyChatIDHeader",
|
||||
chatID: "",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_EmptyAncestorHeader",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: "",
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.setChatID {
|
||||
r.Header.Set(workspacesdk.CoderChatIDHeader, tt.chatID)
|
||||
}
|
||||
if tt.setAncestors {
|
||||
r.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, tt.ancestors)
|
||||
}
|
||||
|
||||
chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r)
|
||||
|
||||
require.Equal(t, tt.wantOK, ok, "ok mismatch")
|
||||
require.Equal(t, tt.wantChatID, chatID, "chatID mismatch")
|
||||
require.Equal(t, tt.wantAncestorIDs, ancestorIDs, "ancestorIDs mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mustMarshalJSON marshals v to a JSON string, failing the test on error.
|
||||
func mustMarshalJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PathStore tracks which file paths each chat has touched.
|
||||
// It is safe for concurrent use.
|
||||
type PathStore struct {
|
||||
mu sync.RWMutex
|
||||
chatPaths map[uuid.UUID]map[string]struct{}
|
||||
subscribers map[uuid.UUID][]chan<- struct{}
|
||||
}
|
||||
|
||||
// NewPathStore creates a new PathStore.
|
||||
func NewPathStore() *PathStore {
|
||||
return &PathStore{
|
||||
chatPaths: make(map[uuid.UUID]map[string]struct{}),
|
||||
subscribers: make(map[uuid.UUID][]chan<- struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// AddPaths adds paths to every chat in chatIDs and notifies
|
||||
// their subscribers. Zero-value UUIDs are silently skipped.
|
||||
func (ps *PathStore) AddPaths(chatIDs []uuid.UUID, paths []string) {
|
||||
affected := make([]uuid.UUID, 0, len(chatIDs))
|
||||
for _, id := range chatIDs {
|
||||
if id != uuid.Nil {
|
||||
affected = append(affected, id)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ps.mu.Lock()
|
||||
for _, id := range affected {
|
||||
m, ok := ps.chatPaths[id]
|
||||
if !ok {
|
||||
m = make(map[string]struct{})
|
||||
ps.chatPaths[id] = m
|
||||
}
|
||||
for _, p := range paths {
|
||||
m[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
ps.mu.Unlock()
|
||||
|
||||
ps.notifySubscribers(affected)
|
||||
}
|
||||
|
||||
// Notify sends a signal to all subscribers of the given chat IDs
|
||||
// without adding any paths. Zero-value UUIDs are silently skipped.
|
||||
func (ps *PathStore) Notify(chatIDs []uuid.UUID) {
|
||||
affected := make([]uuid.UUID, 0, len(chatIDs))
|
||||
for _, id := range chatIDs {
|
||||
if id != uuid.Nil {
|
||||
affected = append(affected, id)
|
||||
}
|
||||
}
|
||||
if len(affected) == 0 {
|
||||
return
|
||||
}
|
||||
ps.notifySubscribers(affected)
|
||||
}
|
||||
|
||||
// notifySubscribers sends a non-blocking signal to all subscriber
|
||||
// channels for the given chat IDs.
|
||||
func (ps *PathStore) notifySubscribers(chatIDs []uuid.UUID) {
|
||||
ps.mu.RLock()
|
||||
toNotify := make([]chan<- struct{}, 0)
|
||||
for _, id := range chatIDs {
|
||||
toNotify = append(toNotify, ps.subscribers[id]...)
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
|
||||
for _, ch := range toNotify {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPaths returns all paths tracked for a chat, deduplicated
|
||||
// and sorted lexicographically.
|
||||
func (ps *PathStore) GetPaths(chatID uuid.UUID) []string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
|
||||
m := ps.chatPaths[chatID]
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for p := range m {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of chat IDs that have tracked paths.
|
||||
func (ps *PathStore) Len() int {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return len(ps.chatPaths)
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a signal whenever
|
||||
// paths change for chatID, along with an unsubscribe function
|
||||
// that removes the channel.
|
||||
func (ps *PathStore) Subscribe(chatID uuid.UUID) (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.subscribers[chatID] = append(ps.subscribers[chatID], ch)
|
||||
ps.mu.Unlock()
|
||||
|
||||
unsub := func() {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
subs := ps.subscribers[chatID]
|
||||
for i, s := range subs {
|
||||
if s == ch {
|
||||
ps.subscribers[chatID] = append(subs[:i], subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ch, unsub
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package agentgit_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestPathStore_AddPaths_StoresForChatAndAncestors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestor1 := uuid.New()
|
||||
ancestor2 := uuid.New()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID, ancestor1, ancestor2}, []string{"/a", "/b"})
|
||||
|
||||
// All three IDs should see the paths.
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(chatID))
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor1))
|
||||
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor2))
|
||||
|
||||
// An unrelated chat should see nothing.
|
||||
require.Nil(t, ps.GetPaths(uuid.New()))
|
||||
}
|
||||
|
||||
func TestPathStore_AddPaths_SkipsNilUUIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
|
||||
// A nil chatID should be a no-op.
|
||||
ps.AddPaths([]uuid.UUID{uuid.Nil}, []string{"/x"})
|
||||
require.Nil(t, ps.GetPaths(uuid.Nil))
|
||||
|
||||
// A nil ancestor should be silently skipped.
|
||||
chatID := uuid.New()
|
||||
ps.AddPaths([]uuid.UUID{chatID, uuid.Nil}, []string{"/y"})
|
||||
require.Equal(t, []string{"/y"}, ps.GetPaths(chatID))
|
||||
require.Nil(t, ps.GetPaths(uuid.Nil))
|
||||
}
|
||||
|
||||
func TestPathStore_GetPaths_DeduplicatedSorted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/z", "/a", "/m", "/a", "/z"})
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/a", "/b"})
|
||||
|
||||
got := ps.GetPaths(chatID)
|
||||
require.Equal(t, []string{"/a", "/b", "/m", "/z"}, got)
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_ReceivesNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_MultipleSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch1, unsub1 := ps.Subscribe(chatID)
|
||||
defer unsub1()
|
||||
ch2, unsub2 := ps.Subscribe(chatID)
|
||||
defer unsub2()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
for i, ch := range []<-chan struct{}{ch1, ch2} {
|
||||
select {
|
||||
case <-ch:
|
||||
// OK
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("subscriber %d did not receive notification", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Unsubscribe_StopsNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
|
||||
|
||||
// AddPaths sends synchronously via a non-blocking send to the
|
||||
// buffered channel, so if a notification were going to arrive
|
||||
// it would already be in the channel by now.
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("received notification after unsubscribe")
|
||||
default:
|
||||
// Expected: no notification.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Subscribe_AncestorNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestor := uuid.New()
|
||||
|
||||
// Subscribe to the ancestor, then add paths via the child.
|
||||
ch, unsub := ps.Subscribe(ancestor)
|
||||
defer unsub()
|
||||
|
||||
ps.AddPaths([]uuid.UUID{chatID, ancestor}, []string{"/file"})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ancestor subscriber did not receive notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_NotifiesWithoutAddingPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{chatID})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for notification")
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(chatID))
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_SkipsNilUUIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
|
||||
ch, unsub := ps.Subscribe(chatID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{uuid.Nil})
|
||||
|
||||
// Notify sends synchronously via a non-blocking send to the
|
||||
// buffered channel, so if a notification were going to arrive
|
||||
// it would already be in the channel by now.
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("received notification for nil UUID")
|
||||
default:
|
||||
// Expected: no notification.
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(chatID))
|
||||
}
|
||||
|
||||
func TestPathStore_Notify_AncestorNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
chatID := uuid.New()
|
||||
ancestorID := uuid.New()
|
||||
|
||||
// Subscribe to the ancestor, then notify via the child.
|
||||
ch, unsub := ps.Subscribe(ancestorID)
|
||||
defer unsub()
|
||||
|
||||
ps.Notify([]uuid.UUID{chatID, ancestorID})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
select {
|
||||
case <-ch:
|
||||
// Success.
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ancestor subscriber did not receive notification")
|
||||
}
|
||||
|
||||
require.Nil(t, ps.GetPaths(ancestorID))
|
||||
}
|
||||
|
||||
func TestPathStore_ConcurrentSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ps := agentgit.NewPathStore()
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
|
||||
chatIDs := make([]uuid.UUID, goroutines)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = uuid.New()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines * 2) // writers + readers
|
||||
|
||||
// Writers.
|
||||
for i := range goroutines {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for j := range iterations {
|
||||
ancestors := []uuid.UUID{chatIDs[(idx+1)%goroutines]}
|
||||
path := []string{
|
||||
"/file-" + chatIDs[idx].String() + "-" + time.Now().Format(time.RFC3339Nano),
|
||||
"/iter-" + string(rune('0'+j%10)),
|
||||
}
|
||||
ps.AddPaths(append([]uuid.UUID{chatIDs[idx]}, ancestors...), path)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers.
|
||||
for i := range goroutines {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_ = ps.GetPaths(chatIDs[idx])
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify every chat has at least the paths it wrote.
|
||||
for _, id := range chatIDs {
|
||||
paths := ps.GetPaths(id)
|
||||
require.NotEmpty(t, paths, "chat %s should have paths", id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user