feat: extract go-git from go-scm/git
Multi-repo git operations: parallel status, sequential push/pull, error handling with stderr capture. 96.7% test coverage. All 40 tests passing. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
commit
a3ff06c764
9 changed files with 1518 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Binaries
|
||||
/bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test
|
||||
*.test
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
58
CLAUDE.md
Normal file
58
CLAUDE.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# CLAUDE.md — go-git
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-repository git operations library. Parallel status checks, sequential push/pull (for SSH passphrase prompts), error handling with stderr capture.
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-git`
|
||||
**Extracted from:** `forge.lthn.ai/core/go-scm/git/`
|
||||
**Coverage:** 96.7%
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go test ./... -v # Run all tests
|
||||
go test -run TestName # Run single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Two files:
|
||||
- `git.go` — Core operations: Status, Push, Pull, PushMultiple (no framework dependency)
|
||||
- `service.go` — Core framework integration: queries (QueryStatus, QueryDirtyRepos, QueryAheadRepos) and tasks (TaskPush, TaskPull, TaskPushMultiple)
|
||||
|
||||
## Key Types
|
||||
|
||||
```go
|
||||
type RepoStatus struct {
|
||||
Name, Path, Branch string
|
||||
Modified, Untracked, Staged, Ahead, Behind int
|
||||
Error error
|
||||
}
|
||||
|
||||
func Status(ctx context.Context, opts StatusOptions) []RepoStatus // parallel
|
||||
func Push(ctx context.Context, path string) error // interactive (stdin/stdout)
|
||||
func Pull(ctx context.Context, path string) error // interactive (stdin/stdout)
|
||||
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult // sequential
|
||||
func IsNonFastForward(err error) bool
|
||||
```
|
||||
|
||||
## Test Naming
|
||||
|
||||
`_Good`, `_Bad`, `_Ugly` suffix pattern. Tests use real git repos via `initTestRepo()`.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- UK English in comments
|
||||
- `Co-Authored-By: Virgil <virgil@lethean.io>` in commits
|
||||
- Errors wrapped as `*GitError` with stderr capture
|
||||
|
||||
## Dependency
|
||||
|
||||
Only `forge.lthn.ai/core/go/pkg/framework` for ServiceRuntime integration. The core git operations (`git.go`) use only stdlib.
|
||||
|
||||
## Forge Remote
|
||||
|
||||
```bash
|
||||
git remote add forge ssh://git@forge.lthn.ai:2223/core/go-git.git
|
||||
```
|
||||
265
git.go
Normal file
265
git.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// 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
|
||||
}
|
||||
569
git_test.go
Normal file
569
git_test.go
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
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())
|
||||
}
|
||||
15
go.mod
Normal file
15
go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module forge.lthn.ai/core/go-git
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.0-20260221021314-d7e521561806
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
22
go.sum
Normal file
22
go.sum
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
forge.lthn.ai/core/go v0.0.0-20260221021314-d7e521561806 h1:jNj66sd9riyMZFSMHE1uusSze3i013P2nVh596dv8SM=
|
||||
forge.lthn.ai/core/go v0.0.0-20260221021314-d7e521561806/go.mod h1:acrcAouNXtzFARXz2bZ9wUC4pkHrq57n0pc94rytDLU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
126
service.go
Normal file
126
service.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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
|
||||
}
|
||||
287
service_extra_test.go
Normal file
287
service_extra_test.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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)
|
||||
}
|
||||
155
service_test.go
Normal file
155
service_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue