Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions backend/internal/adapters/workspace/gitworktree/gitops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package gitworktree

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

// GitOps implements ports.WorkspaceGit over the git CLI. It shares the
// adapter's exec conventions (runCommand) but operates inside an existing
// session workspace rather than managing worktree lifecycles.
type GitOps struct {
gitBinary string
}

// NewGitOps returns a WorkspaceGit adapter using the `git` on PATH.
func NewGitOps() *GitOps {
return &GitOps{gitBinary: defaultGitBinary}
}

var _ ports.WorkspaceGit = (*GitOps)(nil)

func (g *GitOps) git(ctx context.Context, dir string, args ...string) ([]byte, error) {
return runCommand(ctx, g.gitBinary, append([]string{"-C", dir}, args...)...)
}

// Status reports the workspace branch and its uncommitted files. Line counts
// come from `diff --numstat` for tracked files and a direct line count for
// untracked ones; exotic paths that fail the numstat lookup degrade to 0/0
// rather than failing the whole status.
func (g *GitOps) Status(ctx context.Context, path string) (ports.GitStatus, error) {
branchOut, err := g.git(ctx, path, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return ports.GitStatus{}, fmt.Errorf("gitops: resolve branch: %w", err)
}
branch := strings.TrimSpace(string(branchOut))

porcelain, err := g.git(ctx, path, "status", "--porcelain", "-z")
if err != nil {
return ports.GitStatus{}, fmt.Errorf("gitops: status: %w", err)
}

// One numstat against HEAD covers staged and unstaged edits together. It
// can fail legitimately (unborn HEAD); porcelain still lists files then.
counts := map[string][2]int{}
if out, err := g.git(ctx, path, "diff", "--numstat", "HEAD", "--"); err == nil {
mergeNumstat(counts, out)
}

status := ports.GitStatus{Branch: branch, Files: []ports.GitFileChange{}}
entries := strings.Split(string(porcelain), "\x00")
for i := 0; i < len(entries); i++ {
entry := entries[i]
if len(entry) < 4 {
continue
}
xy, filePath := entry[:2], entry[3:]
// Renames/copies carry the original path as the next NUL entry.
if xy[0] == 'R' || xy[0] == 'C' {
i++
}
change := ports.GitFileChange{
Path: filePath,
Staged: xy[0] != ' ' && xy[0] != '?',
}
if c, ok := counts[filePath]; ok {
change.Additions, change.Deletions = c[0], c[1]
} else if xy == "??" {
change.Additions = countFileLines(filepath.Join(path, filePath))
}
status.Files = append(status.Files, change)
}
return status, nil
}

// StageAll stages every change in the workspace, including untracked files.
func (g *GitOps) StageAll(ctx context.Context, path string) error {
if _, err := g.git(ctx, path, "add", "-A"); err != nil {
return fmt.Errorf("gitops: stage all: %w", err)
}
return nil
}

// DiscardAll resets tracked files to HEAD and removes untracked files and
// directories. Destructive by design; callers own the confirmation UX.
//
// Gitignored files deliberately survive (`clean -fd`, not `-fdx`): discard
// means "drop my changes", not "wipe the workspace" — node_modules, build
// caches, and the gitignored AO session/hook files must keep the worktree
// usable after a discard.
func (g *GitOps) DiscardAll(ctx context.Context, path string) error {
if _, err := g.git(ctx, path, "reset", "--hard", "HEAD"); err != nil {
return fmt.Errorf("gitops: reset: %w", err)
}
if _, err := g.git(ctx, path, "clean", "-fd"); err != nil {
return fmt.Errorf("gitops: clean: %w", err)
}
return nil
}

// CommitAll stages everything, commits, and (optionally) pushes the branch to
// the repo's remote, preferring "origin" when several exist. A push request
// against a remoteless repo returns ErrGitNoRemote with the commit SHA in the
// message — the commit itself has already landed.
func (g *GitOps) CommitAll(ctx context.Context, path, message string, push bool) (ports.GitCommitResult, error) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
porcelain, err := g.git(ctx, path, "status", "--porcelain")
if err != nil {
return ports.GitCommitResult{}, fmt.Errorf("gitops: status: %w", err)
}
if strings.TrimSpace(string(porcelain)) == "" {
return ports.GitCommitResult{}, ports.ErrGitNothingToCommit
}

if err := g.StageAll(ctx, path); err != nil {
return ports.GitCommitResult{}, err
}
if _, err := g.git(ctx, path, "commit", "-m", message); err != nil {
return ports.GitCommitResult{}, fmt.Errorf("gitops: commit: %w", err)
}

