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>
This commit is contained in:
2026-09-20 09:12:18 -04:00
parent 9d1519222c
commit 6ef1c195e3
13 changed files with 521 additions and 9 deletions
+68 -7
View File
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
@@ -18,16 +19,18 @@ import (
// 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
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, and (optionally) a forge provider.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg}
// 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.
@@ -154,6 +157,64 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep
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) {
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitmanager/internal/activity"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
// temp repo — never a real one.
func TestGitActions(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "r")
mustMkdir(t, repoPath)
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "t@e.com")
runGit(t, repoPath, "config", "user.name", "T")
writeFile(t, filepath.Join(repoPath, "a.txt"), "one\n")
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "init")
log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
feed := activity.New(log, 200)
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
ctx := context.Background()
// Commit a new file, then the repo should be clean in the index.
writeFile(t, filepath.Join(repoPath, "b.txt"), "two\n")
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, "add b"); err != nil {
t.Fatalf("GitCommit: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after commit, got dirty")
}
// Commit with a blank message is rejected.
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, " "); err == nil {
t.Fatalf("expected error committing with blank message")
}
// Modify a tracked file, then discard resets it. (We check the file itself
// rather than index dirtiness, since the index only updates on a refresh.)
writeFile(t, filepath.Join(repoPath, "a.txt"), "CHANGED\n")
if _, err := svc.GitDiscard(ctx, activity.ActorUser, repoPath); err != nil {
t.Fatalf("GitDiscard: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after discard")
}
// Trim to ignore autocrlf line-ending normalization on Windows.
if got := strings.TrimSpace(readFile(t, filepath.Join(repoPath, "a.txt"))); got != "one" {
t.Fatalf("a.txt = %q, want restored to \"one\"", got)
}
// The feed recorded the successful actions.
kinds := map[string]bool{}
for _, e := range feed.Events(0) {
if e.Detail == "ok" {
kinds[e.Kind] = true
}
}
if !kinds["git-commit"] || !kinds["git-discard"] {
t.Fatalf("expected git-commit and git-discard ok events, got %v", kinds)
}
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.Mkdir(p, 0o755); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}