diff --git a/CLAUDE.md b/CLAUDE.md index 5680fab..8f7a4b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,12 @@ You are a dedicated domain expert for `forge.lthn.ai/core/go-scm`. Virgil orches ## What This Package Does -SCM integration and data collection for the Lethean ecosystem. Four packages: `forge/` (Forgejo API client), `git/` (multi-repo git ops), `gitea/` (Gitea API client), `collect/` (data collection). +SCM integration and data collection for the Lethean ecosystem. Three packages: `forge/` (Forgejo API client), `gitea/` (Gitea API client), `collect/` (data collection). **Extracted to other repos:** `agentci/` + `jobrunner/` → `core/go-agent`, `git/` → `core/go-git`. +**Note:** `forge/` is still used by `core/go-agent` as a dependency. `core/go-forge` is a separate, full API client with a different interface. + ## Commands ```bash @@ -30,20 +32,13 @@ Resolved via `replace` in go.mod or preferably via `go.work`: ```go // forge/client.go type Client struct { api *forgejo.Client; url, token string } - -// git/git.go -type RepoStatus struct { - Name, Path, Branch string - Modified, Untracked, Staged, Ahead, Behind int - Error error -} ``` ## Coding Standards - **UK English**: colour, organisation, centre - **Tests**: testify assert/require, table-driven preferred, `_Good`/`_Bad`/`_Ugly` naming -- **Conventional commits**: `feat(forge):`, `fix(git):`, `test(collect):` +- **Conventional commits**: `feat(forge):`, `fix(gitea):`, `test(collect):` - **Co-Author**: `Co-Authored-By: Virgil ` - **Licence**: EUPL-1.2 - **Imports**: stdlib → forge.lthn.ai → third-party, each group separated by blank line diff --git a/git/git.go b/git/git.go deleted file mode 100644 index 9f5460c..0000000 --- a/git/git.go +++ /dev/null @@ -1,265 +0,0 @@ -// Package git provides utilities for git operations across multiple repositories. -package git - -import ( - "bytes" - "context" - "io" - "os" - "os/exec" - "strconv" - "strings" - "sync" -) - -// RepoStatus represents the git status of a single repository. -type RepoStatus struct { - Name string - Path string - Modified int - Untracked int - Staged int - Ahead int - Behind int - Branch string - Error error -} - -// IsDirty returns true if there are uncommitted changes. -func (s *RepoStatus) IsDirty() bool { - return s.Modified > 0 || s.Untracked > 0 || s.Staged > 0 -} - -// HasUnpushed returns true if there are commits to push. -func (s *RepoStatus) HasUnpushed() bool { - return s.Ahead > 0 -} - -// HasUnpulled returns true if there are commits to pull. -func (s *RepoStatus) HasUnpulled() bool { - return s.Behind > 0 -} - -// StatusOptions configures the status check. -type StatusOptions struct { - // Paths is a list of repo paths to check - Paths []string - // Names maps paths to display names - Names map[string]string -} - -// Status checks git status for multiple repositories in parallel. -func Status(ctx context.Context, opts StatusOptions) []RepoStatus { - var wg sync.WaitGroup - results := make([]RepoStatus, len(opts.Paths)) - - for i, path := range opts.Paths { - wg.Add(1) - go func(idx int, repoPath string) { - defer wg.Done() - name := opts.Names[repoPath] - if name == "" { - name = repoPath - } - results[idx] = getStatus(ctx, repoPath, name) - }(i, path) - } - - wg.Wait() - return results -} - -// getStatus gets the git status for a single repository. -func getStatus(ctx context.Context, path, name string) RepoStatus { - status := RepoStatus{ - Name: name, - Path: path, - } - - // Get current branch - branch, err := gitCommand(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - status.Error = err - return status - } - status.Branch = strings.TrimSpace(branch) - - // Get porcelain status - porcelain, err := gitCommand(ctx, path, "status", "--porcelain") - if err != nil { - status.Error = err - return status - } - - // Parse status output - for _, line := range strings.Split(porcelain, "\n") { - if len(line) < 2 { - continue - } - x, y := line[0], line[1] - - // Untracked - if x == '?' && y == '?' { - status.Untracked++ - continue - } - - // Staged (index has changes) - if x == 'A' || x == 'D' || x == 'R' || x == 'M' { - status.Staged++ - } - - // Modified in working tree - if y == 'M' || y == 'D' { - status.Modified++ - } - } - - // Get ahead/behind counts - ahead, behind := getAheadBehind(ctx, path) - status.Ahead = ahead - status.Behind = behind - - return status -} - -// getAheadBehind returns the number of commits ahead and behind upstream. -func getAheadBehind(ctx context.Context, path string) (ahead, behind int) { - // Try to get ahead count - aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") - if err == nil { - ahead, _ = strconv.Atoi(strings.TrimSpace(aheadStr)) - } - - // Try to get behind count - behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") - if err == nil { - behind, _ = strconv.Atoi(strings.TrimSpace(behindStr)) - } - - return ahead, behind -} - -// Push pushes commits for a single repository. -// Uses interactive mode to support SSH passphrase prompts. -func Push(ctx context.Context, path string) error { - return gitInteractive(ctx, path, "push") -} - -// Pull pulls changes for a single repository. -// Uses interactive mode to support SSH passphrase prompts. -func Pull(ctx context.Context, path string) error { - return gitInteractive(ctx, path, "pull", "--rebase") -} - -// IsNonFastForward checks if an error is a non-fast-forward rejection. -func IsNonFastForward(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "non-fast-forward") || - strings.Contains(msg, "fetch first") || - strings.Contains(msg, "tip of your current branch is behind") -} - -// gitInteractive runs a git command with terminal attached for user interaction. -func gitInteractive(ctx context.Context, dir string, args ...string) error { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - - // Connect to terminal for SSH passphrase prompts - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - - // Capture stderr for error reporting while also showing it - var stderr bytes.Buffer - cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) - - if err := cmd.Run(); err != nil { - if stderr.Len() > 0 { - return &GitError{Err: err, Stderr: stderr.String()} - } - return err - } - - return nil -} - -// PushResult represents the result of a push operation. -type PushResult struct { - Name string - Path string - Success bool - Error error -} - -// PushMultiple pushes multiple repositories sequentially. -// Sequential because SSH passphrase prompts need user interaction. -func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult { - results := make([]PushResult, len(paths)) - - for i, path := range paths { - name := names[path] - if name == "" { - name = path - } - - result := PushResult{ - Name: name, - Path: path, - } - - err := Push(ctx, path) - if err != nil { - result.Error = err - } else { - result.Success = true - } - - results[i] = result - } - - return results -} - -// gitCommand runs a git command and returns stdout. -func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - // Include stderr in error message for better diagnostics - if stderr.Len() > 0 { - return "", &GitError{Err: err, Stderr: stderr.String()} - } - return "", err - } - - return stdout.String(), nil -} - -// GitError wraps a git command error with stderr output. -type GitError struct { - Err error - Stderr string -} - -// Error returns the git error message, preferring stderr output. -func (e *GitError) Error() string { - // Return just the stderr message, trimmed - msg := strings.TrimSpace(e.Stderr) - if msg != "" { - return msg - } - return e.Err.Error() -} - -// Unwrap returns the underlying error for error chain inspection. -func (e *GitError) Unwrap() error { - return e.Err -} diff --git a/git/git_test.go b/git/git_test.go deleted file mode 100644 index e1a09f4..0000000 --- a/git/git_test.go +++ /dev/null @@ -1,569 +0,0 @@ -package git - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// initTestRepo creates a temporary git repository with an initial commit. -// Returns the path to the repository. -func initTestRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - - cmds := [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "test@example.com"}, - {"git", "config", "user.name", "Test User"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - require.NoError(t, err, "failed to run %v: %s", args, string(out)) - } - - // Create a file and commit it so HEAD exists. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test\n"), 0644)) - - cmds = [][]string{ - {"git", "add", "README.md"}, - {"git", "commit", "-m", "initial commit"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - require.NoError(t, err, "failed to run %v: %s", args, string(out)) - } - - return dir -} - -// --- RepoStatus method tests --- - -func TestRepoStatus_IsDirty(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - { - name: "clean repo", - status: RepoStatus{}, - expected: false, - }, - { - name: "modified files", - status: RepoStatus{Modified: 3}, - expected: true, - }, - { - name: "untracked files", - status: RepoStatus{Untracked: 1}, - expected: true, - }, - { - name: "staged files", - status: RepoStatus{Staged: 2}, - expected: true, - }, - { - name: "all types dirty", - status: RepoStatus{Modified: 1, Untracked: 2, Staged: 3}, - expected: true, - }, - { - name: "only ahead is not dirty", - status: RepoStatus{Ahead: 5}, - expected: false, - }, - { - name: "only behind is not dirty", - status: RepoStatus{Behind: 2}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.status.IsDirty()) - }) - } -} - -func TestRepoStatus_HasUnpushed(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - { - name: "no commits ahead", - status: RepoStatus{Ahead: 0}, - expected: false, - }, - { - name: "commits ahead", - status: RepoStatus{Ahead: 3}, - expected: true, - }, - { - name: "behind but not ahead", - status: RepoStatus{Behind: 5}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.status.HasUnpushed()) - }) - } -} - -func TestRepoStatus_HasUnpulled(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - { - name: "no commits behind", - status: RepoStatus{Behind: 0}, - expected: false, - }, - { - name: "commits behind", - status: RepoStatus{Behind: 2}, - expected: true, - }, - { - name: "ahead but not behind", - status: RepoStatus{Ahead: 3}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.status.HasUnpulled()) - }) - } -} - -// --- GitError tests --- - -func TestGitError_Error(t *testing.T) { - tests := []struct { - name string - err *GitError - expected string - }{ - { - name: "stderr takes precedence", - err: &GitError{Err: errors.New("exit 1"), Stderr: "fatal: not a git repository"}, - expected: "fatal: not a git repository", - }, - { - name: "falls back to underlying error", - err: &GitError{Err: errors.New("exit status 128"), Stderr: ""}, - expected: "exit status 128", - }, - { - name: "trims whitespace from stderr", - err: &GitError{Err: errors.New("exit 1"), Stderr: " error message \n"}, - expected: "error message", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.err.Error()) - }) - } -} - -func TestGitError_Unwrap(t *testing.T) { - inner := errors.New("underlying error") - gitErr := &GitError{Err: inner, Stderr: "stderr output"} - assert.Equal(t, inner, gitErr.Unwrap()) - assert.True(t, errors.Is(gitErr, inner)) -} - -// --- IsNonFastForward tests --- - -func TestIsNonFastForward(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "non-fast-forward message", - err: errors.New("! [rejected] main -> main (non-fast-forward)"), - expected: true, - }, - { - name: "fetch first message", - err: errors.New("Updates were rejected because the remote contains work that you do not have locally. fetch first"), - expected: true, - }, - { - name: "tip behind message", - err: errors.New("Updates were rejected because the tip of your current branch is behind"), - expected: true, - }, - { - name: "unrelated error", - err: errors.New("connection refused"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, IsNonFastForward(tt.err)) - }) - } -} - -// --- gitCommand tests with real git repos --- - -func TestGitCommand_Good(t *testing.T) { - dir := initTestRepo(t) - - out, err := gitCommand(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD") - require.NoError(t, err) - // Default branch could be main or master depending on git config. - branch := out - assert.NotEmpty(t, branch) -} - -func TestGitCommand_Bad_InvalidDir(t *testing.T) { - _, err := gitCommand(context.Background(), "/nonexistent/path", "status") - require.Error(t, err) -} - -func TestGitCommand_Bad_NotARepo(t *testing.T) { - dir := t.TempDir() - _, err := gitCommand(context.Background(), dir, "status") - require.Error(t, err) - - // Should be a GitError with stderr. - var gitErr *GitError - if errors.As(err, &gitErr) { - assert.Contains(t, gitErr.Stderr, "not a git repository") - } -} - -// --- getStatus integration tests --- - -func TestGetStatus_Good_CleanRepo(t *testing.T) { - dir := initTestRepo(t) - - status := getStatus(context.Background(), dir, "test-repo") - require.NoError(t, status.Error) - assert.Equal(t, "test-repo", status.Name) - assert.Equal(t, dir, status.Path) - assert.NotEmpty(t, status.Branch) - assert.False(t, status.IsDirty()) -} - -func TestGetStatus_Good_ModifiedFile(t *testing.T) { - dir := initTestRepo(t) - - // Modify the existing tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Modified\n"), 0644)) - - status := getStatus(context.Background(), dir, "modified-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Modified) - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Good_UntrackedFile(t *testing.T) { - dir := initTestRepo(t) - - // Create a new untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "newfile.txt"), []byte("hello"), 0644)) - - status := getStatus(context.Background(), dir, "untracked-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Untracked) - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Good_StagedFile(t *testing.T) { - dir := initTestRepo(t) - - // Create and stage a new file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644)) - cmd := exec.Command("git", "add", "staged.txt") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - status := getStatus(context.Background(), dir, "staged-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Staged) - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Good_MixedChanges(t *testing.T) { - dir := initTestRepo(t) - - // Create untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)) - - // Modify tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Changed\n"), 0644)) - - // Create and stage another file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644)) - cmd := exec.Command("git", "add", "staged.txt") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - status := getStatus(context.Background(), dir, "mixed-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Modified, "expected 1 modified file") - assert.Equal(t, 1, status.Untracked, "expected 1 untracked file") - assert.Equal(t, 1, status.Staged, "expected 1 staged file") - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Good_DeletedTrackedFile(t *testing.T) { - dir := initTestRepo(t) - - // Delete the tracked file (unstaged deletion). - require.NoError(t, os.Remove(filepath.Join(dir, "README.md"))) - - status := getStatus(context.Background(), dir, "deleted-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Modified, "deletion in working tree counts as modified") - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Good_StagedDeletion(t *testing.T) { - dir := initTestRepo(t) - - // Stage a deletion. - cmd := exec.Command("git", "rm", "README.md") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - status := getStatus(context.Background(), dir, "staged-delete-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Staged, "staged deletion counts as staged") - assert.True(t, status.IsDirty()) -} - -func TestGetStatus_Bad_InvalidPath(t *testing.T) { - status := getStatus(context.Background(), "/nonexistent/path", "bad-repo") - assert.Error(t, status.Error) - assert.Equal(t, "bad-repo", status.Name) -} - -// --- Status (parallel multi-repo) tests --- - -func TestStatus_Good_MultipleRepos(t *testing.T) { - dir1 := initTestRepo(t) - dir2 := initTestRepo(t) - - // Make dir2 dirty. - require.NoError(t, os.WriteFile(filepath.Join(dir2, "extra.txt"), []byte("extra"), 0644)) - - results := Status(context.Background(), StatusOptions{ - Paths: []string{dir1, dir2}, - Names: map[string]string{ - dir1: "clean-repo", - dir2: "dirty-repo", - }, - }) - - require.Len(t, results, 2) - - assert.Equal(t, "clean-repo", results[0].Name) - assert.NoError(t, results[0].Error) - assert.False(t, results[0].IsDirty()) - - assert.Equal(t, "dirty-repo", results[1].Name) - assert.NoError(t, results[1].Error) - assert.True(t, results[1].IsDirty()) -} - -func TestStatus_Good_EmptyPaths(t *testing.T) { - results := Status(context.Background(), StatusOptions{ - Paths: []string{}, - }) - assert.Empty(t, results) -} - -func TestStatus_Good_NameFallback(t *testing.T) { - dir := initTestRepo(t) - - // No name mapping — path should be used as name. - results := Status(context.Background(), StatusOptions{ - Paths: []string{dir}, - Names: map[string]string{}, - }) - - require.Len(t, results, 1) - assert.Equal(t, dir, results[0].Name, "name should fall back to path") -} - -func TestStatus_Good_WithErrors(t *testing.T) { - validDir := initTestRepo(t) - invalidDir := "/nonexistent/path" - - results := Status(context.Background(), StatusOptions{ - Paths: []string{validDir, invalidDir}, - Names: map[string]string{ - validDir: "good", - invalidDir: "bad", - }, - }) - - require.Len(t, results, 2) - assert.NoError(t, results[0].Error) - assert.Error(t, results[1].Error) -} - -// --- PushMultiple tests --- - -func TestPushMultiple_Good_NoRemote(t *testing.T) { - // Push without a remote will fail but we can test the result structure. - dir := initTestRepo(t) - - results := PushMultiple(context.Background(), []string{dir}, map[string]string{ - dir: "test-repo", - }) - - require.Len(t, results, 1) - assert.Equal(t, "test-repo", results[0].Name) - assert.Equal(t, dir, results[0].Path) - // Push without remote should fail. - assert.False(t, results[0].Success) - assert.Error(t, results[0].Error) -} - -func TestPushMultiple_Good_NameFallback(t *testing.T) { - dir := initTestRepo(t) - - results := PushMultiple(context.Background(), []string{dir}, map[string]string{}) - - require.Len(t, results, 1) - assert.Equal(t, dir, results[0].Name, "name should fall back to path") -} - -// --- Pull tests --- - -func TestPull_Bad_NoRemote(t *testing.T) { - dir := initTestRepo(t) - err := Pull(context.Background(), dir) - assert.Error(t, err, "pull without remote should fail") -} - -// --- Push tests --- - -func TestPush_Bad_NoRemote(t *testing.T) { - dir := initTestRepo(t) - err := Push(context.Background(), dir) - assert.Error(t, err, "push without remote should fail") -} - -// --- Context cancellation test --- - -func TestGetStatus_Good_ContextCancellation(t *testing.T) { - dir := initTestRepo(t) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately. - - status := getStatus(ctx, dir, "cancelled-repo") - // With a cancelled context, the git commands should fail. - assert.Error(t, status.Error) -} - -// --- getAheadBehind with a tracking branch --- - -func TestGetAheadBehind_Good_WithUpstream(t *testing.T) { - // Create a bare remote and a clone to test ahead/behind counts. - bareDir := t.TempDir() - cloneDir := t.TempDir() - - // Initialise the bare repo. - cmd := exec.Command("git", "init", "--bare") - cmd.Dir = bareDir - require.NoError(t, cmd.Run()) - - // Clone it. - cmd = exec.Command("git", "clone", bareDir, cloneDir) - require.NoError(t, cmd.Run()) - - // Configure user in clone. - for _, args := range [][]string{ - {"git", "config", "user.email", "test@example.com"}, - {"git", "config", "user.name", "Test User"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - require.NoError(t, cmd.Run()) - } - - // Create initial commit and push. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "initial"}, - {"git", "push", "origin", "HEAD"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - out, err := cmd.CombinedOutput() - require.NoError(t, err, "command %v failed: %s", args, string(out)) - } - - // Make a local commit without pushing (ahead by 1). - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "local commit"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - require.NoError(t, cmd.Run()) - } - - ahead, behind := getAheadBehind(context.Background(), cloneDir) - assert.Equal(t, 1, ahead, "should be 1 commit ahead") - assert.Equal(t, 0, behind, "should not be behind") -} - -// --- Renamed file detection --- - -func TestGetStatus_Good_RenamedFile(t *testing.T) { - dir := initTestRepo(t) - - // Rename via git mv (stages the rename). - cmd := exec.Command("git", "mv", "README.md", "GUIDE.md") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - status := getStatus(context.Background(), dir, "renamed-repo") - require.NoError(t, status.Error) - assert.Equal(t, 1, status.Staged, "rename should count as staged") - assert.True(t, status.IsDirty()) -} diff --git a/git/service.go b/git/service.go deleted file mode 100644 index 892d6fc..0000000 --- a/git/service.go +++ /dev/null @@ -1,126 +0,0 @@ -package git - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/framework" -) - -// Queries for git service - -// QueryStatus requests git status for paths. -type QueryStatus struct { - Paths []string - Names map[string]string -} - -// QueryDirtyRepos requests repos with uncommitted changes. -type QueryDirtyRepos struct{} - -// QueryAheadRepos requests repos with unpushed commits. -type QueryAheadRepos struct{} - -// Tasks for git service - -// TaskPush requests git push for a path. -type TaskPush struct { - Path string - Name string -} - -// TaskPull requests git pull for a path. -type TaskPull struct { - Path string - Name string -} - -// TaskPushMultiple requests git push for multiple paths. -type TaskPushMultiple struct { - Paths []string - Names map[string]string -} - -// ServiceOptions for configuring the git service. -type ServiceOptions struct { - WorkDir string -} - -// Service provides git operations as a Core service. -type Service struct { - *framework.ServiceRuntime[ServiceOptions] - lastStatus []RepoStatus -} - -// NewService creates a git service factory. -func NewService(opts ServiceOptions) func(*framework.Core) (any, error) { - return func(c *framework.Core) (any, error) { - return &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, opts), - }, nil - } -} - -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { - switch m := q.(type) { - case QueryStatus: - statuses := Status(context.Background(), StatusOptions(m)) - s.lastStatus = statuses - return statuses, true, nil - - case QueryDirtyRepos: - return s.DirtyRepos(), true, nil - - case QueryAheadRepos: - return s.AheadRepos(), true, nil - } - return nil, false, nil -} - -func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { - switch m := t.(type) { - case TaskPush: - err := Push(context.Background(), m.Path) - return nil, true, err - - case TaskPull: - err := Pull(context.Background(), m.Path) - return nil, true, err - - case TaskPushMultiple: - results := PushMultiple(context.Background(), m.Paths, m.Names) - return results, true, nil - } - return nil, false, nil -} - -// Status returns last status result. -func (s *Service) Status() []RepoStatus { return s.lastStatus } - -// DirtyRepos returns repos with uncommitted changes. -func (s *Service) DirtyRepos() []RepoStatus { - var dirty []RepoStatus - for _, st := range s.lastStatus { - if st.Error == nil && st.IsDirty() { - dirty = append(dirty, st) - } - } - return dirty -} - -// AheadRepos returns repos with unpushed commits. -func (s *Service) AheadRepos() []RepoStatus { - var ahead []RepoStatus - for _, st := range s.lastStatus { - if st.Error == nil && st.HasUnpushed() { - ahead = append(ahead, st) - } - } - return ahead -} diff --git a/git/service_extra_test.go b/git/service_extra_test.go deleted file mode 100644 index 13cd0e6..0000000 --- a/git/service_extra_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package git - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "forge.lthn.ai/core/go/pkg/framework" -) - -func TestNewService_Good(t *testing.T) { - opts := ServiceOptions{WorkDir: "/tmp/test"} - factory := NewService(opts) - assert.NotNil(t, factory) - - // Create a minimal Core to test the factory. - c, err := framework.New() - require.NoError(t, err) - - svc, err := factory(c) - require.NoError(t, err) - assert.NotNil(t, svc) - - service, ok := svc.(*Service) - require.True(t, ok) - assert.NotNil(t, service) -} - -func TestService_OnStartup_Good(t *testing.T) { - c, err := framework.New() - require.NoError(t, err) - - opts := ServiceOptions{WorkDir: "/tmp"} - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, opts), - } - - err = svc.OnStartup(context.Background()) - assert.NoError(t, err) -} - -func TestService_HandleQuery_Good_Status(t *testing.T) { - dir := initTestRepo(t) - - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - // Call handleQuery directly. - result, handled, err := svc.handleQuery(c, QueryStatus{ - Paths: []string{dir}, - Names: map[string]string{dir: "test-repo"}, - }) - - require.NoError(t, err) - assert.True(t, handled) - - statuses, ok := result.([]RepoStatus) - require.True(t, ok) - require.Len(t, statuses, 1) - assert.Equal(t, "test-repo", statuses[0].Name) - - // Verify lastStatus was updated. - assert.Len(t, svc.lastStatus, 1) -} - -func TestService_HandleQuery_Good_DirtyRepos(t *testing.T) { - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - lastStatus: []RepoStatus{ - {Name: "clean"}, - {Name: "dirty", Modified: 1}, - }, - } - - result, handled, err := svc.handleQuery(c, QueryDirtyRepos{}) - require.NoError(t, err) - assert.True(t, handled) - - dirty, ok := result.([]RepoStatus) - require.True(t, ok) - assert.Len(t, dirty, 1) - assert.Equal(t, "dirty", dirty[0].Name) -} - -func TestService_HandleQuery_Good_AheadRepos(t *testing.T) { - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - lastStatus: []RepoStatus{ - {Name: "synced"}, - {Name: "ahead", Ahead: 3}, - }, - } - - result, handled, err := svc.handleQuery(c, QueryAheadRepos{}) - require.NoError(t, err) - assert.True(t, handled) - - ahead, ok := result.([]RepoStatus) - require.True(t, ok) - assert.Len(t, ahead, 1) - assert.Equal(t, "ahead", ahead[0].Name) -} - -func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - result, handled, err := svc.handleQuery(c, "unknown query type") - require.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestService_HandleTask_Good_Push(t *testing.T) { - dir := initTestRepo(t) - - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - // Push without a remote will fail, but handleTask should still handle it. - _, handled, err := svc.handleTask(c, TaskPush{Path: dir, Name: "test"}) - assert.True(t, handled) - assert.Error(t, err, "push without remote should fail") -} - -func TestService_HandleTask_Good_Pull(t *testing.T) { - dir := initTestRepo(t) - - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - _, handled, err := svc.handleTask(c, TaskPull{Path: dir, Name: "test"}) - assert.True(t, handled) - assert.Error(t, err, "pull without remote should fail") -} - -func TestService_HandleTask_Good_PushMultiple(t *testing.T) { - dir := initTestRepo(t) - - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - result, handled, err := svc.handleTask(c, TaskPushMultiple{ - Paths: []string{dir}, - Names: map[string]string{dir: "test"}, - }) - - assert.True(t, handled) - assert.NoError(t, err) // PushMultiple does not return errors directly - - results, ok := result.([]PushResult) - require.True(t, ok) - assert.Len(t, results, 1) - assert.False(t, results[0].Success) // No remote -} - -func TestService_HandleTask_Good_UnknownTask(t *testing.T) { - c, err := framework.New() - require.NoError(t, err) - - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}), - } - - result, handled, err := svc.handleTask(c, "unknown task") - require.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -// --- Additional git operation tests --- - -func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) { - // A repo without a tracking branch should return 0 ahead/behind. - dir := initTestRepo(t) - - status := getStatus(context.Background(), dir, "no-upstream") - require.NoError(t, status.Error) - assert.Equal(t, 0, status.Ahead) - assert.Equal(t, 0, status.Behind) -} - -func TestPushMultiple_Good_Empty(t *testing.T) { - results := PushMultiple(context.Background(), []string{}, map[string]string{}) - assert.Empty(t, results) -} - -func TestPushMultiple_Good_MultiplePaths(t *testing.T) { - dir1 := initTestRepo(t) - dir2 := initTestRepo(t) - - results := PushMultiple(context.Background(), []string{dir1, dir2}, map[string]string{ - dir1: "repo-1", - dir2: "repo-2", - }) - - require.Len(t, results, 2) - assert.Equal(t, "repo-1", results[0].Name) - assert.Equal(t, "repo-2", results[1].Name) - // Both should fail (no remote). - assert.False(t, results[0].Success) - assert.False(t, results[1].Success) -} - -func TestPush_Good_WithRemote(t *testing.T) { - // Create a bare remote and a clone. - bareDir := t.TempDir() - cloneDir := t.TempDir() - - cmd := exec.Command("git", "init", "--bare") - cmd.Dir = bareDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "clone", bareDir, cloneDir) - require.NoError(t, cmd.Run()) - - for _, args := range [][]string{ - {"git", "config", "user.email", "test@example.com"}, - {"git", "config", "user.name", "Test User"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - require.NoError(t, cmd.Run()) - } - - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "initial"}, - {"git", "push", "origin", "HEAD"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - out, err := cmd.CombinedOutput() - require.NoError(t, err, "failed: %v: %s", args, string(out)) - } - - // Make a local commit. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "second commit"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - require.NoError(t, cmd.Run()) - } - - // Push should succeed. - err := Push(context.Background(), cloneDir) - assert.NoError(t, err) - - // Verify ahead count is now 0. - ahead, _ := getAheadBehind(context.Background(), cloneDir) - assert.Equal(t, 0, ahead) -} diff --git a/git/service_test.go b/git/service_test.go deleted file mode 100644 index e3ae7f6..0000000 --- a/git/service_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package git - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// --- Service helper method tests --- -// These test DirtyRepos/AheadRepos filtering without needing the framework. - -func TestService_DirtyRepos_Good(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "clean", Modified: 0, Untracked: 0, Staged: 0}, - {Name: "dirty-modified", Modified: 2}, - {Name: "dirty-untracked", Untracked: 1}, - {Name: "dirty-staged", Staged: 3}, - {Name: "errored", Modified: 5, Error: assert.AnError}, - }, - } - - dirty := s.DirtyRepos() - assert.Len(t, dirty, 3) - - names := make([]string, len(dirty)) - for i, d := range dirty { - names[i] = d.Name - } - assert.Contains(t, names, "dirty-modified") - assert.Contains(t, names, "dirty-untracked") - assert.Contains(t, names, "dirty-staged") -} - -func TestService_DirtyRepos_Good_NoneFound(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "clean1"}, - {Name: "clean2"}, - }, - } - - dirty := s.DirtyRepos() - assert.Empty(t, dirty) -} - -func TestService_DirtyRepos_Good_EmptyStatus(t *testing.T) { - s := &Service{} - dirty := s.DirtyRepos() - assert.Empty(t, dirty) -} - -func TestService_AheadRepos_Good(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "up-to-date", Ahead: 0}, - {Name: "ahead-by-one", Ahead: 1}, - {Name: "ahead-by-five", Ahead: 5}, - {Name: "behind-only", Behind: 3}, - {Name: "errored-ahead", Ahead: 2, Error: assert.AnError}, - }, - } - - ahead := s.AheadRepos() - assert.Len(t, ahead, 2) - - names := make([]string, len(ahead)) - for i, a := range ahead { - names[i] = a.Name - } - assert.Contains(t, names, "ahead-by-one") - assert.Contains(t, names, "ahead-by-five") -} - -func TestService_AheadRepos_Good_NoneFound(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "synced1"}, - {Name: "synced2"}, - }, - } - - ahead := s.AheadRepos() - assert.Empty(t, ahead) -} - -func TestService_AheadRepos_Good_EmptyStatus(t *testing.T) { - s := &Service{} - ahead := s.AheadRepos() - assert.Empty(t, ahead) -} - -func TestService_Status_Good(t *testing.T) { - expected := []RepoStatus{ - {Name: "repo1", Branch: "main"}, - {Name: "repo2", Branch: "develop"}, - } - s := &Service{lastStatus: expected} - - assert.Equal(t, expected, s.Status()) -} - -func TestService_Status_Good_NilSlice(t *testing.T) { - s := &Service{} - assert.Nil(t, s.Status()) -} - -// --- Query/Task type tests --- - -func TestQueryStatus_MapsToStatusOptions(t *testing.T) { - q := QueryStatus{ - Paths: []string{"/path/a", "/path/b"}, - Names: map[string]string{"/path/a": "repo-a"}, - } - - // QueryStatus can be cast directly to StatusOptions. - opts := StatusOptions(q) - assert.Equal(t, q.Paths, opts.Paths) - assert.Equal(t, q.Names, opts.Names) -} - -func TestServiceOptions_WorkDir(t *testing.T) { - opts := ServiceOptions{WorkDir: "/home/claude/repos"} - assert.Equal(t, "/home/claude/repos", opts.WorkDir) -} - -// --- DirtyRepos excludes errored repos --- - -func TestService_DirtyRepos_Good_ExcludesErrors(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "dirty-ok", Modified: 1}, - {Name: "dirty-error", Modified: 1, Error: assert.AnError}, - }, - } - - dirty := s.DirtyRepos() - assert.Len(t, dirty, 1) - assert.Equal(t, "dirty-ok", dirty[0].Name) -} - -// --- AheadRepos excludes errored repos --- - -func TestService_AheadRepos_Good_ExcludesErrors(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "ahead-ok", Ahead: 2}, - {Name: "ahead-error", Ahead: 3, Error: assert.AnError}, - }, - } - - ahead := s.AheadRepos() - assert.Len(t, ahead, 1) - assert.Equal(t, "ahead-ok", ahead[0].Name) -}