a1013342a3
* feat: implement real-time git status integration - Add workspace tracker to monitor git changes every 2 seconds - Implement WebSocket streaming for git status updates - Add database persistence for git status in agent session metadata - Compute diffs backend-side with additions/deletions per file - Integrate git status into frontend UI (top bar, files panel, changes panel) - Display real-time ahead/behind commits and line changes - Show file diffs with syntax highlighting in task view - Add retry logic for WebSocket connections during reconciliation - Fix context lifecycle issue in workspace tracker * refactor: remove debug and unnecessary logging - Remove debug logs from workspace tracker - Remove info logs from WebSocket handlers - Remove verbose git status update logs - Keep only essential error logs * fix: compare git diff against remote branch instead of HEAD - Changed git diff comparison from HEAD to remote tracking branch - Now shows all changes including committed files that haven't been pushed - Files committed locally but not pushed will appear in the diff - Falls back to HEAD if no remote branch is configured * fix: prevent stale git status when switching tasks - Clear git status when switching to a new task - Only accept git.status WebSocket updates for the current task - Prevents showing git data from previous task when switching * refactor: remove unused diff stream client code - Remove diffConn field from agentctl Client struct - Remove StreamDiff() method (never called) - Remove CloseDiffStream() method - Remove DiffUpdate type from client.go - All diff data is already included in GitStatusUpdate.Files - Keep server-side diff endpoint in agentctl for potential future use * refactor: remove unused diff stream server-side code - Remove /workspace/diff/stream endpoint from agentctl API - Remove handleDiffStreamWS handler - Remove DiffUpdate type from workspace_tracker.go - Remove DiffSubscriber type - Remove updateDiff() and getDiff() methods - Remove SubscribeDiff(), UnsubscribeDiff() methods - Remove notifyDiffSubscribers() method - Remove GetCurrentDiff() method - Remove currentDiff field and diffSubscribers map - All diff data is already included in GitStatusUpdate.Files * refactor: remove unused output stream code - Remove /output and /output/stream endpoints from agentctl API - Remove handleGetOutput and handleOutputStreamWS handlers - Delete output.go file entirely - Remove OutputBuffer and related code (buffer.go deleted) - Remove outputConn field from agentctl Client - Remove StreamOutput() and CloseOutputStream() methods - Remove OutputLine type from client.go - Remove OutputBufferSize config option - Update readStderr to just log instead of buffering Output data flows through ACP stream (StreamUpdates) which includes all session notifications. The separate output stream was never used.
545 lines
14 KiB
Go
545 lines
14 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GitStatusUpdate represents a git status update
|
|
type GitStatusUpdate struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Modified []string `json:"modified"`
|
|
Added []string `json:"added"`
|
|
Deleted []string `json:"deleted"`
|
|
Untracked []string `json:"untracked"`
|
|
Renamed []string `json:"renamed"`
|
|
Ahead int `json:"ahead"`
|
|
Behind int `json:"behind"`
|
|
Branch string `json:"branch"`
|
|
RemoteBranch string `json:"remote_branch,omitempty"`
|
|
Files map[string]FileInfo `json:"files,omitempty"`
|
|
}
|
|
|
|
// FileInfo represents information about a file
|
|
type FileInfo struct {
|
|
Path string `json:"path"`
|
|
Status string `json:"status"` // modified, added, deleted, untracked, renamed
|
|
Additions int `json:"additions,omitempty"`
|
|
Deletions int `json:"deletions,omitempty"`
|
|
OldPath string `json:"old_path,omitempty"` // For renamed files
|
|
Diff string `json:"diff,omitempty"`
|
|
}
|
|
|
|
// FileListUpdate represents a file listing update
|
|
type FileListUpdate struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Files []FileEntry `json:"files"`
|
|
}
|
|
|
|
// FileEntry represents a file in the workspace
|
|
type FileEntry struct {
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size,omitempty"`
|
|
}
|
|
|
|
// GitStatusSubscriber is a channel that receives git status updates
|
|
type GitStatusSubscriber chan GitStatusUpdate
|
|
|
|
// FilesSubscriber is a channel that receives file listing updates
|
|
type FilesSubscriber chan FileListUpdate
|
|
|
|
// WorkspaceTracker monitors workspace changes and provides real-time updates
|
|
type WorkspaceTracker struct {
|
|
workDir string
|
|
logger *logger.Logger
|
|
|
|
// Current state
|
|
currentStatus GitStatusUpdate
|
|
currentFiles FileListUpdate
|
|
mu sync.RWMutex
|
|
|
|
// Subscribers
|
|
gitStatusSubscribers map[GitStatusSubscriber]struct{}
|
|
filesSubscribers map[FilesSubscriber]struct{}
|
|
subMu sync.RWMutex
|
|
|
|
// Control
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewWorkspaceTracker creates a new workspace tracker
|
|
func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
|
|
return &WorkspaceTracker{
|
|
workDir: workDir,
|
|
logger: log.WithFields(zap.String("component", "workspace-tracker")),
|
|
gitStatusSubscribers: make(map[GitStatusSubscriber]struct{}),
|
|
filesSubscribers: make(map[FilesSubscriber]struct{}),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins monitoring the workspace
|
|
func (wt *WorkspaceTracker) Start(ctx context.Context) {
|
|
wt.wg.Add(1)
|
|
go wt.monitorLoop(ctx)
|
|
}
|
|
|
|
// Stop stops the workspace tracker
|
|
func (wt *WorkspaceTracker) Stop() {
|
|
close(wt.stopCh)
|
|
wt.wg.Wait()
|
|
wt.logger.Info("workspace tracker stopped")
|
|
}
|
|
|
|
// monitorLoop periodically checks for workspace changes
|
|
func (wt *WorkspaceTracker) monitorLoop(ctx context.Context) {
|
|
defer wt.wg.Done()
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
// Initial update
|
|
wt.updateGitStatus(ctx)
|
|
wt.updateFiles(ctx)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-wt.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
wt.updateGitStatus(ctx)
|
|
wt.updateFiles(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateGitStatus updates the git status
|
|
func (wt *WorkspaceTracker) updateGitStatus(ctx context.Context) {
|
|
status, err := wt.getGitStatus(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
wt.mu.Lock()
|
|
wt.currentStatus = status
|
|
wt.mu.Unlock()
|
|
|
|
// Notify subscribers
|
|
wt.notifyGitStatusSubscribers(status)
|
|
}
|
|
|
|
// getGitStatus retrieves the current git status
|
|
func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (GitStatusUpdate, error) {
|
|
update := GitStatusUpdate{
|
|
Timestamp: time.Now(),
|
|
Modified: []string{},
|
|
Added: []string{},
|
|
Deleted: []string{},
|
|
Untracked: []string{},
|
|
Renamed: []string{},
|
|
Files: make(map[string]FileInfo),
|
|
}
|
|
|
|
// Get current branch
|
|
branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
branchCmd.Dir = wt.workDir
|
|
branchOut, err := branchCmd.Output()
|
|
if err != nil {
|
|
return update, err
|
|
}
|
|
update.Branch = strings.TrimSpace(string(branchOut))
|
|
|
|
// Get remote branch
|
|
remoteCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "@{upstream}")
|
|
remoteCmd.Dir = wt.workDir
|
|
if remoteOut, err := remoteCmd.Output(); err == nil {
|
|
update.RemoteBranch = strings.TrimSpace(string(remoteOut))
|
|
}
|
|
|
|
// Get ahead/behind counts
|
|
if update.RemoteBranch != "" {
|
|
countCmd := exec.CommandContext(ctx, "git", "rev-list", "--left-right", "--count", update.Branch+"..."+update.RemoteBranch)
|
|
countCmd.Dir = wt.workDir
|
|
if countOut, err := countCmd.Output(); err == nil {
|
|
parts := strings.Fields(string(countOut))
|
|
if len(parts) == 2 {
|
|
update.Ahead, _ = strconv.Atoi(parts[0])
|
|
update.Behind, _ = strconv.Atoi(parts[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get file status using git status --porcelain
|
|
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
|
|
statusCmd.Dir = wt.workDir
|
|
statusOut, err := statusCmd.Output()
|
|
if err != nil {
|
|
return update, err
|
|
}
|
|
|
|
// Parse porcelain output
|
|
lines := strings.Split(string(statusOut), "\n")
|
|
for _, line := range lines {
|
|
if len(line) < 4 {
|
|
continue
|
|
}
|
|
|
|
statusCode := line[:2]
|
|
filePath := strings.TrimSpace(line[3:])
|
|
|
|
fileInfo := FileInfo{
|
|
Path: filePath,
|
|
}
|
|
|
|
// Parse status code
|
|
switch {
|
|
case strings.HasPrefix(statusCode, "M ") || strings.HasPrefix(statusCode, " M"):
|
|
fileInfo.Status = "modified"
|
|
update.Modified = append(update.Modified, filePath)
|
|
case strings.HasPrefix(statusCode, "A "):
|
|
fileInfo.Status = "added"
|
|
update.Added = append(update.Added, filePath)
|
|
case strings.HasPrefix(statusCode, "D ") || strings.HasPrefix(statusCode, " D"):
|
|
fileInfo.Status = "deleted"
|
|
update.Deleted = append(update.Deleted, filePath)
|
|
case strings.HasPrefix(statusCode, "??"):
|
|
fileInfo.Status = "untracked"
|
|
update.Untracked = append(update.Untracked, filePath)
|
|
case strings.HasPrefix(statusCode, "R "):
|
|
fileInfo.Status = "renamed"
|
|
// Renamed files have format "old -> new"
|
|
if idx := strings.Index(filePath, " -> "); idx != -1 {
|
|
fileInfo.OldPath = filePath[:idx]
|
|
fileInfo.Path = filePath[idx+4:]
|
|
}
|
|
update.Renamed = append(update.Renamed, filePath)
|
|
}
|
|
|
|
update.Files[filePath] = fileInfo
|
|
}
|
|
|
|
// Enrich file info with diff data (additions, deletions, and actual diff content)
|
|
wt.enrichWithDiffData(ctx, &update)
|
|
|
|
return update, nil
|
|
}
|
|
|
|
// enrichWithDiffData adds diff information (additions, deletions, diff content) to file info
|
|
func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *GitStatusUpdate) {
|
|
// Determine the base ref to compare against
|
|
// Use remote branch if available, otherwise use HEAD
|
|
baseRef := "HEAD"
|
|
if update.RemoteBranch != "" {
|
|
baseRef = update.RemoteBranch
|
|
}
|
|
|
|
// Get numstat for additions/deletions count (compare against base branch)
|
|
numstatCmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef)
|
|
numstatCmd.Dir = wt.workDir
|
|
numstatOut, err := numstatCmd.Output()
|
|
if err != nil {
|
|
wt.logger.Debug("failed to get numstat", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
// Parse numstat output and update file info
|
|
lines := strings.Split(string(numstatOut), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
|
|
additions, _ := strconv.Atoi(parts[0])
|
|
deletions, _ := strconv.Atoi(parts[1])
|
|
filePath := parts[2]
|
|
|
|
// Update existing file info if it exists
|
|
if fileInfo, exists := update.Files[filePath]; exists {
|
|
fileInfo.Additions = additions
|
|
fileInfo.Deletions = deletions
|
|
|
|
// Get the actual diff content for this file (compare against base branch)
|
|
diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath)
|
|
diffCmd.Dir = wt.workDir
|
|
if diffOut, err := diffCmd.Output(); err == nil {
|
|
fileInfo.Diff = string(diffOut)
|
|
}
|
|
|
|
update.Files[filePath] = fileInfo
|
|
} else {
|
|
// File might be committed but not in status (because it's committed)
|
|
// Add it to the Files map
|
|
fileInfo := FileInfo{
|
|
Path: filePath,
|
|
Status: "modified", // Assume modified if it has a diff
|
|
Additions: additions,
|
|
Deletions: deletions,
|
|
}
|
|
|
|
// Get the actual diff content for this file
|
|
diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath)
|
|
diffCmd.Dir = wt.workDir
|
|
if diffOut, err := diffCmd.Output(); err == nil {
|
|
fileInfo.Diff = string(diffOut)
|
|
}
|
|
|
|
update.Files[filePath] = fileInfo
|
|
update.Modified = append(update.Modified, filePath)
|
|
}
|
|
}
|
|
|
|
// Also check for staged changes
|
|
stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--numstat")
|
|
stagedCmd.Dir = wt.workDir
|
|
stagedOut, err := stagedCmd.Output()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
lines = strings.Split(string(stagedOut), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
|
|
additions, _ := strconv.Atoi(parts[0])
|
|
deletions, _ := strconv.Atoi(parts[1])
|
|
filePath := parts[2]
|
|
|
|
// Update existing file info if it exists
|
|
if fileInfo, exists := update.Files[filePath]; exists {
|
|
// Add staged changes to the counts
|
|
fileInfo.Additions += additions
|
|
fileInfo.Deletions += deletions
|
|
|
|
// Get the staged diff content
|
|
diffCmd := exec.CommandContext(ctx, "git", "diff", "--cached", "--", filePath)
|
|
diffCmd.Dir = wt.workDir
|
|
if diffOut, err := diffCmd.Output(); err == nil {
|
|
// Append staged diff to existing diff
|
|
if fileInfo.Diff != "" {
|
|
fileInfo.Diff += "\n\n--- Staged changes ---\n" + string(diffOut)
|
|
} else {
|
|
fileInfo.Diff = string(diffOut)
|
|
}
|
|
}
|
|
|
|
update.Files[filePath] = fileInfo
|
|
}
|
|
}
|
|
|
|
// For untracked files, show the entire file content as "added"
|
|
for filePath, fileInfo := range update.Files {
|
|
if fileInfo.Status == "untracked" {
|
|
// Show file content as diff (all additions)
|
|
catCmd := exec.CommandContext(ctx, "cat", filePath)
|
|
catCmd.Dir = wt.workDir
|
|
if catOut, err := catCmd.Output(); err == nil {
|
|
content := string(catOut)
|
|
lines := strings.Split(content, "\n")
|
|
fileInfo.Additions = len(lines)
|
|
fileInfo.Deletions = 0
|
|
|
|
// Format as a diff
|
|
var diffBuilder strings.Builder
|
|
diffBuilder.WriteString("--- /dev/null\n")
|
|
diffBuilder.WriteString("+++ b/" + filePath + "\n")
|
|
diffBuilder.WriteString("@@ -0,0 +1," + strconv.Itoa(len(lines)) + " @@\n")
|
|
for _, line := range lines {
|
|
diffBuilder.WriteString("+" + line + "\n")
|
|
}
|
|
fileInfo.Diff = diffBuilder.String()
|
|
|
|
update.Files[filePath] = fileInfo
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateFiles updates the file listing
|
|
func (wt *WorkspaceTracker) updateFiles(ctx context.Context) {
|
|
files, err := wt.getFileList(ctx)
|
|
if err != nil {
|
|
wt.logger.Debug("failed to get file list", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
wt.mu.Lock()
|
|
wt.currentFiles = files
|
|
wt.mu.Unlock()
|
|
|
|
// Notify subscribers
|
|
wt.notifyFilesSubscribers(files)
|
|
}
|
|
|
|
// getFileList retrieves the list of files in the workspace
|
|
func (wt *WorkspaceTracker) getFileList(ctx context.Context) (FileListUpdate, error) {
|
|
update := FileListUpdate{
|
|
Timestamp: time.Now(),
|
|
Files: []FileEntry{},
|
|
}
|
|
|
|
// Use git ls-files to get tracked files
|
|
cmd := exec.CommandContext(ctx, "git", "ls-files")
|
|
cmd.Dir = wt.workDir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return update, err
|
|
}
|
|
|
|
lines := strings.Split(string(out), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
update.Files = append(update.Files, FileEntry{
|
|
Path: line,
|
|
IsDir: false,
|
|
})
|
|
}
|
|
|
|
return update, nil
|
|
}
|
|
|
|
|
|
|
|
// Subscriber management methods
|
|
|
|
// SubscribeGitStatus creates a new git status subscriber
|
|
func (wt *WorkspaceTracker) SubscribeGitStatus() GitStatusSubscriber {
|
|
sub := make(GitStatusSubscriber, 10)
|
|
|
|
wt.subMu.Lock()
|
|
wt.gitStatusSubscribers[sub] = struct{}{}
|
|
wt.subMu.Unlock()
|
|
|
|
// Send current status immediately
|
|
wt.mu.RLock()
|
|
current := wt.currentStatus
|
|
wt.mu.RUnlock()
|
|
|
|
if current.Timestamp.IsZero() {
|
|
current.Timestamp = time.Now()
|
|
}
|
|
|
|
select {
|
|
case sub <- current:
|
|
default:
|
|
}
|
|
|
|
return sub
|
|
}
|
|
|
|
// UnsubscribeGitStatus removes a git status subscriber
|
|
func (wt *WorkspaceTracker) UnsubscribeGitStatus(sub GitStatusSubscriber) {
|
|
wt.subMu.Lock()
|
|
delete(wt.gitStatusSubscribers, sub)
|
|
wt.subMu.Unlock()
|
|
close(sub)
|
|
}
|
|
|
|
// SubscribeFiles creates a new files subscriber
|
|
func (wt *WorkspaceTracker) SubscribeFiles() FilesSubscriber {
|
|
sub := make(FilesSubscriber, 10)
|
|
|
|
wt.subMu.Lock()
|
|
wt.filesSubscribers[sub] = struct{}{}
|
|
wt.subMu.Unlock()
|
|
|
|
// Send current files immediately
|
|
wt.mu.RLock()
|
|
current := wt.currentFiles
|
|
wt.mu.RUnlock()
|
|
|
|
if current.Timestamp.IsZero() {
|
|
current.Timestamp = time.Now()
|
|
}
|
|
|
|
select {
|
|
case sub <- current:
|
|
default:
|
|
}
|
|
|
|
return sub
|
|
}
|
|
|
|
// UnsubscribeFiles removes a files subscriber
|
|
func (wt *WorkspaceTracker) UnsubscribeFiles(sub FilesSubscriber) {
|
|
wt.subMu.Lock()
|
|
delete(wt.filesSubscribers, sub)
|
|
wt.subMu.Unlock()
|
|
close(sub)
|
|
}
|
|
|
|
// Notification methods
|
|
|
|
// notifyGitStatusSubscribers notifies all git status subscribers
|
|
func (wt *WorkspaceTracker) notifyGitStatusSubscribers(update GitStatusUpdate) {
|
|
wt.subMu.RLock()
|
|
defer wt.subMu.RUnlock()
|
|
|
|
for sub := range wt.gitStatusSubscribers {
|
|
select {
|
|
case sub <- update:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyFilesSubscribers notifies all files subscribers
|
|
func (wt *WorkspaceTracker) notifyFilesSubscribers(update FileListUpdate) {
|
|
wt.subMu.RLock()
|
|
defer wt.subMu.RUnlock()
|
|
|
|
for sub := range wt.filesSubscribers {
|
|
select {
|
|
case sub <- update:
|
|
default:
|
|
// Subscriber is slow, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetCurrentGitStatus returns the current git status
|
|
func (wt *WorkspaceTracker) GetCurrentGitStatus() GitStatusUpdate {
|
|
wt.mu.RLock()
|
|
defer wt.mu.RUnlock()
|
|
return wt.currentStatus
|
|
}
|
|
|
|
// GetCurrentFiles returns the current file list
|
|
func (wt *WorkspaceTracker) GetCurrentFiles() FileListUpdate {
|
|
wt.mu.RLock()
|
|
defer wt.mu.RUnlock()
|
|
return wt.currentFiles
|
|
}
|
|
|
|
|
|
|
|
|