Files
GitManager/internal/service/service.go
T
TBNilles 6ef1c195e3 Slice 6: right-click command menu + git write actions
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>
2026-09-20 09:12:18 -04:00

253 lines
9.3 KiB
Go

// Package service is the ONE capability layer behind both the HTTP API and the
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
// adapters that call these methods; Git/forge logic never lives in a handler.
// Everything here goes through the internal/git (and later internal/forge)
// boundaries and obeys the safety rules (§1.4).
package service
import (
"context"
"fmt"
"path/filepath"
"strings"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// Service holds the shared dependencies the capabilities need.
type Service struct {
git *git.CLI
index *repos.Index
feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
}
// New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
// (may be nil) used to re-scan a repo after a mutating action.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
}
// ListRepos returns a snapshot of every discovered repository.
func (s *Service) ListRepos() []repos.State {
return s.index.List()
}
// GetRepo returns the cached state for one repo, or false if it is not indexed.
// The path is cleaned so separator style does not defeat the exact-match lookup.
func (s *Service) GetRepo(path string) (repos.State, bool) {
return s.index.Get(filepath.Clean(path))
}
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
// repo. The second return is false when the path is not an indexed repository —
// we never run git against an arbitrary caller-supplied path (§1.3).
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
base, ok := s.index.Get(filepath.Clean(path))
if !ok {
return repos.Detail{}, false
}
return repos.BuildDetail(ctx, s.git, base), true
}
// --- Activity & active project (§8.2) --------------------------------------
// ActiveProject returns the current active project path ("" if none).
func (s *Service) ActiveProject() string {
return s.feed.ActiveProject()
}
// SetActiveProject makes path the active project (or clears it when empty). It
// rejects a path that is not an indexed repository — the active project must be
// a real repo (§1.3). Returns the recorded event and whether it changed.
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
if path != "" {
path = filepath.Clean(path)
if _, ok := s.index.Get(path); !ok {
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
}
}
ev, changed := s.feed.SetActiveProject(actor, path)
return ev, changed, nil
}
// RecordActivity appends an arbitrary event to the feed.
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
return s.feed.Record(actor, kind, repo, detail)
}
// Activity returns up to limit recent events, oldest first.
func (s *Service) Activity(limit int) []activity.Event {
return s.feed.Events(limit)
}
// SubscribeActivity returns a channel of future events plus an unsubscribe func
// the caller must invoke when done.
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
return s.feed.Subscribe()
}
// --- Graceful project handoff (§8.3) ---------------------------------------
// RequestSwitch records a user's request for Claude to switch to target. The
// target must be an indexed repository.
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
target = filepath.Clean(target)
if _, ok := s.index.Get(target); !ok {
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
}
return s.feed.RequestSwitch(actor, target, note), nil
}
// PendingSwitch returns the outstanding switch request, if any.
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
return s.feed.PendingSwitch()
}
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
// project to the requested target and records Claude's summary. Returns false if
// nothing was pending.
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
return s.feed.AckSwitch(activity.ActorClaude, summary)
}
// CancelSwitch clears a pending switch request.
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
return s.feed.CancelSwitch(actor)
}
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up.
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
// on the configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return nil, err
}
return s.forge.ListPullRequests(ctx, owner, repo)
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
// The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude).
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.MergeResult{}, err
}
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil {
return forge.MergeResult{}, err
}
detail := fmt.Sprintf("merged PR #%d", res.Number)
if res.BranchDeleted && res.Branch != "" {
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
}
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
return res, nil
}
// --- Git actions (the plain-language commands, §6) --------------------------
// 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) {
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", fmt.Errorf("unknown repository %q", repoPath)
}
out, err := run(base.Path)
if err != nil {
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
return "", err
}
s.feed.Record(actor, kind, base.Path, "ok")
if s.refresh != nil {
s.refresh(ctx, base.Path)
}
return out, nil
}
// 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.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.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.git.Push(ctx, d)
})
}
func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) {
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.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.git.DiscardAll(ctx, d)
})
}
// 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) {
if s.forge == nil {
return "", "", forge.ErrNotConfigured
}
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath)
}
remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil {
return "", "", err
}
// Prefer origin, then any matching remote.
var fallback [2]string
haveFallback := false
for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL)
if !ok || !s.forge.Handles(host) {
continue
}
if rm.Name == "origin" {
return o, r, nil
}
if !haveFallback {
fallback = [2]string{o, r}
haveFallback = true
}
}
if haveFallback {
return fallback[0], fallback[1], nil
}
return "", "", forge.ErrNotSupported
}