shaOut, err := g.git(ctx, path, "rev-parse", "HEAD")
if err != nil {
return ports.GitCommitResult{}, fmt.Errorf("gitops: resolve sha: %w", err)
}
branchOut, err := g.git(ctx, path, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return ports.GitCommitResult{}, fmt.Errorf("gitops: resolve branch: %w", err)
}
result := ports.GitCommitResult{
SHA: strings.TrimSpace(string(shaOut)),
Branch: strings.TrimSpace(string(branchOut)),
}
if !push {
return result, nil
}

remote, err := g.pickRemote(ctx, path)
if err != nil {
return result, fmt.Errorf("gitops: committed %s: %w", result.SHA, err)
}
if _, err := g.git(ctx, path, "push", "--set-upstream", remote, "HEAD"); err != nil {
return result, fmt.Errorf("gitops: committed %s, push failed: %w", result.SHA, err)
}
result.Pushed = true
return result, nil
}

func (g *GitOps) pickRemote(ctx context.Context, path string) (string, error) {
out, err := g.git(ctx, path, "remote")
if err != nil {
return "", fmt.Errorf("list remotes: %w", err)
}
remotes := strings.Fields(string(out))
if len(remotes) == 0 {
return "", ports.ErrGitNoRemote
}
for _, remote := range remotes {
if remote == "origin" {
return remote, nil
}
}
return remotes[0], nil
}

// mergeNumstat folds `diff --numstat` output ("adds\tdels\tpath" per line)
// into counts, summing across calls. Binary files report "-" and count as 0.
func mergeNumstat(counts map[string][2]int, out []byte) {
for _, line := range strings.Split(string(out), "\n") {
parts := strings.SplitN(line, "\t", 3)
if len(parts) != 3 {
continue
}
adds, _ := strconv.Atoi(parts[0])
dels, _ := strconv.Atoi(parts[1])
existing := counts[parts[2]]
counts[parts[2]] = [2]int{existing[0] + adds, existing[1] + dels}
}
}

// countFileLines sizes an untracked file's "+N" the way numstat would once
// staged. Unreadable or oversized files degrade to 0 rather than erroring.
const maxCountableFileBytes = 4 << 20

func countFileLines(path string) int {
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() > maxCountableFileBytes {
return 0
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return 0
}
lines := bytes.Count(data, []byte("\n"))
if data[len(data)-1] != '\n' {
lines++
}
return lines
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package gitworktree

import (
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

func TestGitOpsIntegrationStatusCommitPush(t *testing.T) {
git := requireGit(t)
tmp := t.TempDir()
repo := setupOriginClone(t, git, tmp)
runGit(t, git, repo, "config", "user.email", "ao@example.com")
runGit(t, git, repo, "config", "user.name", "Ao Agents")

ops := NewGitOps()
ctx := context.Background()

// Clean workspace: branch resolved, no files, commit refuses.
status, err := ops.Status(ctx, repo)
if err != nil {
t.Fatalf("status clean: %v", err)
}
if status.Branch != "main" || len(status.Files) != 0 {
t.Fatalf("clean status = %+v", status)
}
if _, err := ops.CommitAll(ctx, repo, "noop", false); !errors.Is(err, ports.ErrGitNothingToCommit) {
t.Fatalf("commit on clean tree = %v, want ErrGitNothingToCommit", err)
}

// Tracked edit + untracked file show up with line counts and staged flags.
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("seed\nmore\n"), 0o644); err != nil {
t.Fatalf("edit README: %v", err)
}
if err := os.WriteFile(filepath.Join(repo, "notes.txt"), []byte("a\nb\nc\n"), 0o644); err != nil {
t.Fatalf("write notes: %v", err)
}
status, err = ops.Status(ctx, repo)
if err != nil {
t.Fatalf("status dirty: %v", err)
}
if len(status.Files) != 2 {
t.Fatalf("dirty files = %+v", status.Files)
}
byPath := map[string]ports.GitFileChange{}
for _, f := range status.Files {
byPath[f.Path] = f
}
if f := byPath["README.md"]; f.Additions != 1 || f.Staged {
t.Fatalf("README change = %+v", f)
}
if f := byPath["notes.txt"]; f.Additions != 3 || f.Staged {
t.Fatalf("notes change = %+v", f)
}

// StageAll flips the staged flag.
if err := ops.StageAll(ctx, repo); err != nil {
t.Fatalf("stage all: %v", err)
}
status, err = ops.Status(ctx, repo)
if err != nil {
t.Fatalf("status staged: %v", err)
}
for _, f := range status.Files {
if !f.Staged {
t.Fatalf("file not staged after StageAll: %+v", f)
}
}

// CommitAll with push lands on the origin remote.
result, err := ops.CommitAll(ctx, repo, "feat: rail test", true)
if err != nil {
t.Fatalf("commit+push: %v", err)
}
if result.SHA == "" || result.Branch != "main" || !result.Pushed {
t.Fatalf("commit result = %+v", result)
}
status, err = ops.Status(ctx, repo)
if err != nil {
t.Fatalf("status after commit: %v", err)
}
if len(status.Files) != 0 {
t.Fatalf("files after commit = %+v", status.Files)
}
}

func TestGitOpsIntegrationDiscardAll(t *testing.T) {
git := requireGit(t)
tmp := t.TempDir()
repo := setupOriginClone(t, git, tmp)

ops := NewGitOps()
ctx := context.Background()

if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("clobbered\n"), 0o644); err != nil {
t.Fatalf("edit README: %v", err)
}
if err := os.WriteFile(filepath.Join(repo, "junk.txt"), []byte("junk\n"), 0o644); err != nil {
t.Fatalf("write junk: %v", err)
}

if err := ops.DiscardAll(ctx, repo); err != nil {
t.Fatalf("discard: %v", err)
}
status, err := ops.Status(ctx, repo)
if err != nil {
t.Fatalf("status after discard: %v", err)
}
if len(status.Files) != 0 {
t.Fatalf("files after discard = %+v", status.Files)
}
contents, err := os.ReadFile(filepath.Join(repo, "README.md"))
if err != nil || string(contents) != "seed\n" {
t.Fatalf("README after discard = %q, %v", contents, err)
}
if _, err := os.Stat(filepath.Join(repo, "junk.txt")); !os.IsNotExist(err) {
t.Fatalf("junk.txt still present after discard: %v", err)
}
}

