6ef1c195e3
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/repos"
|
|
"gitmanager/internal/service"
|
|
)
|
|
|
|
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
|
|
// service -> MCP tools, called by an in-memory MCP client.
|
|
func TestMCPRoundTrip(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoPath := filepath.Join(root, "myrepo")
|
|
if err := os.Mkdir(repoPath, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, repoPath, "init", "-b", "main")
|
|
runGit(t, repoPath, "config", "user.email", "test@example.com")
|
|
runGit(t, repoPath, "config", "user.name", "Test")
|
|
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, repoPath, "add", "-A")
|
|
runGit(t, repoPath, "commit", "-m", "first commit")
|
|
|
|
// Populate the index via the real scanner, then build service + MCP server.
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
g := git.New("git")
|
|
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
|
scanner.Refresh(context.Background())
|
|
|
|
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
|
|
srv := NewServer(svc, "test")
|
|
|
|
// Wire an in-memory client<->server session.
|
|
ctx := context.Background()
|
|
clientT, serverT := mcpsdk.NewInMemoryTransports()
|
|
serverSession, err := srv.Connect(ctx, serverT, nil)
|
|
if err != nil {
|
|
t.Fatalf("server connect: %v", err)
|
|
}
|
|
defer serverSession.Close()
|
|
|
|
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil)
|
|
cs, err := client.Connect(ctx, clientT, nil)
|
|
if err != nil {
|
|
t.Fatalf("client connect: %v", err)
|
|
}
|
|
defer cs.Close()
|
|
|
|
// list_repos should find our one repo.
|
|
res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"})
|
|
if err != nil {
|
|
t.Fatalf("list_repos: %v", err)
|
|
}
|
|
var listOut listReposOutput
|
|
decodeResult(t, res, &listOut)
|
|
states := listOut.Repos
|
|
if len(states) != 1 {
|
|
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
|
|
}
|
|
if states[0].Name != "myrepo" || states[0].Branch != "main" {
|
|
t.Fatalf("unexpected repo state: %+v", states[0])
|
|
}
|
|
|
|
// get_repo should return detail including the commit we made.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "get_repo",
|
|
Arguments: map[string]any{"path": states[0].Path},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get_repo: %v", err)
|
|
}
|
|
var detail repos.Detail
|
|
decodeResult(t, res, &detail)
|
|
if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" {
|
|
t.Fatalf("unexpected detail commits: %+v", detail.Commits)
|
|
}
|
|
|
|
// get_repo with a bad path is a tool error, not a protocol error.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "get_repo",
|
|
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get_repo(bad) protocol error: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatalf("expected IsError for unknown repo, got success")
|
|
}
|
|
|
|
// active project starts empty.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
|
if err != nil {
|
|
t.Fatalf("get_active_project: %v", err)
|
|
}
|
|
var ap struct {
|
|
Path string `json:"path"`
|
|
}
|
|
decodeResult(t, res, &ap)
|
|
if ap.Path != "" {
|
|
t.Fatalf("expected empty active project, got %q", ap.Path)
|
|
}
|
|
|
|
// set_active_project to our repo, then read it back.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "set_active_project",
|
|
Arguments: map[string]any{"path": states[0].Path},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("set_active_project: %v", err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
|
|
}
|
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
|
decodeResult(t, res, &ap)
|
|
if ap.Path != states[0].Path {
|
|
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
|
|
}
|
|
|
|
// setting an unknown project is a tool error.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "set_active_project",
|
|
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("set_active_project(bad) protocol error: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatalf("expected IsError for unknown active project")
|
|
}
|
|
|
|
// the activity feed should now contain the active-project-changed event.
|
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
|
|
var act struct {
|
|
Events []activity.Event `json:"events"`
|
|
}
|
|
decodeResult(t, res, &act)
|
|
found := false
|
|
for _, ev := range act.Events {
|
|
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
|
|
}
|
|
|
|
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
|
|
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
|
|
|
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
|
var ps struct {
|
|
Pending bool `json:"pending"`
|
|
Target string `json:"target"`
|
|
}
|
|
decodeResult(t, res, &ps)
|
|
if !ps.Pending || ps.Target != states[0].Path {
|
|
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
|
|
}
|
|
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "ack_switch",
|
|
Arguments: map[string]any{"summary": "left tests green"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ack_switch: %v", err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("ack_switch tool error: %+v", res.Content)
|
|
}
|
|
|
|
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
|
|
decodeResult(t, res, &ps)
|
|
if ps.Pending {
|
|
t.Fatalf("expected no pending switch after ack, got %+v", ps)
|
|
}
|
|
|
|
// ack with nothing pending is a tool error.
|
|
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
|
Name: "ack_switch",
|
|
Arguments: map[string]any{"summary": "nothing"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ack_switch(empty) protocol error: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatalf("expected IsError acking with no pending switch")
|
|
}
|
|
}
|
|
|
|
// decodeResult unmarshals the JSON text content of a tool result into v.
|
|
func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) {
|
|
t.Helper()
|
|
for _, c := range res.Content {
|
|
if tc, ok := c.(*mcpsdk.TextContent); ok {
|
|
if err := json.Unmarshal([]byte(tc.Text), v); err != nil {
|
|
t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("no text content in result: %+v", res.Content)
|
|
}
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|