// 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" "gitmanager/internal/activity" "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 } // New builds a Service over the git boundary, the scanner's repo index, and the // activity feed. func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service { return &Service{git: g, index: index, feed: feed} } // 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() }