From e1999bcf2119470c4bb882969c3f1f6a8b514863 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sun, 20 Sep 2026 17:27:58 -0400 Subject: [PATCH] Slice 9: git_checkout and create_branch (menu + MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git boundary Checkout/CreateBranch; service GitCheckout/GitCreateBranch (activity detail names the branch; gitAction takes an ok-detail). HTTP /api/repo/git ops checkout + create-branch (branch field). MCP tools git_checkout and create_branch. gains Switch branch… and New branch… (prompt for name). Service test covers create+switch and existing-branch failure. Checkout is not destructive - git refuses if it would overwrite changes. Co-Authored-By: Claude Opus 4.8 --- AGENT.md | 6 ++--- CHANGELOG.md | 13 +++++++++++ cmd/server/main.go | 5 +++++ components/repo-menu/repo-menu.js | 12 ++++++++++ components/repo-menu/repo-menu.md | 11 +++++---- internal/git/git.go | 12 ++++++++++ internal/mcp/mcp.go | 34 ++++++++++++++++++++++++++++ internal/service/service.go | 37 +++++++++++++++++++++++++------ internal/service/service_test.go | 18 +++++++++++++++ web/templates/help.html | 2 ++ 10 files changed, 136 insertions(+), 14 deletions(-) diff --git a/AGENT.md b/AGENT.md index af825bf..102f6db 100644 --- a/AGENT.md +++ b/AGENT.md @@ -365,9 +365,9 @@ obeys the safety rules (§1.4). the tool handlers (§1.7). Expected tools (grow as features land): - Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, `get_pending_switch`, `list_prs`. - - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, - `git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`, - `ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.) + - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`, + `create_branch`, `git_discard_changes`, `merge_and_cleanup_pr`, + `set_active_project`, `ack_switch`. (More — `create_pr` — as they land.) - **A tool's result type must be a struct, never a bare slice/map/scalar.** The go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP structured output must be a JSON **object** (`type: "object"`). A handler that diff --git a/CHANGELOG.md b/CHANGELOG.md index 9434892..207e373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,3 +217,16 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. set; `http.extraheader` present; `git_fetch` via the app returned ok. - **Security note:** the token is written to the container's ephemeral gitconfig and passed in a `git config` argv — acceptable for a localhost dev container. + +## 2026-09-20 — Slice 9: git_checkout + create_branch +- **What:** git boundary `Checkout` (switch existing branch) and `CreateBranch` + (git checkout -b). Service `GitCheckout`/`GitCreateBranch` (feed detail names + the branch; `gitAction` now takes an ok-detail). HTTP `/api/repo/git` gained + ops `checkout` and `create-branch` (+`branch` field). MCP tools `git_checkout` + and `create_branch`. `` gained "Switch branch…" and "New branch…" + (prompt for the name). Service test covers create+switch+existing-branch-fails. +- **Why:** Round out the everyday git commands in both front doors (§1.7). +- **Affects:** `internal/git`, `internal/service` (+test), `internal/mcp`, + `cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`, + `AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would + overwrite uncommitted changes. diff --git a/cmd/server/main.go b/cmd/server/main.go index fdb9a03..afdb362 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -195,6 +195,7 @@ func main() { Path string `json:"path"` Op string `json:"op"` Message string `json:"message"` + Branch string `json:"branch"` } if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) @@ -213,6 +214,10 @@ func main() { out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message) case "discard": out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path) + case "checkout": + out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch) + case "create-branch": + out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch) default: return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op}) } diff --git a/components/repo-menu/repo-menu.js b/components/repo-menu/repo-menu.js index 3a24cc9..747d36c 100644 --- a/components/repo-menu/repo-menu.js +++ b/components/repo-menu/repo-menu.js @@ -12,6 +12,8 @@ const ITEMS = [ { cmd: 'push', label: 'Publish', hint: 'push' }, { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, { cmd: 'commit', label: 'Save my work…', hint: 'commit' }, + { cmd: 'checkout', label: 'Switch branch…', hint: 'checkout' }, + { cmd: 'newbranch', label: 'New branch…', hint: 'branch' }, { sep: true }, { cmd: 'active', label: 'Set as active project' }, { cmd: 'handoff', label: 'Ask Claude to switch here' }, @@ -91,6 +93,16 @@ class RepoMenu extends HTMLElement { if (msg && msg.trim()) await this.#git('commit', { message: msg }); break; } + case 'checkout': { + const b = window.prompt(`Switch ${name} to which existing branch?`); + if (b && b.trim()) await this.#git('checkout', { branch: b.trim() }); + break; + } + case 'newbranch': { + const b = window.prompt(`New branch name in ${name}:`); + if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() }); + break; + } case 'discard': { const ok = window.confirm( `Discard ALL uncommitted changes in ${name}?\n\n` + diff --git a/components/repo-menu/repo-menu.md b/components/repo-menu/repo-menu.md index effdd35..0839fd4 100644 --- a/components/repo-menu/repo-menu.md +++ b/components/repo-menu/repo-menu.md @@ -11,10 +11,11 @@ event. Safe commands run on click; the destructive one confirms first (§1.4). - **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }` (dispatched by `` on right-click). Shows the menu at (x, y). - **Commands → endpoints:** - - Get latest / Publish / Check for updates / Save my work… / Discard all - changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/ - commit/discard). "Save my work…" prompts for a message; "Discard all - changes…" confirms (destructive). + - Get latest / Publish / Check for updates / Save my work… / Switch branch… / + New branch… / Discard all changes… → `POST /api/repo/git {path, op, + message?, branch?}` (op: pull/push/fetch/commit/checkout/create-branch/ + discard). "Save my work…" prompts for a message; "Switch branch…"/"New + branch…" prompt for the branch; "Discard all changes…" confirms (destructive). - Set as active project → `POST /api/active-project`. - Ask Claude to switch here → `POST /api/switch` (the handoff request). - Copy path → clipboard. @@ -23,6 +24,8 @@ event. Safe commands run on click; the destructive one confirms first (§1.4). ## History - 2026-09-20: created — slice 6; plain-language git commands + coordination actions, backed by the shared service layer (same ops Claude gets via MCP). +- 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch), + both prompting for the branch name (slice 9). ## Notes / gotchas - Results aren't shown inline; they appear in `` (each op records a diff --git a/internal/git/git.go b/internal/git/git.go index ca9a6c5..0a35b3d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -155,6 +155,18 @@ func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) { return c.run(ctx, dir, "reset", "--hard", "HEAD") } +// Checkout switches to an existing branch. Git refuses if uncommitted changes +// would be overwritten, so this is not destructive — the error is surfaced. +func (c *CLI) Checkout(ctx context.Context, dir, branch string) (string, error) { + return c.run(ctx, dir, "checkout", branch) +} + +// CreateBranch creates a new branch from the current HEAD and switches to it +// (git checkout -b). Fails if the branch already exists. +func (c *CLI) CreateBranch(ctx context.Context, dir, name string) (string, error) { + return c.run(ctx, dir, "checkout", "-b", name) +} + // Branch is a local branch and its upstream, if any. type Branch struct { Name string `json:"name"` diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c5197f2..828e547 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -92,6 +92,18 @@ type gitActionOutput struct { Output string `json:"output" jsonschema:"the git command output (may be empty)"` } +// gitCheckoutInput selects a branch to switch to. +type gitCheckoutInput struct { + Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` + Branch string `json:"branch" jsonschema:"the existing branch to switch to"` +} + +// createBranchInput names a new branch to create. +type createBranchInput struct { + Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` + Name string `json:"name" jsonschema:"the new branch name to create and switch to"` +} + // NewServer builds the MCP server and registers the (currently read-only) tools. func NewServer(svc *service.Service, version string) *mcpsdk.Server { s := mcpsdk.NewServer(&mcpsdk.Implementation{ @@ -253,6 +265,28 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server { return nil, gitActionOutput{Output: out}, nil }) + mcpsdk.AddTool(s, &mcpsdk.Tool{ + Name: "git_checkout", + Description: "Switch a repository to an existing branch. Git refuses if uncommitted changes would be overwritten (the error is returned). Path is from list_repos.", + }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCheckoutInput) (*mcpsdk.CallToolResult, gitActionOutput, error) { + out, err := svc.GitCheckout(ctx, activity.ActorClaude, in.Path, in.Branch) + if err != nil { + return nil, gitActionOutput{}, err + } + return nil, gitActionOutput{Output: out}, nil + }) + + mcpsdk.AddTool(s, &mcpsdk.Tool{ + Name: "create_branch", + Description: "Create a new branch from the current HEAD and switch to it (git checkout -b). Fails if the branch already exists. Path is from list_repos.", + }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createBranchInput) (*mcpsdk.CallToolResult, gitActionOutput, error) { + out, err := svc.GitCreateBranch(ctx, activity.ActorClaude, in.Path, in.Name) + if err != nil { + return nil, gitActionOutput{}, err + } + return nil, gitActionOutput{Output: out}, nil + }) + return s } diff --git a/internal/service/service.go b/internal/service/service.go index c42caa9..8310e1a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -162,7 +162,7 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep // gitAction runs one mutating git op through the boundary, records the outcome // on the activity feed, and refreshes the repo in the index on success. Callers // are responsible for §1.4 confirmation of destructive ops (e.g. discard). -func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) { +func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind, okDetail string, run func(dir string) (string, error)) (string, error) { base, ok := s.index.Get(filepath.Clean(repoPath)) if !ok { return "", fmt.Errorf("unknown repository %q", repoPath) @@ -172,7 +172,10 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, s.feed.Record(actor, kind, base.Path, "failed: "+err.Error()) return "", err } - s.feed.Record(actor, kind, base.Path, "ok") + if okDetail == "" { + okDetail = "ok" + } + s.feed.Record(actor, kind, base.Path, okDetail) if s.refresh != nil { s.refresh(ctx, base.Path) } @@ -182,19 +185,19 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, // GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands // the right-click menu (and, later, MCP) invoke. func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { - return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-fetch", "ok", func(d string) (string, error) { return "", s.git.Fetch(ctx, d) }) } func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { - return s.gitAction(ctx, actor, repoPath, "git-pull", func(d string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-pull", "ok", func(d string) (string, error) { return s.git.Pull(ctx, d) }) } func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { - return s.gitAction(ctx, actor, repoPath, "git-push", func(d string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) { return s.git.Push(ctx, d) }) } @@ -203,18 +206,38 @@ func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, if strings.TrimSpace(message) == "" { return "", fmt.Errorf("a commit message is required") } - return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-commit", "ok", func(d string) (string, error) { return s.git.Commit(ctx, d, message) }) } // GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first. func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { - return s.gitAction(ctx, actor, repoPath, "git-discard", func(d string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-discard", "ok", func(d string) (string, error) { return s.git.DiscardAll(ctx, d) }) } +// GitCheckout switches to an existing branch. +func (s *Service) GitCheckout(ctx context.Context, actor activity.Actor, repoPath, branch string) (string, error) { + if strings.TrimSpace(branch) == "" { + return "", fmt.Errorf("a branch name is required") + } + return s.gitAction(ctx, actor, repoPath, "git-checkout", "switched to "+branch, func(d string) (string, error) { + return s.git.Checkout(ctx, d, branch) + }) +} + +// GitCreateBranch creates a new branch from HEAD and switches to it. +func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, repoPath, name string) (string, error) { + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("a branch name is required") + } + return s.gitAction(ctx, actor, repoPath, "git-create-branch", "created "+name, func(d string) (string, error) { + return s.git.CreateBranch(ctx, d, name) + }) +} + // resolveForge maps a repo path to (owner, repo) on the configured forge host via // its git remotes, preferring "origin". func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 31c30db..22981d5 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -65,6 +65,24 @@ func TestGitActions(t *testing.T) { t.Fatalf("a.txt = %q, want restored to \"one\"", got) } + // Create a branch (switches to it), then switch back to main. + if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err != nil { + t.Fatalf("GitCreateBranch: %v", err) + } + if st, _ := svc.GetRepo(repoPath); st.Branch != "feature-x" { + t.Fatalf("branch = %q, want feature-x", st.Branch) + } + if _, err := svc.GitCheckout(ctx, activity.ActorUser, repoPath, "main"); err != nil { + t.Fatalf("GitCheckout: %v", err) + } + if st, _ := svc.GetRepo(repoPath); st.Branch != "main" { + t.Fatalf("branch = %q, want main", st.Branch) + } + // Creating an existing branch fails. + if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err == nil { + t.Fatalf("expected error creating an existing branch") + } + // The feed recorded the successful actions. kinds := map[string]bool{} for _, e := range feed.Events(0) { diff --git a/web/templates/help.html b/web/templates/help.html index d9e8f99..1220e14 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -55,6 +55,8 @@
  • Publish — push your commits.
  • Check for updates — fetch without changing your files.
  • Save my work… — commit everything (asks for a message).
  • +
  • Switch branch… — move to another existing branch.
  • +
  • New branch… — create a branch and switch to it.
  • Set as active project / Ask Claude to switch here.
  • Copy path.
  • Discard all changes… — throw away uncommitted edits