func TestGitOpsIntegrationCommitPushWithoutRemote(t *testing.T) {
git := requireGit(t)
tmp := t.TempDir()
repo := filepath.Join(tmp, "local")
run(t, git, "init", repo)
runGit(t, git, repo, "config", "user.email", "ao@example.com")
runGit(t, git, repo, "config", "user.name", "Ao Agents")
if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
runGit(t, git, repo, "add", "-A")
runGit(t, git, repo, "commit", "-m", "seed")

if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a\nb\n"), 0o644); err != nil {
t.Fatalf("edit: %v", err)
}

ops := NewGitOps()
result, err := ops.CommitAll(context.Background(), repo, "feat: local", true)
if !errors.Is(err, ports.ErrGitNoRemote) {
t.Fatalf("push without remote = %v, want ErrGitNoRemote", err)
}
// The commit itself landed; only the push was impossible.
if result.SHA == "" || result.Pushed {
t.Fatalf("result = %+v", result)
}
}
16 changes: 16 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string,
return sessionsvc.ClaimPRResult{}, nil
}

func (f *fakeSessionService) GitStatus(context.Context, domain.SessionID) (ports.GitStatus, error) {
return ports.GitStatus{}, nil
}

func (f *fakeSessionService) GitStageAll(context.Context, domain.SessionID) error {
return nil
}

func (f *fakeSessionService) GitDiscardAll(context.Context, domain.SessionID) error {
return nil
}

func (f *fakeSessionService) GitCommitAll(context.Context, domain.SessionID, string, bool) (ports.GitCommitResult, error) {
return ports.GitCommitResult{}, nil
}

// fakeProjectManager captures the project.AddInput the controller decodes from
// the CLI's request body. Every other method is a no-op so it satisfies the
// projectsvc.Manager interface.
Expand Down
1 change: 1 addition & 0 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store,
// no_signal only makes sense for harnesses whose adapters install
// activity hooks; the deriver registry is the source of truth for that.
SignalCapable: activitydispatch.SupportsHarness,
WorkspaceGit: gitworktree.NewGitOps(),
}), nil
}

Expand Down
3 changes: 3 additions & 0 deletions backend/internal/domain/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,7 @@ type Session struct {
SessionRecord
Status SessionStatus `json:"status"`
TerminalHandleID string `json:"terminalHandleId,omitempty"`
// Branch is the session's worktree branch, surfaced from Metadata (which
// stays internal) so the UI's git rail can label the workspace.
Branch string `json:"branch,omitempty"`
}
Loading
Loading