e30c3b632a
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/repos"
|
|
"gitmanager/internal/store"
|
|
)
|
|
|
|
// testScanner builds a scanner whose roots are fixed to the given paths.
|
|
func testScanner(g *git.CLI, log *slog.Logger, roots ...string) *repos.Scanner {
|
|
return repos.NewScanner(g, log, func(context.Context) repos.Config {
|
|
return repos.Config{Roots: roots, MaxDepth: 3, Fetch: false}
|
|
}, time.Minute)
|
|
}
|
|
|
|
// testStore opens a throwaway SQLite store.
|
|
func testStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
return st
|
|
}
|
|
|
|
// 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 := testScanner(g, log, root)
|
|
scanner.Refresh(context.Background())
|
|
feed := activity.New(log, 200)
|
|
svc := New(g, scanner.Index, feed, testStore(t), log, 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)
|
|
}
|
|
|
|
// Create a branch (switches to it), then switch back to main.
|
|
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err != nil {
|
|
t.Fatalf("GitCreateBranch: %v", err)
|
|
}
|
|
if st, _ := svc.GetRepo(repoPath); st.Branch != "feature-x" {
|
|
t.Fatalf("branch = %q, want feature-x", st.Branch)
|
|
}
|
|
if _, err := svc.GitCheckout(ctx, activity.ActorUser, repoPath, "main"); err != nil {
|
|
t.Fatalf("GitCheckout: %v", err)
|
|
}
|
|
if st, _ := svc.GetRepo(repoPath); st.Branch != "main" {
|
|
t.Fatalf("branch = %q, want main", st.Branch)
|
|
}
|
|
// Creating an existing branch fails.
|
|
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err == nil {
|
|
t.Fatalf("expected error creating an existing branch")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|