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:
@@ -120,6 +120,34 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Mutating operations (user/Claude-initiated only; §1.3, §1.4) -----------
|
||||
|
||||
// Pull integrates the upstream branch (fetch + merge/ff per repo config).
|
||||
func (c *CLI) Pull(ctx context.Context, dir string) (string, error) {
|
||||
return c.run(ctx, dir, "pull")
|
||||
}
|
||||
|
||||
// Push publishes the current branch to its upstream. Plain push only — never a
|
||||
// forced push here (that is a §1.4 action to be added deliberately if ever).
|
||||
func (c *CLI) Push(ctx context.Context, dir string) (string, error) {
|
||||
return c.run(ctx, dir, "push")
|
||||
}
|
||||
|
||||
// Commit stages every change and commits it with message.
|
||||
func (c *CLI) Commit(ctx context.Context, dir, message string) (string, error) {
|
||||
if _, err := c.run(ctx, dir, "add", "-A"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.run(ctx, dir, "commit", "-m", message)
|
||||
}
|
||||
|
||||
// DiscardAll hard-resets tracked files to HEAD, throwing away uncommitted
|
||||
// changes. DESTRUCTIVE (§1.4): callers MUST confirm with the user first.
|
||||
// Untracked files are left in place.
|
||||
func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) {
|
||||
return c.run(ctx, dir, "reset", "--hard", "HEAD")
|
||||
}
|
||||
|
||||
// Branch is a local branch and its upstream, if any.
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), nil)
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
|
||||
@@ -122,6 +122,13 @@ func (s *Scanner) Refresh(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRepo re-scans a single repository and updates the index. Used after a
|
||||
// mutating action so the UI reflects the new state without waiting for the next
|
||||
// full scan.
|
||||
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
|
||||
s.Index.set(s.refreshOne(ctx, path))
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user