439 lines
16 KiB
Markdown
439 lines
16 KiB
Markdown
# Manual Git Commit Sync on Page Refresh - Feasibility Analysis
|
|
|
|
## Problem Statement
|
|
|
|
When users execute git commands directly in the shell/console (e.g., `git commit`, `git reset`, `git rebase`), the UI and data model don't reflect these changes. The user has accepted that manual git operations will only sync on page refresh. This document analyzes whether we can **fully synchronize** our data model with the actual git state when the UI first requests the data.
|
|
|
|
**Full sync means:**
|
|
- **Add** commits that exist in git but not in our model (manual `git commit`)
|
|
- **Remove** commits that exist in our model but not in git (manual `git reset`, `git rebase`)
|
|
|
|
## Current Data Model
|
|
|
|
### Key Entities
|
|
|
|
1. **SessionCommit** (`task_session_commits` table)
|
|
- Stores commits made during a session
|
|
- Fields: `commit_sha`, `parent_sha`, `author_name`, `author_email`, `commit_message`, `committed_at`, `files_changed`, `insertions`, `deletions`
|
|
- Currently populated only by API-initiated commits via `GitOperator`
|
|
|
|
2. **GitSnapshot** (`task_session_git_snapshots` table)
|
|
- Point-in-time snapshots of git state
|
|
- Fields: `head_commit`, `base_commit`, `branch`, `ahead`, `behind`, `files`
|
|
- First snapshot contains `base_commit` (the commit SHA the session started from)
|
|
|
|
3. **Worktree** (`task_session_worktrees` table)
|
|
- Associates sessions with worktrees
|
|
- Fields: `worktree_path`, `worktree_branch`, `repository_id`
|
|
- `BaseBranch` stored in `Worktree` model (the branch the worktree was created from)
|
|
|
|
4. **TaskSession** (`task_sessions` table)
|
|
- Has `repository_id`, `base_branch` fields
|
|
- Associated with worktrees via `TaskSessionWorktree`
|
|
|
|
### Key Relationships
|
|
|
|
```
|
|
TaskSession
|
|
└── TaskSessionWorktree (worktree_path, worktree_branch)
|
|
└── GitSnapshot (first has base_commit = starting point)
|
|
└── SessionCommit (commits we track)
|
|
```
|
|
|
|
## Detection Strategy
|
|
|
|
### Data Available for Commit Range
|
|
|
|
1. **Starting Point**: First `GitSnapshot.base_commit` OR resolve `origin/{baseBranch}` in worktree
|
|
2. **Current HEAD**: Run `git rev-parse HEAD` in worktree path
|
|
3. **Worktree Path**: From `TaskSessionWorktree.worktree_path`
|
|
|
|
### Sync Algorithm
|
|
|
|
```
|
|
1. Get worktree path for session (from TaskSessionWorktree)
|
|
2. Get base_commit from first GitSnapshot (or derive from BaseBranch)
|
|
3. Run: git log --format="%H|%P|%an|%ae|%s|%aI" base_commit..HEAD
|
|
4. Parse commits from git output → actualCommits (set of SHAs)
|
|
5. Get existing SessionCommit records → storedCommits (set of SHAs)
|
|
6. Compute diff:
|
|
- toAdd = actualCommits - storedCommits (commits in git, not in DB)
|
|
- toRemove = storedCommits - actualCommits (commits in DB, not in git)
|
|
7. Insert toAdd commits
|
|
8. Delete toRemove commits
|
|
```
|
|
|
|
This handles:
|
|
- `git commit` → new commit appears in actualCommits → added to DB
|
|
- `git reset --hard HEAD~1` → commit disappears from actualCommits → removed from DB
|
|
- `git rebase` → old commits gone, new commits appear → DB updated accordingly
|
|
- `git commit --amend` → old SHA gone, new SHA appears → old removed, new added
|
|
|
|
### Git Command to List Commits
|
|
|
|
```bash
|
|
git log --format="%H|%P|%an|%ae|%s|%aI" --numstat base_commit..HEAD
|
|
```
|
|
|
|
This provides:
|
|
- `%H` - commit SHA
|
|
- `%P` - parent SHA(s)
|
|
- `%an` - author name
|
|
- `%ae` - author email
|
|
- `%s` - subject (commit message)
|
|
- `%aI` - author date (ISO 8601)
|
|
- `--numstat` - files changed, insertions, deletions
|
|
|
|
## Critical Constraint: Remote Execution
|
|
|
|
**The backend cannot directly execute git commands in the worktree because:**
|
|
|
|
1. **Docker containers** - The worktree lives inside a container, not accessible from the host filesystem
|
|
2. **Remote execution** - The agent might run on a remote VPS or Kubernetes pod
|
|
3. **Isolation** - agentctl is the **only** component with filesystem access to the workspace
|
|
|
|
**Therefore: All git operations must go through agentctl.**
|
|
|
|
## Architecture Options
|
|
|
|
### Option A: Handler Layer via Lifecycle Manager (Recommended)
|
|
|
|
**Location**: `task/handlers/TaskHandlers.wsGetSessionCommits()` or `agent/handlers/GitHandlers`
|
|
|
|
**Flow**:
|
|
```
|
|
Frontend (page refresh)
|
|
→ WS: session.git.commits
|
|
→ Handler: wsGetSessionCommits
|
|
→ Check if session has running agentctl (via lifecycle manager)
|
|
→ If running: call agentctl API to get git log
|
|
→ Sync commits in DB (add missing, remove orphaned)
|
|
→ Return synchronized commits
|
|
→ If not running: return stored commits only (no sync possible)
|
|
```
|
|
|
|
**Changes Required**:
|
|
1. Add new agentctl endpoint: `GET /api/v1/git/log` (returns commits in range)
|
|
2. Add agentctl client method: `GitLog(ctx, baseCommit) ([]*CommitInfo, error)`
|
|
3. Modify handler to call agentctl and sync before returning
|
|
4. Add `DeleteSessionCommit` to repository
|
|
|
|
**Pros**:
|
|
- Works with Docker, remote, and Kubernetes execution
|
|
- Leverages existing agentctl infrastructure
|
|
- Handler already has access to lifecycle manager
|
|
|
|
**Cons**:
|
|
- Sync only works when session is running (agentctl available)
|
|
- Adds latency to the request (git command execution)
|
|
|
|
### Option B: Sync on Session Start/Resume
|
|
|
|
**Location**: `agent/lifecycle/SessionManager.InitializeSession()`
|
|
|
|
**Flow**:
|
|
```
|
|
Session start/resume
|
|
→ agentctl becomes available
|
|
→ Backend calls agentctl to get git log
|
|
→ Sync commits in DB
|
|
→ (Later) Frontend requests commits → returns already-synced data
|
|
```
|
|
|
|
**Pros**:
|
|
- Sync happens once at session start, not on every request
|
|
- Commits are ready when UI requests them
|
|
|
|
**Cons**:
|
|
- Doesn't sync if user does manual git after session starts
|
|
- Still need Option A for mid-session sync
|
|
|
|
### Option C: Hybrid (Recommended)
|
|
|
|
Combine both:
|
|
1. **On session start**: Sync commits via agentctl
|
|
2. **On page refresh (if session running)**: Re-sync via agentctl
|
|
3. **If session not running**: Return stored commits (stale but acceptable)
|
|
|
|
This provides:
|
|
- Fresh data when session is active
|
|
- Best-effort data when session is stopped
|
|
- User understands "refresh while running" = fresh data
|
|
|
|
## Recommended Implementation (Option C - Hybrid)
|
|
|
|
### 1. New agentctl Endpoint
|
|
|
|
```go
|
|
// In agentctl/server/api/git.go
|
|
|
|
// GET /api/v1/git/log?base_commit=<sha>
|
|
// Returns all commits from base_commit to HEAD
|
|
func (s *Server) handleGitLog(c *gin.Context) {
|
|
baseCommit := c.Query("base_commit")
|
|
if baseCommit == "" {
|
|
c.JSON(400, gin.H{"error": "base_commit is required"})
|
|
return
|
|
}
|
|
|
|
commits, err := s.procMgr.GetGitOperator().Log(c.Request.Context(), baseCommit)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"commits": commits})
|
|
}
|
|
```
|
|
|
|
### 2. GitOperator.Log Method
|
|
|
|
```go
|
|
// In agentctl/server/process/git.go
|
|
|
|
type CommitInfo struct {
|
|
SHA string `json:"sha"`
|
|
ParentSHA string `json:"parent_sha"`
|
|
AuthorName string `json:"author_name"`
|
|
AuthorEmail string `json:"author_email"`
|
|
Message string `json:"message"`
|
|
CommittedAt time.Time `json:"committed_at"`
|
|
FilesChanged int `json:"files_changed"`
|
|
Insertions int `json:"insertions"`
|
|
Deletions int `json:"deletions"`
|
|
}
|
|
|
|
func (g *GitOperator) Log(ctx context.Context, baseCommit string) ([]*CommitInfo, error) {
|
|
// git log --format="%H|%P|%an|%ae|%s|%aI" --numstat base_commit..HEAD
|
|
cmd := exec.CommandContext(ctx, "git", "log",
|
|
"--format=%H|%P|%an|%ae|%s|%aI",
|
|
"--numstat",
|
|
baseCommit+"..HEAD")
|
|
cmd.Dir = g.workDir
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseGitLog(string(output))
|
|
}
|
|
```
|
|
|
|
### 3. agentctl Client Method
|
|
|
|
```go
|
|
// In agentctl/client/client.go
|
|
|
|
func (c *Client) GitLog(ctx context.Context, baseCommit string) ([]*CommitInfo, error) {
|
|
resp, err := c.get(ctx, "/api/v1/git/log?base_commit="+url.QueryEscape(baseCommit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result struct {
|
|
Commits []*CommitInfo `json:"commits"`
|
|
}
|
|
if err := json.Unmarshal(resp, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Commits, nil
|
|
}
|
|
```
|
|
|
|
### 4. Handler with Sync Logic
|
|
|
|
```go
|
|
// In task/handlers/task_handlers.go
|
|
|
|
func (h *TaskHandlers) wsGetSessionCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsGetSessionCommitsRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
|
|
// Try to sync if session is running
|
|
if err := h.syncCommitsIfRunning(ctx, req.SessionID); err != nil {
|
|
h.logger.Warn("commit sync failed", zap.Error(err), zap.String("session_id", req.SessionID))
|
|
// Non-fatal - continue with stored commits
|
|
}
|
|
|
|
commits, err := h.controller.GetSessionCommits(ctx, req.SessionID)
|
|
if err != nil {
|
|
h.logger.Error("failed to get session commits", zap.Error(err), zap.String("session_id", req.SessionID))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get session commits", nil)
|
|
}
|
|
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
|
"session_id": req.SessionID,
|
|
"commits": commits,
|
|
})
|
|
}
|
|
|
|
func (h *TaskHandlers) syncCommitsIfRunning(ctx context.Context, sessionID string) error {
|
|
// 1. Check if session has running agentctl
|
|
execution, found := h.lifecycle.GetExecutionBySessionID(sessionID)
|
|
if !found {
|
|
return nil // Not running, skip sync
|
|
}
|
|
client := execution.GetAgentCtlClient()
|
|
if client == nil {
|
|
return nil // No client, skip sync
|
|
}
|
|
|
|
// 2. Get base commit from first snapshot
|
|
baseCommit, err := h.controller.GetSessionBaseCommit(ctx, sessionID)
|
|
if err != nil || baseCommit == "" {
|
|
return nil // No base commit, skip sync
|
|
}
|
|
|
|
// 3. Get actual commits from agentctl
|
|
actualCommits, err := client.GitLog(ctx, baseCommit)
|
|
if err != nil {
|
|
return fmt.Errorf("git log failed: %w", err)
|
|
}
|
|
|
|
// 4. Sync with DB
|
|
return h.controller.SyncSessionCommits(ctx, sessionID, actualCommits)
|
|
}
|
|
```
|
|
|
|
### 5. Controller Sync Method
|
|
|
|
```go
|
|
// In task/controller/task.go
|
|
|
|
func (c *TaskController) SyncSessionCommits(ctx context.Context, sessionID string, actual []*CommitInfo) error {
|
|
return c.service.SyncSessionCommits(ctx, sessionID, actual)
|
|
}
|
|
|
|
func (c *TaskController) GetSessionBaseCommit(ctx context.Context, sessionID string) (string, error) {
|
|
snapshot, err := c.service.GetFirstGitSnapshot(ctx, sessionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return snapshot.BaseCommit, nil
|
|
}
|
|
```
|
|
|
|
### 6. Service Sync Method
|
|
|
|
```go
|
|
// In task/service/service.go
|
|
|
|
func (s *Service) SyncSessionCommits(ctx context.Context, sessionID string, actual []*CommitInfo) error {
|
|
stored, err := s.repo.GetSessionCommits(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
storedSHAs := make(map[string]*models.SessionCommit)
|
|
for _, c := range stored {
|
|
storedSHAs[c.CommitSHA] = c
|
|
}
|
|
|
|
actualSHAs := make(map[string]bool)
|
|
for _, c := range actual {
|
|
actualSHAs[c.SHA] = true
|
|
}
|
|
|
|
// Add commits in git but not in DB
|
|
for _, c := range actual {
|
|
if _, exists := storedSHAs[c.SHA]; !exists {
|
|
commit := &models.SessionCommit{
|
|
SessionID: sessionID,
|
|
CommitSHA: c.SHA,
|
|
ParentSHA: c.ParentSHA,
|
|
AuthorName: c.AuthorName,
|
|
AuthorEmail: c.AuthorEmail,
|
|
CommitMessage: c.Message,
|
|
CommittedAt: c.CommittedAt,
|
|
FilesChanged: c.FilesChanged,
|
|
Insertions: c.Insertions,
|
|
Deletions: c.Deletions,
|
|
}
|
|
if err := s.repo.CreateSessionCommit(ctx, commit); err != nil {
|
|
return fmt.Errorf("failed to add commit %s: %w", c.SHA, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove commits in DB but not in git
|
|
for sha, commit := range storedSHAs {
|
|
if !actualSHAs[sha] {
|
|
if err := s.repo.DeleteSessionCommit(ctx, commit.ID); err != nil {
|
|
return fmt.Errorf("failed to remove commit %s: %w", sha, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### Abstraction Layer Assessment
|
|
|
|
| Layer | Current Responsibility | Added Responsibility | Breaks Abstraction? |
|
|
|-------|------------------------|----------------------|---------------------|
|
|
| agentctl | Workspace operations | GitLog endpoint | No - fits existing pattern |
|
|
| Repository | Data access (CRUD) | DeleteSessionCommit | No - standard CRUD |
|
|
| Service | Business logic | SyncSessionCommits | No - business logic |
|
|
| Controller | Request/response mapping | GetSessionBaseCommit, SyncSessionCommits | No - orchestration |
|
|
| Handler | WS/HTTP routing | syncCommitsIfRunning | Minor - but has lifecycle access |
|
|
|
|
**Verdict**: This follows existing patterns where handlers access lifecycle manager for agentctl operations (see `agent/handlers/git_handlers.go`). The sync logic is appropriately distributed across layers.
|
|
|
|
## Edge Cases
|
|
|
|
1. **Session not running** - No agentctl available; return stored commits (stale but acceptable)
|
|
2. **No first snapshot** - Cannot determine base commit; skip sync
|
|
3. **Git command fails** - Log warning, return existing commits
|
|
4. **Concurrent requests** - Use session-level mutex or idempotent operations
|
|
5. **Rewritten history (reset, rebase, amend)** - Handled by removing orphaned commits
|
|
6. **Force push from remote** - If user pulls rewritten history, sync will update accordingly
|
|
7. **Merge commits** - Handled normally; parent_sha may have multiple parents
|
|
8. **Container restarted** - agentctl restarts, sync works on next request
|
|
|
|
## Required Repository Changes
|
|
|
|
A new method is needed to delete commits:
|
|
|
|
```go
|
|
// In task/repository/interface.go
|
|
DeleteSessionCommit(ctx context.Context, id string) error
|
|
|
|
// In task/repository/sqlite/git_snapshots.go
|
|
func (r *Repository) DeleteSessionCommit(ctx context.Context, id string) error {
|
|
_, err := r.db.ExecContext(ctx, `DELETE FROM task_session_commits WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
```
|
|
|
|
## Implementation Summary
|
|
|
|
| Component | Changes |
|
|
|-----------|---------|
|
|
| `agentctl/server/api` | Add `GET /api/v1/git/log` endpoint |
|
|
| `agentctl/server/process/git.go` | Add `GitOperator.Log()` method |
|
|
| `agentctl/client/client.go` | Add `GitLog()` client method |
|
|
| `task/repository/interface.go` | Add `DeleteSessionCommit()` |
|
|
| `task/repository/sqlite` | Implement `DeleteSessionCommit()` |
|
|
| `task/service/service.go` | Add `SyncSessionCommits()` |
|
|
| `task/controller/task.go` | Add `SyncSessionCommits()`, `GetSessionBaseCommit()` |
|
|
| `task/handlers/task_handlers.go` | Add `syncCommitsIfRunning()`, modify `wsGetSessionCommits` |
|
|
|
|
## Conclusion
|
|
|
|
**Yes, this is feasible without breaking abstraction layers.**
|
|
|
|
The implementation requires routing through agentctl since the workspace may be in a container or remote environment. The sync happens:
|
|
1. **On page refresh** - If session is running, sync via agentctl before returning commits
|
|
2. **If session stopped** - Return stored commits (user understands they may be stale)
|
|
|
|
This is a "lazy sync" pattern - we only sync when data is requested AND the session is running. The sync is **bidirectional**:
|
|
- Commits added manually → appear in UI after refresh (if session running)
|
|
- Commits removed (reset/rebase/amend) → disappear from UI after refresh (if session running)
|
|
|
|
**Limitation**: If the session is stopped, manual git operations won't sync until the session is started again. This is acceptable given the constraint that agentctl must be running to access the workspace.
|
|
|