diff --git a/CLAUDE.md b/CLAUDE.md index 0914df4..7d1ebf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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` +**Module:** `dappco.re/go/git` **Go:** 1.26+ ## Build & Test diff --git a/README.md b/README.md index 758aa84..4236553 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-git.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-git) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/git.svg)](https://pkg.go.dev/dappco.re/go/git) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) [![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) # go-git -Go module: `forge.lthn.ai/core/go-git` +Go module: `dappco.re/go/git` + +Agent index: [`llms.txt`](llms.txt) ## License diff --git a/docs/architecture.md b/docs/architecture.md index debf877..85edeab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,6 +62,19 @@ type PushResult struct { } ``` +### PullResult + +Returned by `PullMultiple`, one per repository: + +```go +type PullResult struct { + Name string + Path string + Success bool + Error error +} +``` + ## Data flow ### Parallel status checking @@ -88,10 +101,10 @@ The `--porcelain` output is parsed character by character. Each line has a two-c | Position X (index) | Position Y (working tree) | Interpretation | |---------------------|---------------------------|----------------| | `?` | `?` | Untracked file | -| `A`, `D`, `R`, `M` | any | Staged change | -| any | `M`, `D` | Working tree modification | +| `A`, `D`, `R`, `M`, `U` | any | Staged change | +| any | `M`, `D`, `U` | Working tree modification | -A single file can increment both `Staged` and `Modified` if it has been staged and then further modified. +A single file can increment both `Staged` and `Modified` if it has been staged and then further modified. Unmerged paths (`U`) increment both counters, which keeps conflicted repositories visibly dirty. ### Interactive push and pull @@ -129,13 +142,13 @@ The factory constructs a `Service` embedding `core.ServiceRuntime[ServiceOptions ### Lifecycle -`Service` implements the `Startable` interface. On startup, it registers a query handler and a task handler with the Core message bus: +`Service` implements the `Startable` interface. On startup, it registers a query handler and a broadcast action handler with the Core message bus: ```go -func (s *Service) OnStartup(ctx context.Context) error { +func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil + s.Core().RegisterAction(s.handleTaskMessage) + return core.Result{OK: true} } ``` @@ -146,6 +159,7 @@ func (s *Service) OnStartup(ctx context.Context) error { | `QueryStatus` | `[]RepoStatus` | Checks Git status for a set of paths (runs in parallel). Updates the cached `lastStatus`. | | `QueryDirtyRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with uncommitted changes. | | `QueryAheadRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with unpushed commits. | +| `QueryBehindRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with unpulled commits. | `QueryStatus` has the same fields as `StatusOptions` and can be type-converted directly: @@ -160,6 +174,7 @@ statuses := Status(ctx, StatusOptions(queryStatus)) | `TaskPush` | `nil` | Pushes a single repository (interactive). | | `TaskPull` | `nil` | Pulls a single repository with `--rebase` (interactive). | | `TaskPushMultiple` | `[]PushResult` | Pushes multiple repositories sequentially. | +| `TaskPullMultiple` | `[]PullResult` | Pulls multiple repositories sequentially with `--rebase`. | ### Path validation @@ -193,10 +208,12 @@ The `Service` caches the most recent `QueryStatus` result in `lastStatus` (prote | `All()` | `iter.Seq[RepoStatus]` | Iterator over all cached statuses. | | `Dirty()` | `iter.Seq[RepoStatus]` | Iterator over repos where `IsDirty()` is true and `Error` is nil. | | `Ahead()` | `iter.Seq[RepoStatus]` | Iterator over repos where `HasUnpushed()` is true and `Error` is nil. | +| `Behind()` | `iter.Seq[RepoStatus]` | Iterator over repos where `HasUnpulled()` is true and `Error` is nil. | | `DirtyRepos()` | `[]RepoStatus` | Collects `Dirty()` into a slice. | | `AheadRepos()` | `[]RepoStatus` | Collects `Ahead()` into a slice. | +| `BehindRepos()` | `[]RepoStatus` | Collects `Behind()` into a slice. | -Errored repositories are excluded from `Dirty()` and `Ahead()` iterators. +Errored repositories are excluded from `Dirty()`, `Ahead()`, and `Behind()` iterators. ## Concurrency model diff --git a/docs/development.md b/docs/development.md index 9fd01b6..f6276f8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,7 @@ description: How to build, test, lint, and contribute to go-git. - **Git** (system binary -- the library shells out to `git`) - **golangci-lint** (for linting) -go-git is part of the Go workspace at `~/Code/go.work`. If you are working within that workspace, module resolution is handled automatically. Otherwise, ensure `GOPRIVATE=forge.lthn.ai/*` is set so Go can fetch private modules. +go-git is part of the Go workspace at `~/Code/go.work`. If you are working within that workspace, module resolution is handled automatically. Otherwise, ensure `GOPRIVATE=dappco.re/*` is set so Go can fetch private modules. ## Running tests diff --git a/docs/index.md b/docs/index.md index 4d64d3f..97f2fd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Multi-repository Git operations library for Go with parallel status # go-git -**Module:** `forge.lthn.ai/core/go-git` +**Module:** `dappco.re/go/git` **Go version:** 1.26+ @@ -13,7 +13,7 @@ description: Multi-repository Git operations library for Go with parallel status ## What it does -go-git is a Go library for orchestrating Git operations across multiple repositories. It was extracted from `forge.lthn.ai/core/go-scm/git/` into a standalone module. +go-git is a Go library for orchestrating Git operations across multiple repositories. It was extracted from `dappco.re/go/core/scm/git/` into a standalone module. The library provides two layers: @@ -33,7 +33,7 @@ import ( "context" "fmt" - git "forge.lthn.ai/core/go-git" + git "dappco.re/go/git" ) func main() { @@ -64,8 +64,8 @@ package main import ( "fmt" - "forge.lthn.ai/core/go/pkg/core" - git "forge.lthn.ai/core/go-git" + "dappco.re/go/core" + git "dappco.re/go/git" ) func main() { @@ -89,7 +89,7 @@ func main() { statuses := result.([]git.RepoStatus) for _, s := range statuses { - fmt.Printf("%s: dirty=%v ahead=%v\n", s.Name, s.IsDirty(), s.HasUnpushed()) + fmt.Printf("%s: dirty=%v ahead=%v behind=%v\n", s.Name, s.IsDirty(), s.HasUnpushed(), s.HasUnpulled()) } } ``` @@ -98,10 +98,10 @@ func main() { | File | Purpose | |------|---------| -| `git.go` | Standalone Git operations -- `Status`, `Push`, `Pull`, `PushMultiple`, error types. Zero framework dependencies. | -| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`). | +| `git.go` | Standalone Git operations -- `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, `PullMultiple`, `PullMultipleIter`, error types. Zero framework dependencies. | +| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`, `TaskPullMultiple`). | | `git_test.go` | Tests for standalone operations using real temporary Git repositories. | -| `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, iterators). | +| `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, `BehindRepos`, iterators). | | `service_extra_test.go` | Integration tests for `Service` query/task handlers against the Core framework. | ## Dependencies diff --git a/git.go b/git.go index 2865ede..8a6acdb 100644 --- a/git.go +++ b/git.go @@ -6,6 +6,7 @@ import ( "context" "fmt" goio "io" + "iter" "os" "os/exec" "path/filepath" @@ -13,10 +14,15 @@ import ( "strconv" "strings" "sync" - - coreerr "forge.lthn.ai/core/go-log" ) +func withBackground(ctx context.Context) context.Context { + if ctx != nil { + return ctx + } + return context.Background() +} + // RepoStatus represents the git status of a single repository. type RepoStatus struct { Name string @@ -54,36 +60,62 @@ type StatusOptions struct { } // Status checks git status for multiple repositories in parallel. +// +// Example: +// +// statuses := Status(ctx, StatusOptions{Paths: []string{"/home/user/Code/core/agent"}}) 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) + return slices.Collect(StatusIter(withBackground(ctx), opts)) +} + +func repoName(path string, names map[string]string) string { + if names == nil { + return path } - wg.Wait() - return results + name := names[path] + if name == "" { + return path + } + return name +} + +// StatusIter checks git status for multiple repositories in parallel and yields +// the results in input order. +func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] { + ctx = withBackground(ctx) + return func(yield func(RepoStatus) bool) { + 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 := repoName(repoPath, opts.Names) + results[idx] = getStatus(ctx, repoPath, name) + }(i, path) + } + + wg.Wait() + for _, result := range results { + if !yield(result) { + return + } + } + } } // getStatus gets the git status for a single repository. func getStatus(ctx context.Context, path, name string) RepoStatus { + ctx = withBackground(ctx) status := RepoStatus{ Name: name, Path: path, } - // Validate path to prevent directory traversal - if !filepath.IsAbs(path) { - status.Error = coreerr.E("git.getStatus", "path must be absolute: "+path, nil) + if err := requireAbsolutePath("git.getStatus", path); err != nil { + status.Error = err return status } @@ -93,7 +125,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { status.Error = err return status } - status.Branch = strings.TrimSpace(branch) + status.Branch = trim(branch) // Get porcelain status porcelain, err := gitCommand(ctx, path, "status", "--porcelain") @@ -103,7 +135,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Parse status output - for line := range strings.SplitSeq(porcelain, "\n") { + for _, line := range strings.Split(porcelain, "\n") { if len(line) < 2 { continue } @@ -116,12 +148,12 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Staged (index has changes) - if slices.Contains([]byte{'A', 'D', 'R', 'M'}, x) { + if isStagedStatus(x) { status.Staged++ } // Modified in working tree - if slices.Contains([]byte{'M', 'D'}, y) { + if isModifiedStatus(y) { status.Modified++ } } @@ -129,8 +161,9 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { // Get ahead/behind counts ahead, behind, err := getAheadBehind(ctx, path) if err != nil { - // We don't fail the whole status if ahead/behind fails (might be no upstream) - // but we could log it or store it if needed. For now, we just keep 0. + // We don't fail the whole status for missing upstream branches. + // We do surface other ahead/behind failures on the result. + status.Error = err } status.Ahead = ahead status.Behind = behind @@ -138,45 +171,105 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { return status } +func isStagedStatus(ch byte) bool { + switch ch { + case 'A', 'C', 'D', 'R', 'M', 'T', 'U': + return true + default: + return false + } +} + +func isModifiedStatus(ch byte) bool { + switch ch { + case 'M', 'D', 'T', 'U': + return true + default: + return false + } +} + +// isNoUpstreamError reports whether an error is due to a missing tracking branch. +func isNoUpstreamError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(trim(err.Error())) + return strings.Contains(msg, "no upstream") +} + +func requireAbsolutePath(op string, path string) error { + if filepath.IsAbs(path) { + return nil + } + return &GitError{ + Args: []string{op}, + Err: fmt.Errorf("path must be absolute: %s", path), + Stderr: "", + } +} + // getAheadBehind returns the number of commits ahead and behind upstream. func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { - // Try to get ahead count + ctx = withBackground(ctx) + if err := requireAbsolutePath("git.getAheadBehind", path); err != nil { + return 0, 0, err + } + aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") if err == nil { - ahead, _ = strconv.Atoi(strings.TrimSpace(aheadStr)) - } else { - // If it failed because of no upstream, don't return error - if strings.Contains(err.Error(), "no upstream") || strings.Contains(err.Error(), "No upstream") { - err = nil + ahead, err = strconv.Atoi(trim(aheadStr)) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse ahead count: %w", err) } + } else if isNoUpstreamError(err) { + err = nil } if err != nil { return 0, 0, err } - // Try to get behind count behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") if err == nil { - behind, _ = strconv.Atoi(strings.TrimSpace(behindStr)) - } else { - if strings.Contains(err.Error(), "no upstream") || strings.Contains(err.Error(), "No upstream") { - err = nil + behind, err = strconv.Atoi(trim(behindStr)) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse behind count: %w", err) } + } else if isNoUpstreamError(err) { + err = nil } return ahead, behind, err } // Push pushes commits for a single repository. +// +// Example: +// +// err := Push(ctx, "/home/user/Code/core/agent") +// // Uses interactive mode to support SSH passphrase prompts. func Push(ctx context.Context, path string) error { + ctx = withBackground(ctx) + if err := requireAbsolutePath("git.push", path); err != nil { + return err + } return gitInteractive(ctx, path, "push") } // Pull pulls changes for a single repository. +// +// Example: +// +// err := Pull(ctx, "/home/user/Code/core/agent") +// // Uses interactive mode to support SSH passphrase prompts. func Pull(ctx context.Context, path string) error { + ctx = withBackground(ctx) + if err := requireAbsolutePath("git.pull", path); err != nil { + return err + } return gitInteractive(ctx, path, "pull", "--rebase") } @@ -185,7 +278,7 @@ func IsNonFastForward(err error) bool { if err == nil { return false } - msg := err.Error() + msg := strings.ToLower(err.Error()) return strings.Contains(msg, "non-fast-forward") || strings.Contains(msg, "fetch first") || strings.Contains(msg, "tip of your current branch is behind") @@ -193,6 +286,11 @@ func IsNonFastForward(err error) bool { // gitInteractive runs a git command with terminal attached for user interaction. func gitInteractive(ctx context.Context, dir string, args ...string) error { + ctx = withBackground(ctx) + if err := requireAbsolutePath("git.interactive", dir); err != nil { + return err + } + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir @@ -223,39 +321,107 @@ type PushResult struct { Error error } +// PullResult represents the result of a pull operation. +type PullResult 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, error) { - results := make([]PushResult, len(paths)) + results := slices.Collect(PushMultipleIter(withBackground(ctx), paths, names)) var lastErr error - for i, path := range paths { - name := names[path] - if name == "" { - name = path + for _, result := range results { + if result.Error != nil { + lastErr = result.Error } + } - result := PushResult{ - Name: name, - Path: path, - } + return results, lastErr +} - err := Push(ctx, path) - if err != nil { - result.Error = err - lastErr = err - } else { - result.Success = true +// PushMultipleIter pushes multiple repositories sequentially and yields each +// per-repository result in input order. +func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] { + ctx = withBackground(ctx) + return func(yield func(PushResult) bool) { + for _, path := range paths { + name := repoName(path, names) + + result := PushResult{ + Name: name, + Path: path, + } + + if err := requireAbsolutePath("git.pushMultiple", path); err != nil { + result.Error = err + } else if err := Push(ctx, path); err != nil { + result.Error = err + } else { + result.Success = true + } + + if !yield(result) { + return + } } + } +} + +// PullMultiple pulls changes for multiple repositories sequentially. +// Sequential because interactive terminal I/O needs a single active prompt. +func PullMultiple(ctx context.Context, paths []string, names map[string]string) ([]PullResult, error) { + results := slices.Collect(PullMultipleIter(withBackground(ctx), paths, names)) + var lastErr error - results[i] = result + for _, result := range results { + if result.Error != nil { + lastErr = result.Error + } } return results, lastErr } +// PullMultipleIter pulls changes for multiple repositories sequentially and yields +// each per-repository result in input order. +func PullMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PullResult] { + ctx = withBackground(ctx) + return func(yield func(PullResult) bool) { + for _, path := range paths { + name := repoName(path, names) + + result := PullResult{ + Name: name, + Path: path, + } + + if err := requireAbsolutePath("git.pullMultiple", path); err != nil { + result.Error = err + } else if err := Pull(ctx, path); err != nil { + result.Error = err + } else { + result.Success = true + } + + if !yield(result) { + return + } + } + } +} + // gitCommand runs a git command and returns stdout. func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { + ctx = withBackground(ctx) + if err := requireAbsolutePath("git.command", dir); err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir @@ -287,7 +453,7 @@ type GitError struct { // Error returns a descriptive error message. func (e *GitError) Error() string { cmd := "git " + strings.Join(e.Args, " ") - stderr := strings.TrimSpace(e.Stderr) + stderr := trim(e.Stderr) if stderr != "" { return fmt.Sprintf("git command %q failed: %s", cmd, stderr) @@ -302,3 +468,7 @@ func (e *GitError) Error() string { func (e *GitError) Unwrap() error { return e.Err } + +func trim(s string) string { + return strings.TrimSpace(s) +} diff --git a/git_test.go b/git_test.go index 5c7d972..50d339c 100644 --- a/git_test.go +++ b/git_test.go @@ -6,8 +6,10 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +33,7 @@ func initTestRepo(t *testing.T) string { } // Create a file and commit it so HEAD exists. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Test\n"), 0644)) cmds = [][]string{ {"git", "add", "README.md"}, @@ -190,7 +192,7 @@ 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)) + assert.True(t, core.Is(gitErr, inner)) } // --- IsNonFastForward tests --- @@ -259,12 +261,27 @@ func TestGitCommand_Bad_NotARepo(t *testing.T) { // Should be a GitError with stderr. var gitErr *GitError - if errors.As(err, &gitErr) { + if core.As(err, &gitErr) { assert.Contains(t, gitErr.Stderr, "not a git repository") assert.Equal(t, []string{"status"}, gitErr.Args) } } +func TestGitCommand_Bad_RelativePath(t *testing.T) { + _, err := gitCommand(context.Background(), "relative/path", "status") + assert.Error(t, err) +} + +func TestPush_Bad_RelativePath(t *testing.T) { + err := Push(context.Background(), "relative/path") + assert.Error(t, err) +} + +func TestPull_Bad_RelativePath(t *testing.T) { + err := Pull(context.Background(), "relative/path") + assert.Error(t, err) +} + // --- getStatus integration tests --- func TestGetStatus_Good_CleanRepo(t *testing.T) { @@ -282,7 +299,7 @@ func TestGetStatus_Good_ModifiedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Modify the existing tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Modified\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Modified\n"), 0644)) status := getStatus(context.Background(), dir, "modified-repo") require.NoError(t, status.Error) @@ -294,7 +311,7 @@ func TestGetStatus_Good_UntrackedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create a new untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "newfile.txt"), []byte("hello"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "newfile.txt"), []byte("hello"), 0644)) status := getStatus(context.Background(), dir, "untracked-repo") require.NoError(t, status.Error) @@ -306,7 +323,7 @@ func TestGetStatus_Good_StagedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create and stage a new file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "staged.txt"), []byte("staged"), 0644)) cmd := exec.Command("git", "add", "staged.txt") cmd.Dir = dir require.NoError(t, cmd.Run()) @@ -321,13 +338,13 @@ func TestGetStatus_Good_MixedChanges(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "untracked.txt"), []byte("new"), 0644)) // Modify tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Changed\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(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)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "staged.txt"), []byte("staged"), 0644)) cmd := exec.Command("git", "add", "staged.txt") cmd.Dir = dir require.NoError(t, cmd.Run()) @@ -344,7 +361,7 @@ func TestGetStatus_Good_DeletedTrackedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Delete the tracked file (unstaged deletion). - require.NoError(t, os.Remove(filepath.Join(dir, "README.md"))) + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) status := getStatus(context.Background(), dir, "deleted-repo") require.NoError(t, status.Error) @@ -366,6 +383,54 @@ func TestGetStatus_Good_StagedDeletion(t *testing.T) { assert.True(t, status.IsDirty()) } +func TestGetStatus_Good_MergeConflict(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Create a conflicting change on a feature branch. + cmd := exec.Command("git", "checkout", "-b", "feature") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Feature\n"), 0644)) + for _, args := range [][]string{ + {"git", "add", "README.md"}, + {"git", "commit", "-m", "feature change"}, + } { + 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 to the original branch and create a divergent change. + cmd = exec.Command("git", "checkout", "-") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Main\n"), 0644)) + for _, args := range [][]string{ + {"git", "add", "README.md"}, + {"git", "commit", "-m", "main change"}, + } { + 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)) + } + + cmd = exec.Command("git", "merge", "feature") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.Error(t, err, "expected the merge to conflict") + assert.Contains(t, string(out), "CONFLICT") + + status := getStatus(context.Background(), dir, "conflicted-repo") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Staged, "unmerged paths count as staged") + assert.Equal(t, 1, status.Modified, "unmerged paths count as modified") + 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) @@ -386,7 +451,7 @@ func TestStatus_Good_MultipleRepos(t *testing.T) { dir2, _ := filepath.Abs(initTestRepo(t)) // Make dir2 dirty. - require.NoError(t, os.WriteFile(filepath.Join(dir2, "extra.txt"), []byte("extra"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir2, "extra.txt"), []byte("extra"), 0644)) results := Status(context.Background(), StatusOptions{ Paths: []string{dir1, dir2}, @@ -407,6 +472,27 @@ func TestStatus_Good_MultipleRepos(t *testing.T) { assert.True(t, results[1].IsDirty()) } +func TestStatusIter_Good_MultipleRepos(t *testing.T) { + dir1, _ := filepath.Abs(initTestRepo(t)) + dir2, _ := filepath.Abs(initTestRepo(t)) + + require.NoError(t, os.WriteFile(core.JoinPath(dir2, "extra.txt"), []byte("extra"), 0644)) + + statuses := slices.Collect(StatusIter(context.Background(), StatusOptions{ + Paths: []string{dir1, dir2}, + Names: map[string]string{ + dir1: "clean-repo", + dir2: "dirty-repo", + }, + })) + + require.Len(t, statuses, 2) + assert.Equal(t, "clean-repo", statuses[0].Name) + assert.Equal(t, "dirty-repo", statuses[1].Name) + assert.False(t, statuses[0].IsDirty()) + assert.True(t, statuses[1].IsDirty()) +} + func TestStatus_Good_EmptyPaths(t *testing.T) { results := Status(context.Background(), StatusOptions{ Paths: []string{}, @@ -463,6 +549,21 @@ func TestPushMultiple_Good_NoRemote(t *testing.T) { assert.Error(t, results[0].Error) } +func TestPullMultiple_Good_NoRemote(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{ + dir: "test-repo", + }) + assert.Error(t, err) + + require.Len(t, results, 1) + assert.Equal(t, "test-repo", results[0].Name) + assert.Equal(t, dir, results[0].Path) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestPushMultiple_Good_NameFallback(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) @@ -473,6 +574,70 @@ func TestPushMultiple_Good_NameFallback(t *testing.T) { assert.Equal(t, dir, results[0].Name, "name should fall back to path") } +func TestPullMultiple_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{}) + assert.Error(t, err) + + require.Len(t, results, 1) + assert.Equal(t, dir, results[0].Name, "name should fall back to path") +} + +func TestPushMultipleIter_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results := slices.Collect(PushMultipleIter(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") + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + +func TestPullMultipleIter_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results := slices.Collect(PullMultipleIter(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") + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + +func TestPushMultiple_Bad_RelativePath(t *testing.T) { + validDir, _ := filepath.Abs(initTestRepo(t)) + relativePath := "relative/repo" + + results, err := PushMultiple(context.Background(), []string{relativePath, validDir}, map[string]string{ + validDir: "valid-repo", + }) + + assert.Error(t, err) + require.Len(t, results, 2) + assert.Equal(t, relativePath, results[0].Path) + assert.Error(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), "path must be absolute") + assert.Equal(t, validDir, results[1].Path) +} + +func TestPullMultiple_Bad_RelativePath(t *testing.T) { + validDir, _ := filepath.Abs(initTestRepo(t)) + relativePath := "relative/repo" + + results, err := PullMultiple(context.Background(), []string{relativePath, validDir}, map[string]string{ + validDir: "valid-repo", + }) + + assert.Error(t, err) + require.Len(t, results, 2) + assert.Equal(t, relativePath, results[0].Path) + assert.Error(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), "path must be absolute") + assert.Equal(t, validDir, results[1].Path) +} + // --- Pull tests --- func TestPull_Bad_NoRemote(t *testing.T) { @@ -529,7 +694,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) { } // Create initial commit and push. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "initial"}, @@ -542,7 +707,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) { } // Make a local commit without pushing (ahead by 1). - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "local commit"}, @@ -573,3 +738,33 @@ func TestGetStatus_Good_RenamedFile(t *testing.T) { assert.Equal(t, 1, status.Staged, "rename should count as staged") assert.True(t, status.IsDirty()) } + +func TestGetStatus_Good_TypeChangedFile_WorkingTree(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Replace the tracked file with a symlink to trigger a working-tree type change. + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) + require.NoError(t, os.Symlink("/etc/hosts", core.JoinPath(dir, "README.md"))) + + status := getStatus(context.Background(), dir, "typechanged-working-tree") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Modified, "type change in working tree counts as modified") + assert.True(t, status.IsDirty()) +} + +func TestGetStatus_Good_TypeChangedFile_Staged(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Stage a type change by replacing the tracked file with a symlink and adding it. + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) + require.NoError(t, os.Symlink("/etc/hosts", core.JoinPath(dir, "README.md"))) + + cmd := exec.Command("git", "add", "README.md") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + status := getStatus(context.Background(), dir, "typechanged-staged") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Staged, "type change in the index counts as staged") + assert.True(t, status.IsDirty()) +} diff --git a/go.mod b/go.mod index 5d52bdd..b85d152 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ -module forge.lthn.ai/core/go-git +module dappco.re/go/git go 1.26.0 require ( - dappco.re/go/core v0.4.7 - forge.lthn.ai/core/go-log v0.0.4 + dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/core/log v0.1.0 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index 32c259a..872f5dc 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= -dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= 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= diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..0c4a95b --- /dev/null +++ b/llms.txt @@ -0,0 +1,38 @@ +# go-git + +Module: `dappco.re/go/git` +Purpose: Multi-repository Git operations library with standalone helpers and Core service integration. + +## Entry Points + +- `git.go`: standalone Git helpers. Exports `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, `PullMultiple`, `PullMultipleIter`, `RepoStatus`, `GitError`, and `IsNonFastForward`. +- `service.go`: Core integration. Exports `NewService`, `Service`, `ServiceOptions`, `QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`, `TaskPush`, `TaskPull`, `TaskPushMultiple`, and `TaskPullMultiple`. +- `docs/architecture.md`: design and data-flow notes. +- `docs/development.md`: test and contribution workflow. + +## Operational Rules + +- Paths must be absolute. +- `ServiceOptions.WorkDir` is optional; when set, all paths must stay within that directory. +- `Status` checks repositories in parallel. +- `Push`, `Pull`, `PushMultiple`, and `PullMultiple` run interactively so SSH prompts can reach the terminal. +- `PushMultiple` and `PullMultiple` are sequential by design. + +## Common Patterns + +- Use `Status(ctx, StatusOptions{Paths: ..., Names: ...})` for batch inspection. +- Use `Service` queries to reuse cached status data with `DirtyRepos`, `AheadRepos`, and `BehindRepos`. +- Use `GitError` to inspect failed command stderr and args. +- Use `IsNonFastForward(err)` to detect pull-before-push rejections. + +## Testing + +```bash +GOMODCACHE=/tmp/gomodcache GOPATH=/tmp/gopath go test ./... +``` + +## Conventions + +- Tests use real temporary git repositories. +- Comments and docs use UK English. +- Commits use conventional prefixes like `feat:`, `fix:`, `refactor:`, and `docs:`. diff --git a/service.go b/service.go index 7b2a68c..e004cfb 100644 --- a/service.go +++ b/service.go @@ -5,11 +5,10 @@ import ( "iter" "path/filepath" "slices" - "strings" "sync" "dappco.re/go/core" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) // Queries for git service @@ -26,6 +25,9 @@ type QueryDirtyRepos struct{} // QueryAheadRepos requests repos with unpushed commits. type QueryAheadRepos struct{} +// QueryBehindRepos requests repos with unpulled commits. +type QueryBehindRepos struct{} + // Tasks for git service // TaskPush requests git push for a path. @@ -46,6 +48,12 @@ type TaskPushMultiple struct { Names map[string]string } +// TaskPullMultiple requests git pull for multiple paths. +type TaskPullMultiple struct { + Paths []string + Names map[string]string +} + // ServiceOptions for configuring the git service. type ServiceOptions struct { WorkDir string @@ -62,6 +70,13 @@ type Service struct { lastStatus []RepoStatus } +const ( + actionGitPush = "git.push" + actionGitPull = "git.pull" + actionGitPushMultiple = "git.push-multiple" + actionGitPullMultiple = "git.pull-multiple" +) + // NewService creates a git service factory. func NewService(opts ServiceOptions) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { @@ -72,23 +87,63 @@ func NewService(opts ServiceOptions) func(*core.Core) (any, error) { } } -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { +// OnStartup registers query and action handlers. +func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil + s.Core().RegisterAction(s.handleTaskMessage) + + s.Core().Action(actionGitPush, func(ctx context.Context, opts core.Options) core.Result { + path := opts.String("path") + return s.runPush(ctx, path) + }) + + s.Core().Action(actionGitPull, func(ctx context.Context, opts core.Options) core.Result { + path := opts.String("path") + return s.runPull(ctx, path) + }) + + s.Core().Action(actionGitPushMultiple, func(ctx context.Context, opts core.Options) core.Result { + r := opts.Get("paths") + paths, _ := r.Value.([]string) + r = opts.Get("names") + names, _ := r.Value.(map[string]string) + return s.runPushMultiple(ctx, paths, names) + }) + + s.Core().Action(actionGitPullMultiple, func(ctx context.Context, opts core.Options) core.Result { + r := opts.Get("paths") + paths, _ := r.Value.([]string) + r = opts.Get("names") + names, _ := r.Value.(map[string]string) + return s.runPullMultiple(ctx, paths, names) + }) + + return core.Result{OK: true} +} + +// handleTaskMessage bridges task structs onto the Core action bus. +func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result { + switch m := msg.(type) { + case TaskPush: + return s.handleTask(c, m) + case TaskPull: + return s.handleTask(c, m) + case TaskPushMultiple: + return s.handleTask(c, m) + case TaskPullMultiple: + return s.handleTask(c, m) + default: + return core.Result{} + } } func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { - ctx := context.Background() // TODO: core should pass context to handlers + ctx := c.Context() switch m := q.(type) { case QueryStatus: - // Validate all paths before execution - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleQuery", "path validation failed") - } + if err := s.validatePaths(m.Paths); err != nil { + return c.LogError(err, "git.handleQuery", "path validation failed") } statuses := Status(ctx, StatusOptions(m)) @@ -104,46 +159,72 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { case QueryAheadRepos: return core.Result{Value: s.AheadRepos(), OK: true} + case QueryBehindRepos: + return core.Result{Value: s.BehindRepos(), OK: true} } return core.Result{} } -func (s *Service) handleTask(c *core.Core, t core.Task) core.Result { - ctx := context.Background() // TODO: core should pass context to handlers +func (s *Service) handleTask(c *core.Core, t any) core.Result { + ctx := c.Context() switch m := t.(type) { case TaskPush: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Push(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "push failed") - } - return core.Result{OK: true} + return s.runPush(ctx, m.Path) case TaskPull: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Pull(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "pull failed") - } - return core.Result{OK: true} + return s.runPull(ctx, m.Path) case TaskPushMultiple: - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - } - results, err := PushMultiple(ctx, m.Paths, m.Names) - if err != nil { - // Log for observability; partial results are still returned. - _ = c.LogError(err, "git.handleTask", "push multiple had failures") - } - return core.Result{Value: results, OK: true} + return s.runPushMultiple(ctx, m.Paths, m.Names) + + case TaskPullMultiple: + return s.runPullMultiple(ctx, m.Paths, m.Names) } - return core.Result{} + + return c.LogError(coreerr.E("git.handleTask", "unsupported task type", nil), "git.handleTask", "unsupported task type") +} + +func (s *Service) runPush(ctx context.Context, path string) core.Result { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.push", "path validation failed") + } + if err := Push(ctx, path); err != nil { + return s.Core().LogError(err, "git.push", "push failed") + } + return core.Result{OK: true} +} + +func (s *Service) runPull(ctx context.Context, path string) core.Result { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.pull", "path validation failed") + } + if err := Pull(ctx, path); err != nil { + return s.Core().LogError(err, "git.pull", "pull failed") + } + return core.Result{OK: true} +} + +func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { + if err := s.validatePaths(paths); err != nil { + return s.Core().LogError(err, "git.push-multiple", "path validation failed") + } + results, err := PushMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.push-multiple", "push multiple had failures") + } + return core.Result{Value: results, OK: err == nil} +} + +func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { + if err := s.validatePaths(paths); err != nil { + return s.Core().LogError(err, "git.pull-multiple", "path validation failed") + } + results, err := PullMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.pull-multiple", "pull multiple had failures") + } + return core.Result{Value: results, OK: err == nil} } func (s *Service) validatePath(path string) error { @@ -152,10 +233,25 @@ func (s *Service) validatePath(path string) error { } workDir := s.opts.WorkDir - if workDir != "" { - rel, err := filepath.Rel(workDir, path) - if err != nil || strings.HasPrefix(rel, "..") { - return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) + if workDir == "" { + return nil + } + + workDir = filepath.Clean(workDir) + if !filepath.IsAbs(workDir) { + return coreerr.E("git.validatePath", "WorkDir must be absolute: "+s.opts.WorkDir, nil) + } + rel, err := filepath.Rel(workDir, filepath.Clean(path)) + if err != nil || rel == ".." || core.HasPrefix(rel, ".."+string(filepath.Separator)) { + return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) + } + return nil +} + +func (s *Service) validatePaths(paths []string) error { + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return err } } return nil @@ -175,15 +271,15 @@ func (s *Service) All() iter.Seq[RepoStatus] { return slices.Values(slices.Clone(s.lastStatus)) } -// Dirty returns an iterator over repos with uncommitted changes. -func (s *Service) Dirty() iter.Seq[RepoStatus] { +// filteredIter returns an iterator over status entries that satisfy pred. +func (s *Service) filteredIter(pred func(RepoStatus) bool) iter.Seq[RepoStatus] { s.mu.RLock() defer s.mu.RUnlock() - lastStatus := slices.Clone(s.lastStatus) + snapshot := slices.Clone(s.lastStatus) return func(yield func(RepoStatus) bool) { - for _, st := range lastStatus { - if st.Error == nil && st.IsDirty() { + for _, st := range snapshot { + if st.Error == nil && pred(st) { if !yield(st) { return } @@ -192,21 +288,19 @@ func (s *Service) Dirty() iter.Seq[RepoStatus] { } } +// Dirty returns an iterator over repos with uncommitted changes. +func (s *Service) Dirty() iter.Seq[RepoStatus] { + return s.filteredIter(func(st RepoStatus) bool { return st.IsDirty() }) +} + // Ahead returns an iterator over repos with unpushed commits. func (s *Service) Ahead() iter.Seq[RepoStatus] { - s.mu.RLock() - defer s.mu.RUnlock() - lastStatus := slices.Clone(s.lastStatus) + return s.filteredIter(func(st RepoStatus) bool { return st.HasUnpushed() }) +} - return func(yield func(RepoStatus) bool) { - for _, st := range lastStatus { - if st.Error == nil && st.HasUnpushed() { - if !yield(st) { - return - } - } - } - } +// Behind returns an iterator over repos with unpulled commits. +func (s *Service) Behind() iter.Seq[RepoStatus] { + return s.filteredIter(func(st RepoStatus) bool { return st.HasUnpulled() }) } // DirtyRepos returns repos with uncommitted changes. @@ -218,3 +312,8 @@ func (s *Service) DirtyRepos() []RepoStatus { func (s *Service) AheadRepos() []RepoStatus { return slices.Collect(s.Ahead()) } + +// BehindRepos returns repos with unpulled commits. +func (s *Service) BehindRepos() []RepoStatus { + return slices.Collect(s.Behind()) +} diff --git a/service_extra_test.go b/service_extra_test.go index 3d6ddf3..762d5af 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -29,6 +29,20 @@ func TestService_ValidatePath_Bad_OutsideWorkDir(t *testing.T) { assert.Contains(t, err.Error(), "outside of allowed WorkDir") } +func TestService_ValidatePath_Bad_OutsideWorkDirPrefix(t *testing.T) { + svc := &Service{opts: ServiceOptions{WorkDir: "/home/repos"}} + err := svc.validatePath("/home/repos2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "outside of allowed WorkDir") +} + +func TestService_ValidatePath_Bad_WorkDirNotAbsolute(t *testing.T) { + svc := &Service{opts: ServiceOptions{WorkDir: "relative/workdir"}} + err := svc.validatePath("/any/absolute/path") + assert.Error(t, err) + assert.Contains(t, err.Error(), "WorkDir must be absolute") +} + func TestService_ValidatePath_Good_InsideWorkDir(t *testing.T) { svc := &Service{opts: ServiceOptions{WorkDir: "/home/repos"}} err := svc.validatePath("/home/repos/my-project") @@ -60,42 +74,69 @@ func TestService_HandleQuery_Bad_InvalidPath(t *testing.T) { // --- handleTask path validation --- -func TestService_HandleTask_Bad_PushInvalidPath(t *testing.T) { +func TestService_Action_Bad_PushInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPush{Path: "relative/path"}) + result := c.Action("git.push").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: "relative/path"}, + )) + _ = svc assert.False(t, result.OK) } -func TestService_HandleTask_Bad_PullInvalidPath(t *testing.T) { +func TestService_Action_Bad_PullInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPull{Path: "/etc/passwd"}) + result := c.Action("git.pull").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: "/etc/passwd"}, + )) + _ = svc assert.False(t, result.OK) } -func TestService_HandleTask_Bad_PushMultipleInvalidPath(t *testing.T) { +func TestService_Action_Bad_PushMultipleInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPushMultiple{ - Paths: []string{"/home/repos/ok", "/etc/bad"}, - Names: map[string]string{}, - }) + opts := core.NewOptions() + opts.Set("paths", []string{"/home/repos/ok", "/etc/bad"}) + opts.Set("names", map[string]string{}) + result := c.Action("git.push-multiple").Run(context.Background(), opts) + _ = svc + assert.False(t, result.OK) +} + +func TestService_Action_Bad_PullMultipleInvalidPath(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), + opts: ServiceOptions{WorkDir: "/home/repos"}, + } + svc.OnStartup(context.Background()) + + opts := core.NewOptions() + opts.Set("paths", []string{"/home/repos/ok", "/etc/bad"}) + opts.Set("names", map[string]string{}) + result := c.Action("git.pull-multiple").Run(context.Background(), opts) + _ = svc assert.False(t, result.OK) } @@ -125,8 +166,8 @@ func TestService_OnStartup_Good(t *testing.T) { opts: opts, } - err := svc.OnStartup(context.Background()) - assert.NoError(t, err) + result := svc.OnStartup(context.Background()) + assert.True(t, result.OK) } func TestService_HandleQuery_Good_Status(t *testing.T) { @@ -195,7 +236,160 @@ func TestService_HandleQuery_Good_AheadRepos(t *testing.T) { assert.Equal(t, "ahead", ahead[0].Name) } -func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { +func TestService_HandleQuery_Good_BehindRepos(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + lastStatus: []RepoStatus{ + {Name: "synced"}, + {Name: "behind", Behind: 2}, + }, + } + + result := svc.handleQuery(c, QueryBehindRepos{}) + assert.True(t, result.OK) + + behind, ok := result.Value.([]RepoStatus) + require.True(t, ok) + assert.Len(t, behind, 1) + assert.Equal(t, "behind", behind[0].Name) +} + +func TestService_HandleTaskMessage_Good_TaskPush(t *testing.T) { + bareDir, _ := filepath.Abs(t.TempDir()) + cloneDir, _ := filepath.Abs(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(core.JoinPath(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)) + } + + require.NoError(t, os.WriteFile(core.JoinPath(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()) + } + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTaskMessage(c, TaskPush{Path: cloneDir}) + assert.True(t, result.OK) +} + +func TestService_HandleTaskMessage_Ignores_UnknownTask(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTaskMessage(c, struct{}{}) + assert.False(t, result.OK) + assert.Nil(t, result.Value) +} + +func TestService_HandleTask_Bad_UnknownTask(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTask(c, struct{}{}) + assert.False(t, result.OK) + assert.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "unsupported task type") +} + +func TestService_Action_Good_TaskPush(t *testing.T) { + bareDir, _ := filepath.Abs(t.TempDir()) + cloneDir, _ := filepath.Abs(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(core.JoinPath(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)) + } + + require.NoError(t, os.WriteFile(core.JoinPath(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()) + } + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + result := c.ACTION(TaskPush{Path: cloneDir}) + assert.True(t, result.OK) + + ahead, behind, err := getAheadBehind(context.Background(), cloneDir) + require.NoError(t, err) + assert.Equal(t, 0, ahead) + assert.Equal(t, 0, behind) +} + +func TestService_HandleQuery_Ignores_UnknownQuery(t *testing.T) { c := core.New() svc := &Service{ @@ -207,7 +401,7 @@ func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { assert.Nil(t, result.Value) } -func TestService_HandleTask_Bad_PushNoRemote(t *testing.T) { +func TestService_Action_Bad_PushNoRemote(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) c := core.New() @@ -215,12 +409,15 @@ func TestService_HandleTask_Bad_PushNoRemote(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPush{Path: dir, Name: "test"}) + result := c.Action("git.push").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: dir}, + )) assert.False(t, result.OK, "push without remote should fail") } -func TestService_HandleTask_Bad_PullNoRemote(t *testing.T) { +func TestService_Action_Bad_PullNoRemote(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) c := core.New() @@ -228,11 +425,65 @@ func TestService_HandleTask_Bad_PullNoRemote(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPull{Path: dir, Name: "test"}) + result := c.Action("git.pull").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: dir}, + )) assert.False(t, result.OK, "pull without remote should fail") } +func TestService_Action_Good_PushMultiple(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + opts := core.NewOptions() + opts.Set("paths", []string{dir}) + opts.Set("names", map[string]string{dir: "test"}) + result := c.Action("git.push-multiple").Run(context.Background(), opts) + _ = svc + + // PushMultiple returns results even when individual pushes fail, but the + // overall action should still report failure. + assert.False(t, result.OK) + + results, ok := result.Value.([]PushResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.False(t, results[0].Success) // No remote +} + +func TestService_Action_Good_PullMultiple(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + opts := core.NewOptions() + opts.Set("paths", []string{dir}) + opts.Set("names", map[string]string{dir: "test"}) + result := c.Action("git.pull-multiple").Run(context.Background(), opts) + _ = svc + + assert.False(t, result.OK) + results, ok := result.Value.([]PullResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestService_HandleTask_Good_PushMultiple(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) @@ -247,25 +498,36 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) { Names: map[string]string{dir: "test"}, }) - // PushMultiple returns results even when individual pushes fail. - assert.True(t, result.OK) - + assert.False(t, result.OK) results, ok := result.Value.([]PushResult) require.True(t, ok) assert.Len(t, results, 1) - assert.False(t, results[0].Success) // No remote + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) } -func TestService_HandleTask_Good_UnknownTask(t *testing.T) { +func TestService_HandleTask_Good_PullMultiple(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } - result := svc.handleTask(c, "unknown task") + result := svc.handleTask(c, TaskPullMultiple{ + Paths: []string{dir}, + Names: map[string]string{dir: "test"}, + }) + assert.False(t, result.OK) - assert.Nil(t, result.Value) + results, ok := result.Value.([]PullResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) } // --- Additional git operation tests --- @@ -325,7 +587,7 @@ func TestPush_Good_WithRemote(t *testing.T) { require.NoError(t, cmd.Run()) } - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "initial"}, @@ -338,7 +600,7 @@ func TestPush_Good_WithRemote(t *testing.T) { } // Make a local commit. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "second commit"}, diff --git a/service_test.go b/service_test.go index b199126..1fb86d5 100644 --- a/service_test.go +++ b/service_test.go @@ -97,6 +97,49 @@ func TestService_AheadRepos_Good_EmptyStatus(t *testing.T) { assert.Empty(t, ahead) } +func TestService_BehindRepos_Good(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "synced", Behind: 0}, + {Name: "behind-by-one", Behind: 1}, + {Name: "behind-by-five", Behind: 5}, + {Name: "ahead-only", Ahead: 3}, + {Name: "errored-behind", Behind: 2, Error: assert.AnError}, + }, + } + + behind := s.BehindRepos() + assert.Len(t, behind, 2) + + names := slices.Collect(func(yield func(string) bool) { + for _, b := range behind { + if !yield(b.Name) { + return + } + } + }) + assert.Contains(t, names, "behind-by-one") + assert.Contains(t, names, "behind-by-five") +} + +func TestService_BehindRepos_Good_NoneFound(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "synced1"}, + {Name: "synced2"}, + }, + } + + behind := s.BehindRepos() + assert.Empty(t, behind) +} + +func TestService_BehindRepos_Good_EmptyStatus(t *testing.T) { + s := &Service{} + behind := s.BehindRepos() + assert.Empty(t, behind) +} + func TestService_Iterators_Good(t *testing.T) { s := &Service{ lastStatus: []RepoStatus{ @@ -119,6 +162,10 @@ func TestService_Iterators_Good(t *testing.T) { ahead := slices.Collect(s.Ahead()) assert.Len(t, ahead, 1) assert.Equal(t, "ahead", ahead[0].Name) + + // Test Behind() + behind := slices.Collect(s.Behind()) + assert.Len(t, behind, 0) } func TestService_Status_Good(t *testing.T) { @@ -150,6 +197,16 @@ func TestQueryStatus_MapsToStatusOptions(t *testing.T) { assert.Equal(t, q.Names, opts.Names) } +func TestQueryBehindRepos_TypeExists(t *testing.T) { + var q QueryBehindRepos + assert.IsType(t, QueryBehindRepos{}, q) +} + +func TestTaskPullMultiple_TypeExists(t *testing.T) { + var tpm TaskPullMultiple + assert.IsType(t, TaskPullMultiple{}, tpm) +} + func TestServiceOptions_WorkDir(t *testing.T) { opts := ServiceOptions{WorkDir: "/home/claude/repos"} assert.Equal(t, "/home/claude/repos", opts.WorkDir) @@ -184,3 +241,18 @@ func TestService_AheadRepos_Good_ExcludesErrors(t *testing.T) { assert.Len(t, ahead, 1) assert.Equal(t, "ahead-ok", ahead[0].Name) } + +// --- BehindRepos excludes errored repos --- + +func TestService_BehindRepos_Good_ExcludesErrors(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "behind-ok", Behind: 2}, + {Name: "behind-error", Behind: 3, Error: assert.AnError}, + }, + } + + behind := s.BehindRepos() + assert.Len(t, behind, 1) + assert.Equal(t, "behind-ok", behind[0].Name) +}