Slice 9: git_checkout and create_branch (menu + MCP)
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. <repo-menu> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user