// 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" "log/slog" "os" "path/filepath" "strings" "sync" "gitmanager/internal/activity" "gitmanager/internal/forge" "gitmanager/internal/git" "gitmanager/internal/repos" "gitmanager/internal/store" ) // Service holds the shared dependencies the capabilities need. type Service struct { git *git.CLI index *repos.Index feed *activity.Feed store *store.Store log *slog.Logger refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil) mu sync.Mutex // guards forgeCache forgeCache map[string]*forge.Gitea // base_url -> provider } // New builds a Service over the git boundary, the scanner's repo index, the // activity feed, the config store, 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, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service { return &Service{ git: g, index: index, feed: feed, store: st, log: log, refresh: refresh, forgeCache: make(map[string]*forge.Gitea), } } // 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 is configured in the store. func (s *Service) ForgeConfigured(ctx context.Context) bool { forges, err := s.store.ListForges(ctx) return err == nil && len(forges) > 0 } // ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured // when no forge is configured, or forge.ErrNotSupported when the repo's remote is // not on a configured host. func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) { prov, owner, repo, err := s.resolveForge(ctx, repoPath) if err != nil { return nil, err } return prov.ListPullRequests(ctx, owner, repo) } // CreatePR opens a pull request from head into base (empty base = the repo's // default branch) and records the action. The head branch must already exist on // the remote (push it first). func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) { prov, owner, repo, err := s.resolveForge(ctx, repoPath) if err != nil { return forge.PullRequest{}, err } pr, err := prov.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body}) if err != nil { return forge.PullRequest{}, err } s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base)) return pr, nil } // 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) { prov, owner, repo, err := s.resolveForge(ctx, repoPath) if err != nil { return forge.MergeResult{}, err } res, err := prov.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, 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) } out, err := run(base.Path) if err != nil { s.feed.Record(actor, kind, base.Path, "failed: "+err.Error()) return "", err } if okDetail == "" { okDetail = "ok" } s.feed.Record(actor, kind, base.Path, okDetail) 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", "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", "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", "ok", 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", "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", "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 (provider, owner, repo) by matching its git // remotes (preferring "origin") against the forges configured in the store. func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) { forges, err := s.store.ListForges(ctx) if err != nil { return nil, "", "", err } if len(forges) == 0 { return nil, "", "", forge.ErrNotConfigured } base, ok := s.index.Get(filepath.Clean(repoPath)) if !ok { return nil, "", "", fmt.Errorf("unknown repository %q", repoPath) } remotes, err := s.git.RemoteDetails(ctx, base.Path) if err != nil { return nil, "", "", err } match := func(remoteURL string) (*forge.Gitea, string, string, bool) { host, o, r, ok := forge.ParseRemote(remoteURL) if !ok { return nil, "", "", false } for _, f := range forges { prov, perr := s.forgeFor(f) if perr != nil { continue } if prov.Handles(host) { return prov, o, r, true } } return nil, "", "", false } // Prefer origin, then any matching remote. var fb *forge.Gitea var fo, fr string for _, rm := range remotes { prov, o, r, ok := match(rm.URL) if !ok { continue } if rm.Name == "origin" { return prov, o, r, nil } if fb == nil { fb, fo, fr = prov, o, r } } if fb != nil { return fb, fo, fr, nil } return nil, "", "", forge.ErrNotSupported } // forgeFor returns a cached *forge.Gitea for a configured forge, building it on // first use. The cache is cleared whenever forges change (invalidateForges). func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) { s.mu.Lock() defer s.mu.Unlock() if p, ok := s.forgeCache[f.BaseURL]; ok { return p, nil } p, err := forge.NewGitea(f.BaseURL, f.Token) if err != nil { return nil, err } if p == nil { return nil, forge.ErrNotConfigured } s.forgeCache[f.BaseURL] = p return p, nil } func (s *Service) invalidateForges() { s.mu.Lock() s.forgeCache = make(map[string]*forge.Gitea) s.mu.Unlock() } // --- Configuration store (forges, project dirs, git identity) — §1.3 -------- // Forges lists configured forges (tokens are never included). func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) { return s.store.ListForges(ctx) } // AddForge adds a forge, then reapplies git auth so pushes to it work. func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) { if strings.TrimSpace(baseURL) == "" { return store.Forge{}, fmt.Errorf("a base URL is required") } f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token}) if err != nil { return store.Forge{}, err } s.invalidateForges() s.ApplyGitConfig(ctx) s.feed.Record(activity.ActorUser, "forge-added", "", baseURL) return f, nil } // UpdateForge edits a forge (empty token keeps the existing one). func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error { if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil { return err } s.invalidateForges() s.ApplyGitConfig(ctx) return nil } // DeleteForge removes a forge. func (s *Service) DeleteForge(ctx context.Context, id int64) error { if err := s.store.DeleteForge(ctx, id); err != nil { return err } s.invalidateForges() return nil } // ProjectDirs lists configured project directories. func (s *Service) ProjectDirs(ctx context.Context) ([]store.ProjectDir, error) { return s.store.ListProjectDirs(ctx) } // AddProjectDir adds a directory to scan. The path must exist inside the // container (i.e. be under a mounted base) and be a directory. func (s *Service) AddProjectDir(ctx context.Context, path string) (store.ProjectDir, error) { path = filepath.Clean(strings.TrimSpace(path)) if path == "" { return store.ProjectDir{}, fmt.Errorf("a path is required") } info, err := os.Stat(path) if err != nil { return store.ProjectDir{}, fmt.Errorf("path not found in the container: %s (is it under a mounted directory?)", path) } if !info.IsDir() { return store.ProjectDir{}, fmt.Errorf("not a directory: %s", path) } d, err := s.store.AddProjectDir(ctx, path) if err != nil { return store.ProjectDir{}, err } s.feed.Record(activity.ActorUser, "project-dir-added", path, "") if s.refresh == nil { return d, nil } return d, nil } // SetProjectDirEnabled toggles a project directory. func (s *Service) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error { return s.store.SetProjectDirEnabled(ctx, id, enabled) } // DeleteProjectDir removes a project directory. func (s *Service) DeleteProjectDir(ctx context.Context, id int64) error { return s.store.DeleteProjectDir(ctx, id) } // GitIdentity returns the configured commit identity. func (s *Service) GitIdentity(ctx context.Context) (name, email string) { name, _ = s.store.GetSetting(ctx, "git_user_name", "") email, _ = s.store.GetSetting(ctx, "git_user_email", "") return name, email } // SetGitIdentity stores the commit identity and reapplies it to git config. func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error { if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil { return err } if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil { return err } s.ApplyGitConfig(ctx) return nil } // ScanRoots returns the enabled project directories (for the scanner). func (s *Service) ScanRoots(ctx context.Context) []string { roots, err := s.store.EnabledRoots(ctx) if err != nil && s.log != nil { s.log.Warn("could not read project dirs", "err", err) } return roots } // ApplyGitConfig writes the container's git global config from the store: a // commit identity, safe.directory for host-owned mounts, and an auth header per // forge so push/fetch/pull over HTTPS work. Called at startup and after changes. func (s *Service) ApplyGitConfig(ctx context.Context) { set := func(key, value string) { if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil { s.log.Warn("git config failed", "key", key, "err", err) } } set("safe.directory", "*") if name, email := s.GitIdentity(ctx); name != "" || email != "" { if name != "" { set("user.name", name) } if email != "" { set("user.email", email) } } forges, err := s.store.ListForges(ctx) if err != nil { return } for _, f := range forges { if f.Token == "" { continue } set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token) } }