Detailed changes
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
+ "shelley.exe.dev/gitstate"
"shelley.exe.dev/llm"
)
@@ -96,7 +97,16 @@ func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut
c.OnChange(targetPath)
}
+ // Check git status for the new directory
+ state := gitstate.GetGitState(targetPath)
+ var resultText string
+ if state.IsRepo {
+ resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s)", targetPath, state.Worktree)
+ } else {
+ resultText = fmt.Sprintf("Changed working directory to: %s\n\nNot in a git repository.", targetPath)
+ }
+
return llm.ToolOut{
- LLMContent: llm.TextContent(fmt.Sprintf("Changed working directory to: %s", targetPath)),
+ LLMContent: llm.TextContent(resultText),
}
}
@@ -53,6 +53,7 @@ func TS() *go2ts.Go2TS {
db.MessageTypeTool,
db.MessageTypeError,
db.MessageTypeSystem,
+ db.MessageTypeGitInfo,
},
)
@@ -333,11 +333,12 @@ func (db *DB) UpdateConversationCwd(ctx context.Context, conversationID, cwd str
type MessageType string
const (
- MessageTypeUser MessageType = "user"
- MessageTypeAgent MessageType = "agent"
- MessageTypeTool MessageType = "tool"
- MessageTypeSystem MessageType = "system"
- MessageTypeError MessageType = "error"
+ MessageTypeUser MessageType = "user"
+ MessageTypeAgent MessageType = "agent"
+ MessageTypeTool MessageType = "tool"
+ MessageTypeSystem MessageType = "system"
+ MessageTypeError MessageType = "error"
+ MessageTypeGitInfo MessageType = "gitinfo" // user-visible only, not sent to LLM
)
// CreateMessageParams contains parameters for creating a message
@@ -0,0 +1,32 @@
+-- Add 'gitinfo' to the message type check constraint
+-- This requires dropping and recreating the messages table with the new constraint
+-- SQLite doesn't support ALTER TABLE to modify CHECK constraints
+
+-- Step 1: Create a new messages table with the updated constraint
+CREATE TABLE messages_new (
+ message_id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL,
+ sequence_id INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system', 'error', 'gitinfo')),
+ llm_data TEXT, -- JSON data sent to/from LLM
+ user_data TEXT, -- JSON data for UI display
+ usage_data TEXT, -- JSON data about token usage, etc.
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ display_data TEXT, -- JSON data for display purposes
+ FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old table to new table
+INSERT INTO messages_new (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data)
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages;
+
+-- Step 3: Drop the old table
+DROP TABLE messages;
+
+-- Step 4: Rename the new table
+ALTER TABLE messages_new RENAME TO messages;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);
@@ -0,0 +1,112 @@
+// Package gitstate provides utilities for tracking git repository state.
+package gitstate
+
+import (
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// GitState represents the current state of a git repository.
+type GitState struct {
+ // Worktree is the absolute path to the worktree root.
+ // For regular repos, this is the same as the git root.
+ // For worktrees, this is the worktree directory.
+ Worktree string
+
+ // Branch is the current branch name, or empty if detached HEAD.
+ Branch string
+
+ // Commit is the current commit hash (short form).
+ Commit string
+
+ // Subject is the commit message subject line.
+ Subject string
+
+ // IsRepo is true if the directory is inside a git repository.
+ IsRepo bool
+}
+
+// GetGitState returns the git state for the given directory.
+// If dir is empty, uses the current working directory.
+func GetGitState(dir string) *GitState {
+ state := &GitState{}
+
+ // Get the worktree root (this works for both regular repos and worktrees)
+ cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ output, err := cmd.Output()
+ if err != nil {
+ // Not in a git repository
+ return state
+ }
+ state.IsRepo = true
+ state.Worktree = strings.TrimSpace(string(output))
+
+ // Get the current commit hash (short form)
+ cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ output, err = cmd.Output()
+ if err == nil {
+ state.Commit = strings.TrimSpace(string(output))
+ }
+
+ // Get the commit subject line
+ cmd = exec.Command("git", "log", "-1", "--format=%s")
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ output, err = cmd.Output()
+ if err == nil {
+ state.Subject = strings.TrimSpace(string(output))
+ }
+
+ // Get the current branch name
+ // First try symbolic-ref for normal branches
+ cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD")
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ output, err = cmd.Output()
+ if err == nil {
+ state.Branch = strings.TrimSpace(string(output))
+ }
+ // If symbolic-ref fails, we're in detached HEAD state - branch stays empty
+
+ return state
+}
+
+// Equal returns true if two git states are equal.
+func (g *GitState) Equal(other *GitState) bool {
+ if g == nil && other == nil {
+ return true
+ }
+ if g == nil || other == nil {
+ return false
+ }
+ return g.Worktree == other.Worktree &&
+ g.Branch == other.Branch &&
+ g.Commit == other.Commit &&
+ g.Subject == other.Subject &&
+ g.IsRepo == other.IsRepo
+}
+
+// String returns a human-readable description of the git state change.
+// It's designed to be shown to users, not the LLM.
+func (g *GitState) String() string {
+ if g == nil || !g.IsRepo {
+ return ""
+ }
+
+ // Get just the worktree name (last path component)
+ worktreeName := filepath.Base(g.Worktree)
+
+ if g.Branch != "" {
+ return worktreeName + "/" + g.Branch + " now at " + g.Commit
+ }
+ return worktreeName + " (detached) now at " + g.Commit
+}
@@ -0,0 +1,225 @@
+package gitstate
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestGetGitState_NotARepo(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ state := GetGitState(tmpDir)
+
+ if state.IsRepo {
+ t.Error("expected IsRepo to be false for non-repo directory")
+ }
+ if state.Worktree != "" {
+ t.Errorf("expected empty Worktree, got %q", state.Worktree)
+ }
+ if state.Branch != "" {
+ t.Errorf("expected empty Branch, got %q", state.Branch)
+ }
+ if state.Commit != "" {
+ t.Errorf("expected empty Commit, got %q", state.Commit)
+ }
+}
+
+func TestGetGitState_RegularRepo(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Initialize a git repo
+ runGit(t, tmpDir, "init")
+ runGit(t, tmpDir, "config", "user.email", "test@test.com")
+ runGit(t, tmpDir, "config", "user.name", "Test")
+
+ // Create a commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, tmpDir, "add", ".")
+ runGit(t, tmpDir, "commit", "-m", "initial")
+
+ state := GetGitState(tmpDir)
+
+ if !state.IsRepo {
+ t.Error("expected IsRepo to be true")
+ }
+ if state.Worktree != tmpDir {
+ t.Errorf("expected Worktree %q, got %q", tmpDir, state.Worktree)
+ }
+ // Default branch might be master or main depending on git config
+ if state.Branch != "master" && state.Branch != "main" {
+ t.Errorf("expected Branch 'master' or 'main', got %q", state.Branch)
+ }
+ if state.Commit == "" {
+ t.Error("expected non-empty Commit")
+ }
+ if len(state.Commit) < 7 {
+ t.Errorf("expected short commit hash, got %q", state.Commit)
+ }
+}
+
+func TestGetGitState_Worktree(t *testing.T) {
+ tmpDir := t.TempDir()
+ mainRepo := filepath.Join(tmpDir, "main")
+ worktreeDir := filepath.Join(tmpDir, "worktree")
+
+ // Create main repo
+ if err := os.MkdirAll(mainRepo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, mainRepo, "init")
+ runGit(t, mainRepo, "config", "user.email", "test@test.com")
+ runGit(t, mainRepo, "config", "user.name", "Test")
+
+ // Create initial commit
+ testFile := filepath.Join(mainRepo, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, mainRepo, "add", ".")
+ runGit(t, mainRepo, "commit", "-m", "initial")
+
+ // Create a worktree on a new branch
+ runGit(t, mainRepo, "worktree", "add", "-b", "feature", worktreeDir)
+
+ // Check state in main repo
+ mainState := GetGitState(mainRepo)
+ if !mainState.IsRepo {
+ t.Error("expected main repo IsRepo to be true")
+ }
+ if mainState.Worktree != mainRepo {
+ t.Errorf("expected main Worktree %q, got %q", mainRepo, mainState.Worktree)
+ }
+
+ // Check state in worktree
+ worktreeState := GetGitState(worktreeDir)
+ if !worktreeState.IsRepo {
+ t.Error("expected worktree IsRepo to be true")
+ }
+ if worktreeState.Worktree != worktreeDir {
+ t.Errorf("expected worktree Worktree %q, got %q", worktreeDir, worktreeState.Worktree)
+ }
+ if worktreeState.Branch != "feature" {
+ t.Errorf("expected worktree Branch 'feature', got %q", worktreeState.Branch)
+ }
+
+ // Both should have the same commit (initially)
+ if mainState.Commit != worktreeState.Commit {
+ t.Errorf("expected same commit, got main=%q worktree=%q", mainState.Commit, worktreeState.Commit)
+ }
+}
+
+func TestGetGitState_DetachedHead(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Initialize and create commits
+ runGit(t, tmpDir, "init")
+ runGit(t, tmpDir, "config", "user.email", "test@test.com")
+ runGit(t, tmpDir, "config", "user.name", "Test")
+
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, tmpDir, "add", ".")
+ runGit(t, tmpDir, "commit", "-m", "initial")
+
+ // Get the commit hash
+ commit := strings.TrimSpace(runGitOutput(t, tmpDir, "rev-parse", "HEAD"))
+
+ // Checkout to detached HEAD
+ runGit(t, tmpDir, "checkout", commit)
+
+ state := GetGitState(tmpDir)
+
+ if !state.IsRepo {
+ t.Error("expected IsRepo to be true")
+ }
+ if state.Branch != "" {
+ t.Errorf("expected empty Branch for detached HEAD, got %q", state.Branch)
+ }
+ if state.Commit == "" {
+ t.Error("expected non-empty Commit")
+ }
+}
+
+func TestGitState_Equal(t *testing.T) {
+ tests := []struct {
+ name string
+ a *GitState
+ b *GitState
+ expected bool
+ }{
+ {"both nil", nil, nil, true},
+ {"one nil", &GitState{}, nil, false},
+ {"other nil", nil, &GitState{}, false},
+ {"both empty", &GitState{}, &GitState{}, true},
+ {"same values", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, true},
+ {"different worktree", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/bar", Branch: "main", Commit: "abc123", IsRepo: true}, false},
+ {"different branch", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "dev", Commit: "abc123", IsRepo: true}, false},
+ {"different commit", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "def456", IsRepo: true}, false},
+ {"different IsRepo", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", IsRepo: false}, false},
+ {"different subject", &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", Subject: "fix bug", IsRepo: true}, &GitState{Worktree: "/foo", Branch: "main", Commit: "abc123", Subject: "add feature", IsRepo: true}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.a.Equal(tt.b); got != tt.expected {
+ t.Errorf("Equal() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGitState_String(t *testing.T) {
+ tests := []struct {
+ name string
+ state *GitState
+ expected string
+ }{
+ {"nil state", nil, ""},
+ {"not a repo", &GitState{IsRepo: false}, ""},
+ {"with branch", &GitState{Worktree: "/home/user/myrepo", Branch: "main", Commit: "abc1234", IsRepo: true}, "myrepo/main now at abc1234"},
+ {"detached head", &GitState{Worktree: "/home/user/myrepo", Branch: "", Commit: "abc1234", IsRepo: true}, "myrepo (detached) now at abc1234"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.state.String(); got != tt.expected {
+ t.Errorf("String() = %q, want %q", got, tt.expected)
+ }
+ })
+ }
+}
+
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ // For commits, use --no-verify to skip hooks
+ if len(args) > 0 && args[0] == "commit" {
+ newArgs := []string{"commit", "--no-verify"}
+ newArgs = append(newArgs, args[1:]...)
+ args = newArgs
+ }
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, output)
+ }
+}
+
+func runGitOutput(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ output, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("git %v failed: %v", args, err)
+ }
+ return string(output)
+}
@@ -8,36 +8,49 @@ import (
"time"
"shelley.exe.dev/claudetool"
+ "shelley.exe.dev/gitstate"
"shelley.exe.dev/llm"
)
// MessageRecordFunc is called to record new messages to persistent storage
type MessageRecordFunc func(ctx context.Context, message llm.Message, usage llm.Usage) error
+// GitStateChangeFunc is called when the git state changes at the end of a turn.
+// This is used to record user-visible notifications about git changes.
+type GitStateChangeFunc func(ctx context.Context, state *gitstate.GitState)
+
// Config contains all configuration needed to create a Loop
type Config struct {
- LLM llm.Service
- History []llm.Message
- Tools []*llm.Tool
- RecordMessage MessageRecordFunc
- Logger *slog.Logger
- System []llm.SystemContent
- WorkingDir string // working directory for tools
+ LLM llm.Service
+ History []llm.Message
+ Tools []*llm.Tool
+ RecordMessage MessageRecordFunc
+ Logger *slog.Logger
+ System []llm.SystemContent
+ WorkingDir string // working directory for tools
+ OnGitStateChange GitStateChangeFunc
+ // GetWorkingDir returns the current working directory for tools.
+ // If set, this is called at end of turn to check for git state changes.
+ // If nil, Config.WorkingDir is used as a static value.
+ GetWorkingDir func() string
}
// Loop manages a conversation turn with an LLM including tool execution and message recording.
// Notably, when the turn ends, the "Loop" is over. TODO: maybe rename to Turn?
type Loop struct {
- llm llm.Service
- tools []*llm.Tool
- recordMessage MessageRecordFunc
- history []llm.Message
- messageQueue []llm.Message
- totalUsage llm.Usage
- mu sync.Mutex
- logger *slog.Logger
- system []llm.SystemContent
- workingDir string
+ llm llm.Service
+ tools []*llm.Tool
+ recordMessage MessageRecordFunc
+ history []llm.Message
+ messageQueue []llm.Message
+ totalUsage llm.Usage
+ mu sync.Mutex
+ logger *slog.Logger
+ system []llm.SystemContent
+ workingDir string
+ onGitStateChange GitStateChangeFunc
+ getWorkingDir func() string
+ lastGitState *gitstate.GitState
}
// NewLoop creates a new Loop instance with the provided configuration
@@ -47,15 +60,25 @@ func NewLoop(config Config) *Loop {
logger = slog.Default()
}
+ // Get initial git state
+ workingDir := config.WorkingDir
+ if config.GetWorkingDir != nil {
+ workingDir = config.GetWorkingDir()
+ }
+ initialGitState := gitstate.GetGitState(workingDir)
+
return &Loop{
- llm: config.LLM,
- history: config.History,
- tools: config.Tools,
- recordMessage: config.RecordMessage,
- messageQueue: make([]llm.Message, 0),
- logger: logger,
- system: config.System,
- workingDir: config.WorkingDir,
+ llm: config.LLM,
+ history: config.History,
+ tools: config.Tools,
+ recordMessage: config.RecordMessage,
+ messageQueue: make([]llm.Message, 0),
+ logger: logger,
+ system: config.System,
+ workingDir: config.WorkingDir,
+ onGitStateChange: config.OnGitStateChange,
+ getWorkingDir: config.GetWorkingDir,
+ lastGitState: initialGitState,
}
}
@@ -265,9 +288,49 @@ func (l *Loop) processLLMRequest(ctx context.Context) error {
return l.handleToolCalls(ctx, resp.Content)
}
+ // End of turn - check for git state changes
+ l.checkGitStateChange(ctx)
+
return nil
}
+// checkGitStateChange checks if the git state has changed and calls the callback if so.
+// This is called at the end of each turn.
+func (l *Loop) checkGitStateChange(ctx context.Context) {
+ if l.onGitStateChange == nil {
+ return
+ }
+
+ // Get current working directory
+ workingDir := l.workingDir
+ if l.getWorkingDir != nil {
+ workingDir = l.getWorkingDir()
+ }
+
+ // Get current git state
+ currentState := gitstate.GetGitState(workingDir)
+
+ // Compare with last known state
+ l.mu.Lock()
+ lastState := l.lastGitState
+ l.mu.Unlock()
+
+ // Check if state changed
+ if !currentState.Equal(lastState) {
+ l.mu.Lock()
+ l.lastGitState = currentState
+ l.mu.Unlock()
+
+ if currentState.IsRepo {
+ l.logger.Debug("git state changed",
+ "worktree", currentState.Worktree,
+ "branch", currentState.Branch,
+ "commit", currentState.Commit)
+ l.onGitStateChange(ctx, currentState)
+ }
+ }
+}
+
// handleToolCalls processes tool calls from the LLM response
func (l *Loop) handleToolCalls(ctx context.Context, content []llm.Content) error {
var toolResults []llm.Content
@@ -5,11 +5,14 @@ import (
"encoding/json"
"fmt"
"os"
+ "os/exec"
"path/filepath"
+ "sync"
"testing"
"time"
"shelley.exe.dev/claudetool"
+ "shelley.exe.dev/gitstate"
"shelley.exe.dev/llm"
)
@@ -964,3 +967,200 @@ func TestInsertMissingToolResults_EmptyAssistantContent(t *testing.T) {
}
})
}
+
+func TestGitStateTracking(t *testing.T) {
+ // Create a test repo
+ tmpDir := t.TempDir()
+
+ // Initialize git repo
+ runGit(t, tmpDir, "init")
+ runGit(t, tmpDir, "config", "user.email", "test@test.com")
+ runGit(t, tmpDir, "config", "user.name", "Test")
+
+ // Create initial commit
+ testFile := filepath.Join(tmpDir, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, tmpDir, "add", ".")
+ runGit(t, tmpDir, "commit", "-m", "initial")
+
+ // Track git state changes
+ var mu sync.Mutex
+ var gitStateChanges []*gitstate.GitState
+
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ WorkingDir: tmpDir,
+ GetWorkingDir: func() string { return tmpDir },
+ OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+ mu.Lock()
+ gitStateChanges = append(gitStateChanges, state)
+ mu.Unlock()
+ },
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ },
+ })
+
+ // Verify initial state was captured
+ if loop.lastGitState == nil {
+ t.Fatal("expected initial git state to be captured")
+ }
+ if !loop.lastGitState.IsRepo {
+ t.Error("expected IsRepo to be true")
+ }
+
+ // Process a turn (no state change should occur)
+ loop.QueueUserMessage(llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := loop.ProcessOneTurn(ctx)
+ if err != nil {
+ t.Fatalf("ProcessOneTurn failed: %v", err)
+ }
+
+ // No state change should have occurred
+ mu.Lock()
+ numChanges := len(gitStateChanges)
+ mu.Unlock()
+ if numChanges != 0 {
+ t.Errorf("expected no git state changes, got %d", numChanges)
+ }
+
+ // Now make a commit
+ if err := os.WriteFile(testFile, []byte("updated"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, tmpDir, "add", ".")
+ runGit(t, tmpDir, "commit", "-m", "update")
+
+ // Process another turn - this should detect the commit change
+ loop.QueueUserMessage(llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello again"}},
+ })
+
+ err = loop.ProcessOneTurn(ctx)
+ if err != nil {
+ t.Fatalf("ProcessOneTurn failed: %v", err)
+ }
+
+ // Now a state change should have been detected
+ mu.Lock()
+ numChanges = len(gitStateChanges)
+ mu.Unlock()
+ if numChanges != 1 {
+ t.Errorf("expected 1 git state change, got %d", numChanges)
+ }
+}
+
+func TestGitStateTrackingWorktree(t *testing.T) {
+ tmpDir, err := filepath.EvalSymlinks(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ mainRepo := filepath.Join(tmpDir, "main")
+ worktreeDir := filepath.Join(tmpDir, "worktree")
+
+ // Create main repo
+ if err := os.MkdirAll(mainRepo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, mainRepo, "init")
+ runGit(t, mainRepo, "config", "user.email", "test@test.com")
+ runGit(t, mainRepo, "config", "user.name", "Test")
+
+ // Create initial commit
+ testFile := filepath.Join(mainRepo, "test.txt")
+ if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, mainRepo, "add", ".")
+ runGit(t, mainRepo, "commit", "-m", "initial")
+
+ // Create a worktree
+ runGit(t, mainRepo, "worktree", "add", "-b", "feature", worktreeDir)
+
+ // Track git state changes in the worktree
+ var mu sync.Mutex
+ var gitStateChanges []*gitstate.GitState
+
+ loop := NewLoop(Config{
+ LLM: NewPredictableService(),
+ History: []llm.Message{},
+ WorkingDir: worktreeDir,
+ GetWorkingDir: func() string { return worktreeDir },
+ OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+ mu.Lock()
+ gitStateChanges = append(gitStateChanges, state)
+ mu.Unlock()
+ },
+ RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+ return nil
+ },
+ })
+
+ // Verify initial state
+ if loop.lastGitState == nil {
+ t.Fatal("expected initial git state to be captured")
+ }
+ if loop.lastGitState.Branch != "feature" {
+ t.Errorf("expected branch 'feature', got %q", loop.lastGitState.Branch)
+ }
+ if loop.lastGitState.Worktree != worktreeDir {
+ t.Errorf("expected worktree %q, got %q", worktreeDir, loop.lastGitState.Worktree)
+ }
+
+ // Make a commit in the worktree
+ worktreeFile := filepath.Join(worktreeDir, "feature.txt")
+ if err := os.WriteFile(worktreeFile, []byte("feature content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, worktreeDir, "add", ".")
+ runGit(t, worktreeDir, "commit", "-m", "feature commit")
+
+ // Process a turn to detect the change
+ loop.QueueUserMessage(llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err = loop.ProcessOneTurn(ctx)
+ if err != nil {
+ t.Fatalf("ProcessOneTurn failed: %v", err)
+ }
+
+ mu.Lock()
+ numChanges := len(gitStateChanges)
+ mu.Unlock()
+
+ if numChanges != 1 {
+ t.Errorf("expected 1 git state change in worktree, got %d", numChanges)
+ }
+}
+
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ // For commits, use --no-verify to skip hooks
+ if len(args) > 0 && args[0] == "commit" {
+ newArgs := []string{"commit", "--no-verify"}
+ newArgs = append(newArgs, args[1:]...)
+ args = newArgs
+ }
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, output)
+ }
+}
@@ -11,6 +11,7 @@ import (
"shelley.exe.dev/claudetool"
"shelley.exe.dev/db"
"shelley.exe.dev/db/generated"
+ "shelley.exe.dev/gitstate"
"shelley.exe.dev/llm"
"shelley.exe.dev/loop"
"shelley.exe.dev/subpub"
@@ -217,6 +218,11 @@ func (cm *ConversationManager) partitionMessages(messages []generated.Message) (
var system []llm.SystemContent
for _, msg := range messages {
+ // Skip gitinfo messages - they are user-visible only, not sent to LLM
+ if msg.Type == string(db.MessageTypeGitInfo) {
+ continue
+ }
+
llmMsg, err := convertToLLMMessage(msg)
if err != nil {
cm.logger.Warn("Failed to convert message to LLM format", "messageID", msg.MessageID, "error", err)
@@ -293,6 +299,10 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e
Logger: logger,
System: system,
WorkingDir: cwd,
+ GetWorkingDir: toolSet.WorkingDir().Get,
+ OnGitStateChange: func(ctx context.Context, state *gitstate.GitState) {
+ cm.recordGitStateChange(ctx, state)
+ },
})
cm.mu.Lock()
@@ -481,3 +491,73 @@ func (cm *ConversationManager) CancelConversation(ctx context.Context) error {
return nil
}
+
+// GitInfoUserData is the structured data stored in user_data for gitinfo messages.
+type GitInfoUserData struct {
+ Worktree string `json:"worktree"`
+ Branch string `json:"branch"`
+ Commit string `json:"commit"`
+ Subject string `json:"subject"`
+ Text string `json:"text"` // Human-readable description
+}
+
+// recordGitStateChange creates a gitinfo message when git state changes.
+// This message is visible to users in the UI but is not sent to the LLM.
+func (cm *ConversationManager) recordGitStateChange(ctx context.Context, state *gitstate.GitState) {
+ if state == nil || !state.IsRepo {
+ return
+ }
+
+ // Create a gitinfo message with the state description
+ message := llm.Message{
+ Role: llm.MessageRoleAssistant,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: state.String()}},
+ }
+
+ userData := GitInfoUserData{
+ Worktree: state.Worktree,
+ Branch: state.Branch,
+ Commit: state.Commit,
+ Subject: state.Subject,
+ Text: state.String(),
+ }
+
+ createdMsg, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: cm.conversationID,
+ Type: db.MessageTypeGitInfo,
+ LLMData: message,
+ UserData: userData,
+ UsageData: llm.Usage{},
+ })
+ if err != nil {
+ cm.logger.Error("Failed to record git state change", "error", err)
+ return
+ }
+
+ cm.logger.Debug("Recorded git state change", "state", state.String())
+
+ // Notify subscribers so the UI updates
+ go cm.notifyGitStateChange(context.WithoutCancel(ctx), createdMsg)
+}
+
+// notifyGitStateChange publishes a gitinfo message to subscribers.
+func (cm *ConversationManager) notifyGitStateChange(ctx context.Context, msg *generated.Message) {
+ var conversation generated.Conversation
+ err := cm.db.Queries(ctx, func(q *generated.Queries) error {
+ var err error
+ conversation, err = q.GetConversation(ctx, cm.conversationID)
+ return err
+ })
+ if err != nil {
+ cm.logger.Error("Failed to get conversation for git state notification", "error", err)
+ return
+ }
+
+ apiMessages := toAPIMessages([]generated.Message{*msg})
+ streamData := StreamResponse{
+ Messages: apiMessages,
+ Conversation: conversation,
+ AgentWorking: false, // Gitinfo is recorded at end of turn, agent is done
+ }
+ cm.subpub.Publish(msg.SequenceID, streamData)
+}
@@ -189,6 +189,10 @@ func isEndOfTurn(msg *generated.Message) bool {
if msg.Type == string(db.MessageTypeError) {
return true
}
+ // Gitinfo messages are metadata, not part of the agent turn - ignore them
+ if msg.Type == string(db.MessageTypeGitInfo) {
+ return false
+ }
// Only agent messages can have end_of_turn
if msg.Type != string(db.MessageTypeAgent) {
return false
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"net/url"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -1009,3 +1010,117 @@ func TestScreenshotRouteServesImage(t *testing.T) {
t.Fatalf("expected Cache-Control header to be set")
}
}
+
+// TestGitStateChangeCreatesGitInfoMessage verifies that when the agent makes a git commit,
+// a gitinfo message is created in the database.
+func TestGitStateChangeCreatesGitInfoMessage(t *testing.T) {
+ ctx := context.Background()
+
+ // Create a temp directory with a git repo
+ workDir := t.TempDir()
+
+ // Initialize git repo
+ runCmd := func(name string, args ...string) {
+ cmd := exec.Command(name, args...)
+ cmd.Dir = workDir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("Command %s %v failed: %v\n%s", name, args, err, out)
+ }
+ }
+ runCmd("git", "init")
+ runCmd("git", "config", "user.email", "test@example.com")
+ runCmd("git", "config", "user.name", "Test User")
+
+ // Create initial commit
+ initialFile := filepath.Join(workDir, "initial.txt")
+ if err := os.WriteFile(initialFile, []byte("initial content"), 0o644); err != nil {
+ t.Fatalf("Failed to write initial file: %v", err)
+ }
+ runCmd("git", "add", ".")
+ runCmd("git", "commit", "-m", "Initial commit")
+
+ // Create database
+ tempDB := t.TempDir() + "/gitstate_test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create database: %v", err)
+ }
+ defer database.Close()
+ if err := database.Migrate(ctx); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ // Create LLM manager that returns predictable service
+ predictableService := loop.NewPredictableService()
+ customLLMManager := &inspectableLLMManager{
+ predictableService: predictableService,
+ logger: logger,
+ }
+
+ // Create server with git repo as working directory
+ toolConfig := claudetool.ToolSetConfig{
+ WorkingDir: workDir,
+ EnableBrowser: false,
+ }
+ svr := server.NewServer(database, customLLMManager, toolConfig, logger, false, "", "", "", nil)
+
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ // The test command creates a file and commits it. We use explicit paths to avoid bash safety checks.
+ // NOTE: We must set cwd when creating the conversation so the tools run in our git repo.
+ chatReq := map[string]interface{}{
+ "message": "bash: echo 'new content' > newfile.txt && git add newfile.txt && git commit -m 'Add new file'",
+ "model": "predictable",
+ "cwd": workDir,
+ }
+ body, _ := json.Marshal(chatReq)
+ resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+ if err != nil {
+ t.Fatalf("Failed to send message: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+ }
+
+ var createResp struct {
+ ConversationID string `json:"conversation_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ // Poll for the gitinfo message to appear
+ var foundGitInfo bool
+ for i := 0; i < 50; i++ {
+ time.Sleep(100 * time.Millisecond)
+
+ messages, err := database.ListMessagesByConversationPaginated(ctx, createResp.ConversationID, 100, 0)
+ if err != nil {
+ continue
+ }
+
+ for _, msg := range messages {
+ if msg.Type == string(db.MessageTypeGitInfo) {
+ foundGitInfo = true
+ t.Logf("Found gitinfo message: %v", msg.UserData)
+ break
+ }
+ }
+ if foundGitInfo {
+ break
+ }
+ }
+
+ if !foundGitInfo {
+ t.Fatal("Expected a gitinfo message to be created after git commit, but none was found")
+ }
+}
@@ -429,6 +429,9 @@ function ChatInterface({
// Settings modal removed - configuration moved to status bar for empty conversations
const [showOverflowMenu, setShowOverflowMenu] = useState(false);
const [showDiffViewer, setShowDiffViewer] = useState(false);
+ const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState<string | undefined>(
+ undefined,
+ );
const [diffCommentText, setDiffCommentText] = useState("");
const [agentWorking, setAgentWorking] = useState(false);
const [cancelling, setCancelling] = useState(false);
@@ -918,7 +921,16 @@ function ChatInterface({
const rendered = coalescedItems.map((item, index) => {
if (item.type === "message" && item.message) {
- return <MessageComponent key={item.message.message_id} message={item.message} />;
+ return (
+ <MessageComponent
+ key={item.message.message_id}
+ message={item.message}
+ onOpenDiffViewer={(commit) => {
+ setDiffViewerInitialCommit(commit);
+ setShowDiffViewer(true);
+ }}
+ />
+ );
} else if (item.type === "tool") {
return (
<CoalescedToolCall
@@ -1273,8 +1285,12 @@ function ChatInterface({
<DiffViewer
cwd={currentConversation?.cwd || selectedCwd}
isOpen={showDiffViewer}
- onClose={() => setShowDiffViewer(false)}
+ onClose={() => {
+ setShowDiffViewer(false);
+ setDiffViewerInitialCommit(undefined);
+ }}
onCommentTextChange={setDiffCommentText}
+ initialCommit={diffViewerInitialCommit}
/>
</div>
);
@@ -8,6 +8,7 @@ interface DiffViewerProps {
isOpen: boolean;
onClose: () => void;
onCommentTextChange: (text: string) => void;
+ initialCommit?: string; // If set, select this commit when opening
}
// Icon components for cleaner JSX
@@ -77,7 +78,7 @@ function loadMonaco(): Promise<typeof Monaco> {
type ViewMode = "comment" | "edit";
-function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerProps) {
+function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }: DiffViewerProps) {
const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
const [gitRoot, setGitRoot] = useState<string | null>(null);
const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
@@ -174,7 +175,7 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerPro
editorRef.current = null;
}
}
- }, [isOpen, cwd]);
+ }, [isOpen, cwd, initialCommit]);
// Load files when diff is selected
useEffect(() => {
@@ -342,6 +343,18 @@ function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerPro
const response = await api.getGitDiffs(cwd);
setDiffs(response.diffs);
setGitRoot(response.gitRoot);
+
+ // If initialCommit is set, try to select that commit
+ if (initialCommit) {
+ const matchingDiff = response.diffs.find(
+ (d) => d.id === initialCommit || d.id.startsWith(initialCommit),
+ );
+ if (matchingDiff) {
+ setSelectedDiff(matchingDiff.id);
+ return;
+ }
+ }
+
// Auto-select working changes if non-empty
if (response.diffs.length > 0) {
const working = response.diffs.find((d) => d.id === "working");
@@ -24,14 +24,90 @@ interface ToolDisplay {
interface MessageProps {
message: MessageType;
+ onOpenDiffViewer?: (commit: string) => void;
}
-function Message({ message }: MessageProps) {
+function Message({ message, onOpenDiffViewer }: MessageProps) {
// Hide system messages from the UI
if (message.type === "system") {
return null;
}
+ // Render gitinfo messages as compact status updates
+ if (message.type === "gitinfo") {
+ // Parse user_data which contains structured git state info
+ let commitHash: string | null = null;
+ let subject: string | null = null;
+ let branch: string | null = null;
+
+ if (message.user_data) {
+ try {
+ const userData =
+ typeof message.user_data === "string" ? JSON.parse(message.user_data) : message.user_data;
+ if (userData.commit) {
+ commitHash = userData.commit;
+ }
+ if (userData.subject) {
+ subject = userData.subject;
+ }
+ if (userData.branch) {
+ branch = userData.branch;
+ }
+ } catch (err) {
+ console.error("Failed to parse gitinfo user_data:", err);
+ }
+ }
+
+ if (!commitHash) {
+ return null;
+ }
+
+ const canShowDiff = commitHash && onOpenDiffViewer;
+
+ const handleDiffClick = () => {
+ if (commitHash && onOpenDiffViewer) {
+ onOpenDiffViewer(commitHash);
+ }
+ };
+
+ return (
+ <div
+ className="message message-gitinfo"
+ data-testid="message-gitinfo"
+ style={{
+ padding: "0.4rem 1rem",
+ fontSize: "0.8rem",
+ color: "var(--text-secondary)",
+ textAlign: "center",
+ fontStyle: "italic",
+ }}
+ >
+ <span>
+ {branch} now at {commitHash}
+ {subject && ` "${subject}"`}
+ {canShowDiff && (
+ <>
+ {" "}
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ handleDiffClick();
+ }}
+ style={{
+ color: "var(--link-color, #0066cc)",
+ textDecoration: "underline",
+ }}
+ >
+ diff
+ </a>
+ </>
+ )}
+ </span>
+ </div>
+ );
+ }
+
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [showUsageModal, setShowUsageModal] = useState(false);
@@ -43,4 +43,4 @@ export interface StreamResponseForTS {
agent_working: boolean;
}
-export type MessageType = "user" | "agent" | "tool" | "error" | "system";
+export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo";