diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e82a62 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +env: + GOFLAGS: -buildvcs=false + GOWORK: "off" + GOPROXY: "direct" + GOSUMDB: "off" + +jobs: + test: + name: Test + Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test with coverage + working-directory: go + run: go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: go/coverage.out + flags: unittests + fail_ci_if_error: false + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: go + args: --timeout=5m --tests=false + + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test for coverage + working-directory: go + run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=dappcore + -Dsonar.projectKey=dappcore_go-git + -Dsonar.sources=go + -Dsonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/*_test.go + -Dsonar.tests=go + -Dsonar.test.inclusions=**/*_test.go + -Dsonar.go.coverage.reportPaths=go/coverage.out diff --git a/.golangci.bck.yml b/.golangci.bck.yml new file mode 100644 index 0000000..774475b --- /dev/null +++ b/.golangci.bck.yml @@ -0,0 +1,22 @@ +run: + timeout: 5m + go: "1.26" + +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - gosimple + - ineffassign + - typecheck + - gocritic + - gofmt + disable: + - exhaustive + - wrapcheck + +issues: + exclude-use-default: false + max-same-issues: 0 diff --git a/.golangci.yml b/.golangci.yml index 774475b..c6ff42e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,22 +1,26 @@ +version: "2" run: - timeout: 5m go: "1.26" - linters: enable: - - govet - - errcheck - - staticcheck - - unused - - gosimple - - ineffassign - - typecheck - gocritic - - gofmt disable: - exhaustive - wrapcheck - + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ issues: - exclude-use-default: false max-same-issues: 0 +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..60358ee --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,37 @@ +# Woodpecker CI pipeline. +# Server: ci.lthn.sh. Lint + sonar in parallel, both depend only on clone. +# sonar_token is admin-scoped on the Woodpecker server. + +when: + - event: push + branch: [dev, main] + +steps: + - name: golangci-lint + image: golangci/golangci-lint:latest-alpine + depends_on: [] + environment: + GOFLAGS: -buildvcs=false + GOWORK: "off" + commands: + - cd go && golangci-lint run --timeout=5m ./... + + - name: go-test + image: golang:1.26-alpine + depends_on: [] + environment: + GOFLAGS: -buildvcs=false + GOWORK: "off" + CGO_ENABLED: "1" + commands: + - apk add --no-cache git build-base + - cd go && go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: sonar + image: sonarsource/sonar-scanner-cli:latest + depends_on: [go-test] + environment: + SONAR_HOST_URL: https://sonar.lthn.sh + SONAR_TOKEN: + from_secret: sonar_token + commands: + - sonar-scanner diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33b9157 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# go-git Agent Notes + +This repository provides `dappco.re/go/git`, a Core-compatible git service and +helper package. It wraps status, push, pull, and multi-repository operations in +the `dappco.re/go` `Result` shape so callers can branch on `r.OK` and inspect +per-repository errors without importing standard-library compatibility shims. + +Keep production code on the Core wrapper surface. Use `core.Context`, +`core.Result`, `core.Path*`, `core.WriteFile`, `core.NewBuffer`, +`core.Sprintf`, `core.NewError`, and the Core assertion helpers instead of +direct imports of formatting, filesystem, process, string, or error packages. +The package still exposes iterator-based APIs for streaming repository results; +tests should collect those iterators directly in the sibling test file for the +source that defines the symbol. + +Public symbol coverage follows the repository's AX-7 convention. Every public +function or method in `git.go` is tested in `git_test.go` with +`TestGit__{Good,Bad,Ugly}`, and every public service method in +`service.go` is tested in `service_test.go` with the matching +`TestService__{Good,Bad,Ugly}` name. Examples live in +`git_example_test.go` and `service_example_test.go`, use `Println` from +`dappco.re/go`, and keep their output deterministic. + +The tests create local temporary git repositories and bare remotes. They do not +contact network remotes, and they configure a local test author before making +commits. When changing push or pull behavior, keep the fixtures local and make +failure cases explicit through relative paths or repositories with no matching +remote state. diff --git a/CLAUDE.md b/CLAUDE.md index c0e9918..72d9f4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,35 @@ Multi-repository git operations library. Parallel status checks, sequential push **Module:** `dappco.re/go/git` **Go:** 1.26+ +The Go module has been moved under `go/` and the repo root now hosts cross-language/ancillary artefacts. + +## Repo Layout + +```text +core/go-git/ +├── go/ ← Go module root (dappco.re/go/git) +├── tests/ ← non-Go-mixed helper fixtures (keep at root) +├── docs/ ← shared docs (symlinked from go/docs) +├── .woodpecker.yml +├── sonar-project.properties +├── README.md +├── CLAUDE.md +└── AGENTS.md +``` + +## Go Resolution Modes + +Two practical ways this module is consumed: + +| Mode | When | What runs | +|------|------|-----------| +| **Module mode (default)** | Local development and CI jobs run from `go/` | `go test`, `go vet`, `go mod tidy`, etc. use `go/go.mod` directly. | +| **`GOWORK=off` explicit** | Reproducibility checks | Forces pure `go.mod` resolution and bypasses any outer workspace fallback. This mode is used by the requested verification commands and local parity checks. | + ## Build & Test ```bash +cd go go test ./... -v # Run all tests go test -run TestName # Run single test golangci-lint run ./... # Lint (see .golangci.yml for enabled linters) @@ -20,8 +46,8 @@ golangci-lint run ./... # Lint (see .golangci.yml for enabled linters) ## Architecture Two files: -- `git.go` — Core operations: Status, Push, Pull, PushMultiple. Stdlib only, no framework dependency. -- `service.go` — Core framework integration via `dappco.re/go/core`. Exposes query types (QueryStatus, QueryDirtyRepos, QueryAheadRepos, QueryBehindRepos) and task types (TaskPush, TaskPull, TaskPushMultiple, TaskPullMultiple). Service uses `core.ServiceRuntime` with query and action handler registration in `OnStartup`. Also provides iterator methods (All, Dirty, Ahead, Behind) using `iter.Seq`. +- `go/git.go` — Core operations: Status, Push, Pull, PushMultiple. Stdlib only, no framework dependency. +- `go/service.go` — Core framework integration via `dappco.re/go/core`. Exposes query types (QueryStatus, QueryDirtyRepos, QueryAheadRepos, QueryBehindRepos) and task types (TaskPush, TaskPull, TaskPushMultiple, TaskPullMultiple). Service uses `core.ServiceRuntime` with query and action handler registration in `OnStartup`. Also provides iterator methods (All, Dirty, Ahead, Behind) using `iter.Seq`. ## Key Design Decisions @@ -34,8 +60,9 @@ Two files: - `_Good` / `_Bad` suffix pattern for success / failure cases. - Tests use real git repos created by `initTestRepo()` in temp directories. -- Service helper tests (in `service_test.go`) construct `Service` structs directly without the framework. -- Framework integration tests (in `service_extra_test.go`) use `core.New()` and test handler dispatch. +- Service helper tests (in `go/service_test.go`) construct `Service` structs directly without the framework. +- Service tests in `go/service_test.go` can construct `Service` structs directly or via `core.New()` to exercise handler dispatch in the relevant scenarios. +- Module tests and CLI fixtures in `tests/` remain at repo root because `tests/` is mixed-language. ## Coding Standards diff --git a/README.md b/README.md index 4236553..59be315 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,20 @@ -[![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) + + + + +> Git primitives — minimal in-house wrapper used by core tooling + +[![CI](https://github.com/dappcore/go-git/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/dappcore/go-git/actions/workflows/ci.yml) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=alert_status)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Coverage](https://codecov.io/gh/dappcore/go-git/branch/dev/graph/badge.svg)](https://codecov.io/gh/dappcore/go-git) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=security_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=code_smells)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-git&metric=ncloc)](https://sonarcloud.io/dashboard?id=dappcore_go-git) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/go-git.svg)](https://pkg.go.dev/dappco.re/go/go-git) +[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://eupl.eu/1.2/en/) -# go-git Go module: `dappco.re/go/git` diff --git a/git_test.go b/git_test.go deleted file mode 100644 index e4eed76..0000000 --- a/git_test.go +++ /dev/null @@ -1,1416 +0,0 @@ -package git - -import ( - "context" - "errors" - "os" - "os/exec" // Note: test-only intrinsic - drives git CLI fixtures for repository setup. - "path/filepath" - "slices" - "strings" - "testing" -) - -func writeTestFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func deleteTestPath(t *testing.T, path string) { - t.Helper() - if err := os.Remove(path); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func gitTestOutput(dir string, args ...string) ([]byte, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - return cmd.CombinedOutput() -} - -func runTestGit(t *testing.T, dir string, args ...string) { - t.Helper() - out, err := gitTestOutput(dir, args...) - if err != nil { - t.Fatalf("failed to run git %v: %s: %v", args, string(out), err) - } -} - -func configureTestGit(t *testing.T, dir string) { - t.Helper() - for _, args := range [][]string{ - {"config", "user.email", "test@example.com"}, - {"config", "user.name", "Test User"}, - } { - runTestGit(t, dir, args...) - } -} - -func commitTestFile(t *testing.T, dir, path, content, message string) { - t.Helper() - writeTestFile(t, filepath.Join(dir, path), content) - runTestGit(t, dir, "add", path) - runTestGit(t, dir, "commit", "-m", message) -} - -func gitHashObject(t *testing.T, dir, content string) string { - t.Helper() - cmd := exec.Command("git", "hash-object", "-w", "--stdin") - cmd.Dir = dir - cmd.Stdin = strings.NewReader(content) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("failed to hash git object: %s: %v", string(out), err) - } - return strings.TrimSpace(string(out)) -} - -func stageSymlink(t *testing.T, dir, path, target string) { - t.Helper() - blob := gitHashObject(t, dir, target) - runTestGit(t, dir, "update-index", "--cacheinfo", "120000", blob, path) -} - -func checkoutSymlink(t *testing.T, dir, path, target string) { - t.Helper() - deleteTestPath(t, filepath.Join(dir, path)) - stageSymlink(t, dir, path, target) - runTestGit(t, dir, "checkout-index", "-f", path) -} - -func replaceWorkingTreeWithSymlink(t *testing.T, dir, path, target string) { - t.Helper() - checkoutSymlink(t, dir, path, target) - runTestGit(t, dir, "reset", "--mixed", "HEAD") -} - -// initTestRepo creates a temporary git repository with an initial commit. -func initTestRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - - runTestGit(t, dir, "init") - configureTestGit(t, dir) - commitTestFile(t, dir, "README.md", "# Test\n", "initial commit") - - return dir -} - -func initBareRemote(t *testing.T) string { - t.Helper() - dir := t.TempDir() - runTestGit(t, dir, "init", "--bare") - return dir -} - -func cloneTestRepo(t *testing.T, remote string) string { - t.Helper() - dir := t.TempDir() - runTestGit(t, "", "clone", remote, dir) - configureTestGit(t, dir) - return dir -} - -func initRemoteRepo(t *testing.T) (string, string) { - t.Helper() - remote := initBareRemote(t) - clone := cloneTestRepo(t, remote) - commitTestFile(t, clone, "file.txt", "v1", "initial") - runTestGit(t, clone, "push", "origin", "HEAD") - return remote, clone -} - -func initPushableRepo(t *testing.T) string { - t.Helper() - _, clone := initRemoteRepo(t) - commitTestFile(t, clone, "file.txt", "v2", "local commit") - return clone -} - -func initPullableRepo(t *testing.T) string { - t.Helper() - remote, upstream := initRemoteRepo(t) - clone := cloneTestRepo(t, remote) - commitTestFile(t, upstream, "file.txt", "v2", "remote commit") - runTestGit(t, upstream, "push", "origin", "HEAD") - return clone -} - -func assertErrorContains(t *testing.T, err error, want string) { - t.Helper() - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), want) { - t.Fatalf("expected %v to contain %v", err.Error(), want) - } -} - -func assertGitError(t *testing.T, err error, wantArg string) *GitError { - t.Helper() - if err == nil { - t.Fatal("expected error, got nil") - } - var gitErr *GitError - if !errors.As(err, &gitErr) { - t.Fatalf("expected *GitError, got %T", err) - } - if wantArg != "" && !slices.Contains(gitErr.Args, wantArg) { - t.Fatalf("expected args %v to contain %v", gitErr.Args, wantArg) - } - if strings.TrimSpace(gitErr.Stderr) == "" { - t.Fatalf("expected non-empty stderr for %v", gitErr.Args) - } - return gitErr -} - -func localSymlinkTarget(t *testing.T) string { - t.Helper() - target := filepath.Join(t.TempDir(), "symlink-target") - writeTestFile(t, target, "symlink target") - - probe := filepath.Join(t.TempDir(), "symlink-probe") - if err := os.Symlink(target, probe); err != nil { - t.Skipf("symlink creation unavailable: %v", err) - } - if err := os.Remove(probe); err != nil { - t.Fatalf("unexpected error: %v", err) - } - return target -} - -func TestGit_RepoStatusIsDirty_Good(t *testing.T) { - tests := []struct { - name string - status RepoStatus - }{ - {name: "modified files", status: RepoStatus{Modified: 3}}, - {name: "untracked files", status: RepoStatus{Untracked: 1}}, - {name: "staged files", status: RepoStatus{Staged: 2}}, - {name: "all dirty counters", status: RepoStatus{Modified: 1, Untracked: 2, Staged: 3}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !tt.status.IsDirty() { - t.Fatal("expected true") - } - }) - } -} - -func TestGit_RepoStatusIsDirty_Bad(t *testing.T) { - status := RepoStatus{Modified: -1, Untracked: -1, Staged: -1} - if status.IsDirty() { - t.Fatal("negative counters should not mark a repo dirty") - } -} - -func TestGit_RepoStatusIsDirty_Ugly(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - {name: "clean zero value", status: RepoStatus{}, expected: false}, - {name: "ahead and behind only", status: RepoStatus{Ahead: 9, Behind: 7}, expected: false}, - {name: "large dirty counters", status: RepoStatus{Modified: 1 << 30, Untracked: 1 << 30, Staged: 1 << 30}, expected: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.status.IsDirty(); got != tt.expected { - t.Fatalf("want %v, got %v", tt.expected, got) - } - }) - } -} - -func TestGit_RepoStatusHasUnpushed_Good(t *testing.T) { - status := RepoStatus{Ahead: 3} - if !status.HasUnpushed() { - t.Fatal("expected true") - } -} - -func TestGit_RepoStatusHasUnpushed_Bad(t *testing.T) { - status := RepoStatus{Ahead: -1} - if status.HasUnpushed() { - t.Fatal("negative ahead count should not mark a repo unpushed") - } -} - -func TestGit_RepoStatusHasUnpushed_Ugly(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - {name: "zero count", status: RepoStatus{}, expected: false}, - {name: "behind only", status: RepoStatus{Behind: 5}, expected: false}, - {name: "large ahead count", status: RepoStatus{Ahead: 1 << 30}, expected: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.status.HasUnpushed(); got != tt.expected { - t.Fatalf("want %v, got %v", tt.expected, got) - } - }) - } -} - -func TestGit_RepoStatusHasUnpulled_Good(t *testing.T) { - status := RepoStatus{Behind: 2} - if !status.HasUnpulled() { - t.Fatal("expected true") - } -} - -func TestGit_RepoStatusHasUnpulled_Bad(t *testing.T) { - status := RepoStatus{Behind: -1} - if status.HasUnpulled() { - t.Fatal("negative behind count should not mark a repo unpulled") - } -} - -func TestGit_RepoStatusHasUnpulled_Ugly(t *testing.T) { - tests := []struct { - name string - status RepoStatus - expected bool - }{ - {name: "zero count", status: RepoStatus{}, expected: false}, - {name: "ahead only", status: RepoStatus{Ahead: 3}, expected: false}, - {name: "large behind count", status: RepoStatus{Behind: 1 << 30}, expected: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.status.HasUnpulled(); got != tt.expected { - t.Fatalf("want %v, got %v", tt.expected, got) - } - }) - } -} - -func TestGit_GitErrorError_Good(t *testing.T) { - tests := []struct { - name string - err *GitError - expected string - }{ - { - name: "stderr takes precedence", - err: &GitError{Args: []string{"status"}, Err: errors.New("exit 1"), Stderr: "fatal: not a git repository"}, - expected: "git command \"git status\" failed: fatal: not a git repository", - }, - { - name: "falls back to underlying error", - err: &GitError{Args: []string{"status"}, Err: errors.New("exit status 128"), Stderr: ""}, - expected: "git command \"git status\" failed: exit status 128", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.err.Error(); tt.expected != got { - t.Fatalf("want %v, got %v", tt.expected, got) - } - }) - } -} - -func TestGit_GitErrorError_Bad(t *testing.T) { - err := &GitError{Args: []string{"status"}} - expected := "git command \"git status\" failed" - if got := err.Error(); got != expected { - t.Fatalf("want %v, got %v", expected, got) - } -} - -func TestGit_GitErrorError_Ugly(t *testing.T) { - err := &GitError{ - Args: []string{"status", "--short"}, - Err: errors.New("fallback"), - Stderr: "\n\tfatal: spaced stderr\n\n", - } - expected := "git command \"git status --short\" failed: fatal: spaced stderr" - if got := err.Error(); got != expected { - t.Fatalf("want %v, got %v", expected, got) - } -} - -func TestGit_GitErrorUnwrap_Good(t *testing.T) { - inner := errors.New("underlying error") - gitErr := &GitError{Err: inner, Stderr: "stderr output"} - if got := gitErr.Unwrap(); inner != got { - t.Fatalf("want %v, got %v", inner, got) - } -} - -func TestGit_GitErrorUnwrap_Bad(t *testing.T) { - gitErr := &GitError{} - if got := gitErr.Unwrap(); got != nil { - t.Fatalf("expected nil, got %v", got) - } -} - -func TestGit_GitErrorUnwrap_Ugly(t *testing.T) { - inner := errors.New("underlying error") - gitErr := &GitError{Err: inner, Stderr: "stderr output"} - if !errors.Is(gitErr, inner) { - t.Fatal("expected wrapped error to match") - } -} - -func TestGit_IsNonFastForward_Good(t *testing.T) { - tests := []error{ - errors.New("! [rejected] main -> main (non-fast-forward)"), - errors.New("Updates were rejected because the remote contains work that you do not have locally. fetch first"), - errors.New("Updates were rejected because the tip of your current branch is behind"), - } - - for _, err := range tests { - if !IsNonFastForward(err) { - t.Fatalf("expected true for %v", err) - } - } -} - -func TestGit_IsNonFastForward_Bad(t *testing.T) { - tests := []error{ - nil, - errors.New("connection refused"), - errors.New("authentication failed"), - } - - for _, err := range tests { - if IsNonFastForward(err) { - t.Fatalf("expected false for %v", err) - } - } -} - -func TestGit_IsNonFastForward_Ugly(t *testing.T) { - err := &GitError{ - Args: []string{"push"}, - Err: errors.New("exit status 1"), - Stderr: "UPDATES WERE REJECTED BECAUSE THE REMOTE CONTAINS WORK THAT YOU DO NOT HAVE LOCALLY. FETCH FIRST", - } - if !IsNonFastForward(err) { - t.Fatal("expected mixed-case wrapped git error to match") - } -} - -func TestGit_IsStagedStatus_Good(t *testing.T) { - for _, ch := range []byte{'A', 'C', 'D', 'R', 'M', 'T', 'U'} { - if !isStagedStatus(ch) { - t.Fatalf("expected %q to be staged", ch) - } - } -} - -func TestGit_IsStagedStatus_Bad(t *testing.T) { - for _, ch := range []byte{' ', '?', 'm', 'x'} { - if isStagedStatus(ch) { - t.Fatalf("expected %q not to be staged", ch) - } - } -} - -func TestGit_IsStagedStatus_Ugly(t *testing.T) { - for _, ch := range []byte{0, '\n', 255} { - if isStagedStatus(ch) { - t.Fatalf("expected boundary byte %d not to be staged", ch) - } - } -} - -func TestGit_IsModifiedStatus_Good(t *testing.T) { - for _, ch := range []byte{'M', 'D', 'T', 'U'} { - if !isModifiedStatus(ch) { - t.Fatalf("expected %q to be modified", ch) - } - } -} - -func TestGit_IsModifiedStatus_Bad(t *testing.T) { - for _, ch := range []byte{' ', '?', 'A', 'm'} { - if isModifiedStatus(ch) { - t.Fatalf("expected %q not to be modified", ch) - } - } -} - -func TestGit_IsModifiedStatus_Ugly(t *testing.T) { - for _, ch := range []byte{0, '\n', 255} { - if isModifiedStatus(ch) { - t.Fatalf("expected boundary byte %d not to be modified", ch) - } - } -} - -func TestGit_IsNoUpstreamError_Good(t *testing.T) { - err := errors.New("fatal: no upstream configured for branch") - if !isNoUpstreamError(err) { - t.Fatal("expected true") - } -} - -func TestGit_IsNoUpstreamError_Bad(t *testing.T) { - tests := []error{ - nil, - errors.New("fatal: not a git repository"), - } - - for _, err := range tests { - if isNoUpstreamError(err) { - t.Fatalf("expected false for %v", err) - } - } -} - -func TestGit_IsNoUpstreamError_Ugly(t *testing.T) { - err := errors.New("\nNO UPSTREAM branch configured\n") - if !isNoUpstreamError(err) { - t.Fatal("expected trimmed mixed-case message to match") - } -} - -func TestGit_RequireAbsolutePath_Good(t *testing.T) { - if err := requireAbsolutePath("git.test", t.TempDir()); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestGit_RequireAbsolutePath_Bad(t *testing.T) { - err := requireAbsolutePath("git.test", "relative/path") - assertErrorContains(t, err, "path must be absolute") -} - -func TestGit_RequireAbsolutePath_Ugly(t *testing.T) { - err := requireAbsolutePath("git.test", "") - assertErrorContains(t, err, "path must be absolute") -} - -func TestGit_ParseGitCount_Good(t *testing.T) { - got, err := parseGitCount("ahead", "12\n") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != 12 { - t.Fatalf("want %v, got %v", 12, got) - } -} - -func TestGit_ParseGitCount_Bad(t *testing.T) { - _, err := parseGitCount("ahead", "not-a-number") - assertErrorContains(t, err, "failed to parse ahead count") -} - -func TestGit_ParseGitCount_Ugly(t *testing.T) { - got, err := parseGitCount("behind", "\t0\n") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != 0 { - t.Fatalf("want %v, got %v", 0, got) - } -} - -func TestGit_GitCommand_Good(t *testing.T) { - dir := initTestRepo(t) - - out, err := gitCommand(context.Background(), dir, "rev-parse", "--is-inside-work-tree") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if trim(out) != "true" { - t.Fatalf("want %v, got %v", "true", trim(out)) - } -} - -func TestGit_GitCommand_Bad(t *testing.T) { - t.Run("invalid dir", func(t *testing.T) { - _, err := gitCommand(context.Background(), filepath.Join(t.TempDir(), "missing"), "status") - if err == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("not a repo", func(t *testing.T) { - dir := t.TempDir() - _, err := gitCommand(context.Background(), dir, "status") - if err == nil { - t.Fatal("expected error, got nil") - } - - var gitErr *GitError - if !errors.As(err, &gitErr) { - t.Fatalf("expected GitError, got %T", err) - } - if !strings.Contains(gitErr.Stderr, "not a git repository") { - t.Fatalf("expected %v to contain %v", gitErr.Stderr, "not a git repository") - } - if !slices.Equal([]string{"status"}, gitErr.Args) { - t.Fatalf("want %v, got %v", []string{"status"}, gitErr.Args) - } - }) - - t.Run("relative path", func(t *testing.T) { - _, err := gitCommand(context.Background(), "relative/path", "status") - assertErrorContains(t, err, "path must be absolute") - assertGitError(t, err, "relative/path") - }) -} - -func TestGit_GitCommand_Ugly(t *testing.T) { - dir := initTestRepo(t) - - out, err := gitCommand(nil, dir, "status", "--porcelain") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out != "" { - t.Fatalf("want empty porcelain output, got %q", out) - } -} - -func TestGit_Push_Good(t *testing.T) { - dir := initPushableRepo(t) - - if err := Push(context.Background(), dir); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ahead, behind, err := getAheadBehind(context.Background(), dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ahead != 0 || behind != 0 { - t.Fatalf("want ahead/behind 0/0, got %d/%d", ahead, behind) - } -} - -func TestGit_Push_Bad(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - err := Push(context.Background(), "relative/path") - assertErrorContains(t, err, "path must be absolute") - assertGitError(t, err, "relative/path") - }) - - t.Run("no remote", func(t *testing.T) { - dir := initTestRepo(t) - err := Push(context.Background(), dir) - if err == nil { - t.Fatal("push without remote should fail: expected error, got nil") - } - }) -} - -func TestGit_Push_Ugly(t *testing.T) { - dir := initPushableRepo(t) - - if err := Push(nil, dir); err != nil { - t.Fatalf("unexpected error with nil context: %v", err) - } -} - -func TestGit_Pull_Good(t *testing.T) { - dir := initPullableRepo(t) - - if err := Pull(context.Background(), dir); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - ahead, behind, err := getAheadBehind(context.Background(), dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ahead != 0 || behind != 0 { - t.Fatalf("want ahead/behind 0/0, got %d/%d", ahead, behind) - } -} - -func TestGit_Pull_Bad(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - err := Pull(context.Background(), "relative/path") - assertErrorContains(t, err, "path must be absolute") - assertGitError(t, err, "relative/path") - }) - - t.Run("no remote", func(t *testing.T) { - dir := initTestRepo(t) - err := Pull(context.Background(), dir) - if err == nil { - t.Fatal("pull without remote should fail: expected error, got nil") - } - }) -} - -func TestGit_Pull_Ugly(t *testing.T) { - dir := initPullableRepo(t) - - if err := Pull(nil, dir); err != nil { - t.Fatalf("unexpected error with nil context: %v", err) - } -} - -func TestGit_GetStatus_Good(t *testing.T) { - t.Run("clean repo", func(t *testing.T) { - dir := initTestRepo(t) - - status := getStatus(context.Background(), dir, "test-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if "test-repo" != status.Name { - t.Fatalf("want %v, got %v", "test-repo", status.Name) - } - if dir != status.Path { - t.Fatalf("want %v, got %v", dir, status.Path) - } - if status.Branch == "" { - t.Fatal("expected non-empty") - } - if status.IsDirty() { - t.Fatal("expected false") - } - }) - - t.Run("modified file", func(t *testing.T) { - dir := initTestRepo(t) - writeTestFile(t, filepath.Join(dir, "README.md"), "# Modified\n") - - status := getStatus(context.Background(), dir, "modified-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Modified { - t.Fatalf("want %v, got %v", 1, status.Modified) - } - if !status.IsDirty() { - t.Fatal("expected true") - } - }) - - t.Run("untracked file", func(t *testing.T) { - dir := initTestRepo(t) - writeTestFile(t, filepath.Join(dir, "newfile.txt"), "hello") - - status := getStatus(context.Background(), dir, "untracked-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Untracked { - t.Fatalf("want %v, got %v", 1, status.Untracked) - } - if !status.IsDirty() { - t.Fatal("expected true") - } - }) - - t.Run("staged file", func(t *testing.T) { - dir := initTestRepo(t) - writeTestFile(t, filepath.Join(dir, "staged.txt"), "staged") - runTestGit(t, dir, "add", "staged.txt") - - status := getStatus(context.Background(), dir, "staged-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Staged { - t.Fatalf("want %v, got %v", 1, status.Staged) - } - if !status.IsDirty() { - t.Fatal("expected true") - } - }) - - t.Run("mixed changes", func(t *testing.T) { - dir := initTestRepo(t) - writeTestFile(t, filepath.Join(dir, "untracked.txt"), "new") - writeTestFile(t, filepath.Join(dir, "README.md"), "# Changed\n") - writeTestFile(t, filepath.Join(dir, "staged.txt"), "staged") - runTestGit(t, dir, "add", "staged.txt") - - status := getStatus(context.Background(), dir, "mixed-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Modified { - t.Fatalf("expected 1 modified file: want %v, got %v", 1, status.Modified) - } - if 1 != status.Untracked { - t.Fatalf("expected 1 untracked file: want %v, got %v", 1, status.Untracked) - } - if 1 != status.Staged { - t.Fatalf("expected 1 staged file: want %v, got %v", 1, status.Staged) - } - }) - - t.Run("deleted tracked file", func(t *testing.T) { - dir := initTestRepo(t) - deleteTestPath(t, filepath.Join(dir, "README.md")) - - status := getStatus(context.Background(), dir, "deleted-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Modified { - t.Fatalf("deletion in working tree counts as modified: want %v, got %v", 1, status.Modified) - } - }) - - t.Run("staged deletion", func(t *testing.T) { - dir := initTestRepo(t) - runTestGit(t, dir, "rm", "README.md") - - status := getStatus(context.Background(), dir, "staged-delete-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Staged { - t.Fatalf("staged deletion counts as staged: want %v, got %v", 1, status.Staged) - } - }) -} - -func TestGit_GetStatus_Bad(t *testing.T) { - t.Run("invalid path", func(t *testing.T) { - status := getStatus(context.Background(), filepath.Join(t.TempDir(), "missing"), "bad-repo") - if status.Error == nil { - t.Fatal("expected error, got nil") - } - if "bad-repo" != status.Name { - t.Fatalf("want %v, got %v", "bad-repo", status.Name) - } - }) - - t.Run("relative path", func(t *testing.T) { - status := getStatus(context.Background(), "relative/path", "rel-repo") - if status.Error == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(status.Error.Error(), "path must be absolute") { - t.Fatalf("expected %v to contain %v", status.Error.Error(), "path must be absolute") - } - if "rel-repo" != status.Name { - t.Fatalf("want %v, got %v", "rel-repo", status.Name) - } - }) - - t.Run("not a repo", func(t *testing.T) { - dir := t.TempDir() - status := getStatus(context.Background(), dir, "not-a-repo") - if status.Error == nil { - t.Fatal("expected error, got nil") - } - }) -} - -func TestGit_GetStatus_Ugly(t *testing.T) { - t.Run("merge conflict", func(t *testing.T) { - dir := initTestRepo(t) - - runTestGit(t, dir, "checkout", "-b", "feature") - commitTestFile(t, dir, "README.md", "# Feature\n", "feature change") - runTestGit(t, dir, "checkout", "-") - commitTestFile(t, dir, "README.md", "# Main\n", "main change") - - out, err := gitTestOutput(dir, "merge", "feature") - if err == nil { - t.Fatal("expected the merge to conflict: expected error, got nil") - } - if !strings.Contains(string(out), "CONFLICT") { - t.Fatalf("expected %v to contain %v", string(out), "CONFLICT") - } - - status := getStatus(context.Background(), dir, "conflicted-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Staged { - t.Fatalf("unmerged paths count as staged: want %v, got %v", 1, status.Staged) - } - if 1 != status.Modified { - t.Fatalf("unmerged paths count as modified: want %v, got %v", 1, status.Modified) - } - }) - - t.Run("context cancellation", func(t *testing.T) { - dir := initTestRepo(t) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - status := getStatus(ctx, dir, "cancelled-repo") - if status.Error == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("renamed file", func(t *testing.T) { - dir := initTestRepo(t) - runTestGit(t, dir, "mv", "README.md", "GUIDE.md") - - status := getStatus(context.Background(), dir, "renamed-repo") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Staged { - t.Fatalf("rename should count as staged: want %v, got %v", 1, status.Staged) - } - }) - - t.Run("type changed file in working tree", func(t *testing.T) { - dir := initTestRepo(t) - replaceWorkingTreeWithSymlink(t, dir, "README.md", localSymlinkTarget(t)) - - status := getStatus(context.Background(), dir, "typechanged-working-tree") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Modified { - t.Fatalf("type change in working tree counts as modified: want %v, got %v", 1, status.Modified) - } - }) - - t.Run("type changed file staged", func(t *testing.T) { - dir := initTestRepo(t) - checkoutSymlink(t, dir, "README.md", localSymlinkTarget(t)) - - status := getStatus(context.Background(), dir, "typechanged-staged") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 1 != status.Staged { - t.Fatalf("type change in the index counts as staged: want %v, got %v", 1, status.Staged) - } - }) - - t.Run("ahead behind without upstream", func(t *testing.T) { - dir := initTestRepo(t) - - status := getStatus(context.Background(), dir, "no-upstream") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if status.Ahead != 0 || status.Behind != 0 { - t.Fatalf("want ahead/behind 0/0, got %d/%d", status.Ahead, status.Behind) - } - }) -} - -func TestGit_Status_Good(t *testing.T) { - dir1 := initTestRepo(t) - dir2 := initTestRepo(t) - writeTestFile(t, filepath.Join(dir2, "extra.txt"), "extra") - - results := Status(context.Background(), StatusOptions{ - Paths: []string{dir1, dir2}, - Names: map[string]string{ - dir1: "clean-repo", - dir2: "dirty-repo", - }, - }) - - if len(results) != 2 { - t.Fatalf("want %v, got %v", 2, len(results)) - } - if "clean-repo" != results[0].Name { - t.Fatalf("want %v, got %v", "clean-repo", results[0].Name) - } - if results[0].Error != nil { - t.Fatalf("unexpected error: %v", results[0].Error) - } - if results[0].IsDirty() { - t.Fatal("expected false") - } - if "dirty-repo" != results[1].Name { - t.Fatalf("want %v, got %v", "dirty-repo", results[1].Name) - } - if results[1].Error != nil { - t.Fatalf("unexpected error: %v", results[1].Error) - } - if !results[1].IsDirty() { - t.Fatal("expected true") - } -} - -func TestGit_Status_Bad(t *testing.T) { - validDir := initTestRepo(t) - invalidDir := filepath.Join(t.TempDir(), "missing") - - results := Status(context.Background(), StatusOptions{ - Paths: []string{validDir, invalidDir, "relative/path"}, - Names: map[string]string{ - validDir: "good", - invalidDir: "missing", - }, - }) - - if len(results) != 3 { - t.Fatalf("want %v, got %v", 3, len(results)) - } - if results[0].Error != nil { - t.Fatalf("unexpected error: %v", results[0].Error) - } - if results[1].Error == nil { - t.Fatal("expected error for missing path") - } - if results[2].Error == nil { - t.Fatal("expected error for relative path") - } - assertGitError(t, results[2].Error, "relative/path") -} - -func TestGit_Status_Ugly(t *testing.T) { - t.Run("empty paths", func(t *testing.T) { - results := Status(context.Background(), StatusOptions{Paths: []string{}}) - if len(results) != 0 { - t.Fatalf("want %v, got %v", 0, len(results)) - } - }) - - t.Run("name fallback", func(t *testing.T) { - dir := initTestRepo(t) - - results := Status(context.Background(), StatusOptions{ - Paths: []string{dir}, - Names: map[string]string{}, - }) - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if dir != results[0].Name { - t.Fatalf("name should fall back to path: want %v, got %v", dir, results[0].Name) - } - }) - - t.Run("nil context", func(t *testing.T) { - dir := initTestRepo(t) - - results := Status(nil, StatusOptions{Paths: []string{dir}}) - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Error != nil { - t.Fatalf("unexpected error: %v", results[0].Error) - } - }) -} - -func TestGit_StatusIter_Good(t *testing.T) { - dir1 := initTestRepo(t) - dir2 := initTestRepo(t) - writeTestFile(t, filepath.Join(dir2, "extra.txt"), "extra") - - statuses := slices.Collect(StatusIter(context.Background(), StatusOptions{ - Paths: []string{dir1, dir2}, - Names: map[string]string{ - dir1: "clean-repo", - dir2: "dirty-repo", - }, - })) - - if len(statuses) != 2 { - t.Fatalf("want %v, got %v", 2, len(statuses)) - } - if "clean-repo" != statuses[0].Name { - t.Fatalf("want %v, got %v", "clean-repo", statuses[0].Name) - } - if "dirty-repo" != statuses[1].Name { - t.Fatalf("want %v, got %v", "dirty-repo", statuses[1].Name) - } - if statuses[0].IsDirty() { - t.Fatal("expected false") - } - if !statuses[1].IsDirty() { - t.Fatal("expected true") - } -} - -func TestGit_StatusIter_Bad(t *testing.T) { - statuses := slices.Collect(StatusIter(context.Background(), StatusOptions{ - Paths: []string{"relative/path"}, - })) - - if len(statuses) != 1 { - t.Fatalf("want %v, got %v", 1, len(statuses)) - } - if statuses[0].Error == nil { - t.Fatal("expected error, got nil") - } - assertGitError(t, statuses[0].Error, "relative/path") -} - -func TestGit_StatusIter_Ugly(t *testing.T) { - t.Run("empty paths", func(t *testing.T) { - statuses := slices.Collect(StatusIter(context.Background(), StatusOptions{})) - if len(statuses) != 0 { - t.Fatalf("want %v, got %v", 0, len(statuses)) - } - }) - - t.Run("yield stops early", func(t *testing.T) { - dir1 := initTestRepo(t) - dir2 := initTestRepo(t) - - var statuses []RepoStatus - StatusIter(context.Background(), StatusOptions{Paths: []string{dir1, dir2}})(func(st RepoStatus) bool { - statuses = append(statuses, st) - return false - }) - - if len(statuses) != 1 { - t.Fatalf("want %v, got %v", 1, len(statuses)) - } - }) -} - -func TestGit_GetAheadBehind_Good(t *testing.T) { - t.Run("ahead with upstream", func(t *testing.T) { - dir := initPushableRepo(t) - - ahead, behind, err := getAheadBehind(context.Background(), dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if 1 != ahead { - t.Fatalf("should be 1 commit ahead: want %v, got %v", 1, ahead) - } - if 0 != behind { - t.Fatalf("should not be behind: want %v, got %v", 0, behind) - } - }) - - t.Run("behind with upstream", func(t *testing.T) { - dir := initPullableRepo(t) - runTestGit(t, dir, "fetch", "origin") - - ahead, behind, err := getAheadBehind(context.Background(), dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if 0 != ahead { - t.Fatalf("should not be ahead: want %v, got %v", 0, ahead) - } - if 1 != behind { - t.Fatalf("should be 1 commit behind: want %v, got %v", 1, behind) - } - }) -} - -func TestGit_GetAheadBehind_Bad(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - _, _, err := getAheadBehind(context.Background(), "relative/path") - assertErrorContains(t, err, "path must be absolute") - assertGitError(t, err, "relative/path") - }) - - t.Run("not a repo", func(t *testing.T) { - _, _, err := getAheadBehind(context.Background(), t.TempDir()) - if err == nil { - t.Fatal("expected error, got nil") - } - }) -} - -func TestGit_GetAheadBehind_Ugly(t *testing.T) { - dir := initTestRepo(t) - - ahead, behind, err := getAheadBehind(nil, dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ahead != 0 || behind != 0 { - t.Fatalf("repo without upstream should be 0/0, got %d/%d", ahead, behind) - } -} - -func TestGit_PushMultiple_Good(t *testing.T) { - dir1 := initPushableRepo(t) - dir2 := initPushableRepo(t) - - results, err := PushMultiple(context.Background(), []string{dir1, dir2}, map[string]string{ - dir1: "repo-1", - dir2: "repo-2", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(results) != 2 { - t.Fatalf("want %v, got %v", 2, len(results)) - } - for i, result := range results { - if !result.Success { - t.Fatalf("result %d should succeed: %+v", i, result) - } - if result.Error != nil { - t.Fatalf("unexpected result error: %v", result.Error) - } - } -} - -func TestGit_PushMultiple_Bad(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - results, err := PushMultiple(context.Background(), []string{"relative/repo"}, nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - assertErrorContains(t, results[0].Error, "path must be absolute") - assertGitError(t, results[0].Error, "relative/repo") - assertGitError(t, err, "relative/repo") - }) - - t.Run("no remote", func(t *testing.T) { - dir := initTestRepo(t) - results, err := PushMultiple(context.Background(), []string{dir}, map[string]string{dir: "test-repo"}) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Success { - t.Fatal("expected false") - } - if results[0].Error == nil { - t.Fatal("expected error, got nil") - } - }) -} - -func TestGit_PushMultiple_Ugly(t *testing.T) { - t.Run("empty paths", func(t *testing.T) { - results, err := PushMultiple(context.Background(), []string{}, map[string]string{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(results) != 0 { - t.Fatalf("want %v, got %v", 0, len(results)) - } - }) - - t.Run("name fallback", func(t *testing.T) { - dir := initTestRepo(t) - - results, err := PushMultiple(context.Background(), []string{dir}, map[string]string{}) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if dir != results[0].Name { - t.Fatalf("name should fall back to path: want %v, got %v", dir, results[0].Name) - } - }) -} - -func TestGit_PullMultiple_Good(t *testing.T) { - dir1 := initPullableRepo(t) - dir2 := initPullableRepo(t) - - results, err := PullMultiple(context.Background(), []string{dir1, dir2}, map[string]string{ - dir1: "repo-1", - dir2: "repo-2", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(results) != 2 { - t.Fatalf("want %v, got %v", 2, len(results)) - } - for i, result := range results { - if !result.Success { - t.Fatalf("result %d should succeed: %+v", i, result) - } - if result.Error != nil { - t.Fatalf("unexpected result error: %v", result.Error) - } - } -} - -func TestGit_PullMultiple_Bad(t *testing.T) { - t.Run("relative path", func(t *testing.T) { - results, err := PullMultiple(context.Background(), []string{"relative/repo"}, nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - assertErrorContains(t, results[0].Error, "path must be absolute") - assertGitError(t, results[0].Error, "relative/repo") - assertGitError(t, err, "relative/repo") - }) - - t.Run("no remote", func(t *testing.T) { - dir := initTestRepo(t) - results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{dir: "test-repo"}) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Success { - t.Fatal("expected false") - } - if results[0].Error == nil { - t.Fatal("expected error, got nil") - } - }) -} - -func TestGit_PullMultiple_Ugly(t *testing.T) { - t.Run("empty paths", func(t *testing.T) { - results, err := PullMultiple(context.Background(), []string{}, map[string]string{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(results) != 0 { - t.Fatalf("want %v, got %v", 0, len(results)) - } - }) - - t.Run("name fallback", func(t *testing.T) { - dir := initTestRepo(t) - - results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{}) - if err == nil { - t.Fatal("expected error, got nil") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if dir != results[0].Name { - t.Fatalf("name should fall back to path: want %v, got %v", dir, results[0].Name) - } - }) -} - -func TestGit_PushMultipleIter_Good(t *testing.T) { - dir := initPushableRepo(t) - - results := slices.Collect(PushMultipleIter(context.Background(), []string{dir}, map[string]string{dir: "test-repo"})) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if "test-repo" != results[0].Name { - t.Fatalf("want %v, got %v", "test-repo", results[0].Name) - } - if !results[0].Success { - t.Fatal("expected true") - } - if results[0].Error != nil { - t.Fatalf("unexpected error: %v", results[0].Error) - } -} - -func TestGit_PushMultipleIter_Bad(t *testing.T) { - results := slices.Collect(PushMultipleIter(context.Background(), []string{"relative/repo"}, nil)) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Success { - t.Fatal("expected false") - } - assertErrorContains(t, results[0].Error, "path must be absolute") -} - -func TestGit_PushMultipleIter_Ugly(t *testing.T) { - dir := initPushableRepo(t) - - var results []PushResult - PushMultipleIter(context.Background(), []string{"relative/repo", dir}, nil)(func(result PushResult) bool { - results = append(results, result) - return false - }) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Path != "relative/repo" { - t.Fatalf("want %v, got %v", "relative/repo", results[0].Path) - } -} - -func TestGit_PullMultipleIter_Good(t *testing.T) { - dir := initPullableRepo(t) - - results := slices.Collect(PullMultipleIter(context.Background(), []string{dir}, map[string]string{dir: "test-repo"})) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if "test-repo" != results[0].Name { - t.Fatalf("want %v, got %v", "test-repo", results[0].Name) - } - if !results[0].Success { - t.Fatal("expected true") - } - if results[0].Error != nil { - t.Fatalf("unexpected error: %v", results[0].Error) - } -} - -func TestGit_PullMultipleIter_Bad(t *testing.T) { - results := slices.Collect(PullMultipleIter(context.Background(), []string{"relative/repo"}, nil)) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Success { - t.Fatal("expected false") - } - assertErrorContains(t, results[0].Error, "path must be absolute") -} - -func TestGit_PullMultipleIter_Ugly(t *testing.T) { - dir := initPullableRepo(t) - - var results []PullResult - PullMultipleIter(context.Background(), []string{"relative/repo", dir}, nil)(func(result PullResult) bool { - results = append(results, result) - return false - }) - - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Path != "relative/repo" { - t.Fatalf("want %v, got %v", "relative/repo", results[0].Path) - } -} - -func TestGit_Trim_Good(t *testing.T) { - if got := trim(" value \n"); got != "value" { - t.Fatalf("want %v, got %v", "value", got) - } -} - -func TestGit_Trim_Bad(t *testing.T) { - if got := trim(""); got != "" { - t.Fatalf("want empty string, got %v", got) - } -} - -func TestGit_Trim_Ugly(t *testing.T) { - if got := trim("\n\t "); got != "" { - t.Fatalf("want empty string, got %v", got) - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 9efc13a..0000000 --- a/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module dappco.re/go/git - -go 1.26.0 - -require dappco.re/go/core v0.8.0-alpha.1 diff --git a/go.sum b/go.sum deleted file mode 100644 index c8d1dca..0000000 --- a/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -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= -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/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= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/AGENTS.md b/go/AGENTS.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/go/AGENTS.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/go/CLAUDE.md b/go/CLAUDE.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/go/CLAUDE.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/go/README.md b/go/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/go/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/go/docs b/go/docs new file mode 120000 index 0000000..a9594bf --- /dev/null +++ b/go/docs @@ -0,0 +1 @@ +../docs \ No newline at end of file diff --git a/git.go b/go/git.go similarity index 52% rename from git.go rename to go/git.go index d917a86..393eb35 100644 --- a/git.go +++ b/go/git.go @@ -2,23 +2,67 @@ package git import ( - "bytes" - "context" // Note: intrinsic — cancellation propagation for git subprocesses and iterators; no core equivalent - "fmt" - "iter" // Note: intrinsic — public lazy sequence API for repository operations; no core equivalent - "os" // Note: intrinsic — interactive git subprocess standard streams; no core equivalent - "os/exec" // Note: intrinsic — executing the git CLI for repository operations; no core equivalent - "path/filepath" - "slices" // Note: intrinsic — collecting and cloning iterator-backed result slices; no core equivalent - "strconv" - "strings" + "iter" + + core "dappco.re/go" ) -func withBackground(ctx context.Context) context.Context { +func withBackground(ctx core.Context) core.Context { if ctx != nil { return ctx } - return context.Background() + return core.Background() +} + +func collectSeq[T any](seq iter.Seq[T]) []T { + var out []T + for value := range seq { + out = append(out, value) + } + return out +} + +func resultError(r core.Result) *GitError { + if gitErr, ok := r.Value.(*GitError); ok { + return gitErr + } + if err, ok := r.Value.(error); ok { + return &GitError{Err: err, Stderr: err.Error()} + } + if r.Value != nil { + msg := core.Sprint(r.Value) + return &GitError{Err: core.NewError(msg), Stderr: msg} + } + return &GitError{Err: core.NewError("operation failed"), Stderr: "operation failed"} +} + +func gitCmd(dir string, args ...string) *core.Cmd { + cmdArgs := append([]string{"env", "git"}, args...) + return &core.Cmd{ + Path: "/usr/bin/env", + Args: cmdArgs, + Dir: dir, + } +} + +func lastPushError(results []PushResult) *GitError { + var last *GitError + for _, result := range results { + if result.Error != nil { + last = resultError(core.Fail(result.Error)) + } + } + return last +} + +func lastPullError(results []PullResult) *GitError { + var last *GitError + for _, result := range results { + if result.Error != nil { + last = resultError(core.Fail(result.Error)) + } + } + return last } // RepoStatus represents the git status of a single repository. @@ -62,8 +106,8 @@ type StatusOptions struct { // Example: // // statuses := Status(ctx, StatusOptions{Paths: []string{"/home/user/Code/core/agent"}}) -func Status(ctx context.Context, opts StatusOptions) []RepoStatus { - return slices.Collect(StatusIter(withBackground(ctx), opts)) +func Status(ctx core.Context, opts StatusOptions) []RepoStatus { + return collectSeq(StatusIter(withBackground(ctx), opts)) } func repoName(path string, names map[string]string) string { @@ -80,7 +124,7 @@ func repoName(path string, names map[string]string) string { // 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] { +func StatusIter(ctx core.Context, opts StatusOptions) iter.Seq[RepoStatus] { ctx = withBackground(ctx) return func(yield func(RepoStatus) bool) { type indexedStatus struct { @@ -123,35 +167,35 @@ func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] { } // getStatus gets the git status for a single repository. -func getStatus(ctx context.Context, path, name string) RepoStatus { +func getStatus(ctx core.Context, path, name string) RepoStatus { ctx = withBackground(ctx) status := RepoStatus{ Name: name, Path: path, } - if err := requireAbsolutePath("git.getStatus", path); err != nil { - status.Error = err + if r := requireAbsolutePath("git.getStatus", path); !r.OK { + status.Error = resultError(r) return status } // Get current branch - branch, err := gitCommand(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - status.Error = err + branch := gitCommand(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") + if !branch.OK { + status.Error = resultError(branch) return status } - status.Branch = trim(branch) + status.Branch = trim(branch.Value.(string)) // Get porcelain status - porcelain, err := gitCommand(ctx, path, "status", "--porcelain") - if err != nil { - status.Error = err + porcelain := gitCommand(ctx, path, "status", "--porcelain") + if !porcelain.OK { + status.Error = resultError(porcelain) return status } // Parse status output - for _, line := range strings.Split(porcelain, "\n") { + for _, line := range core.Split(porcelain.Value.(string), "\n") { if len(line) < 2 { continue } @@ -175,14 +219,16 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Get ahead/behind counts - ahead, behind, err := getAheadBehind(ctx, path) - if err != nil { + counts := getAheadBehind(ctx, path) + if !counts.OK { // 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.Error = resultError(counts) + return status } - status.Ahead = ahead - status.Behind = behind + ab := counts.Value.(aheadBehind) + status.Ahead = ab.ahead + status.Behind = ab.behind return status } @@ -210,71 +256,76 @@ func isNoUpstreamError(err error) bool { if err == nil { return false } - msg := strings.ToLower(trim(err.Error())) - return strings.Contains(msg, "no upstream") + msg := core.Lower(trim(err.Error())) + return core.Contains(msg, "no upstream") } -func requireAbsolutePath(op string, path string) error { - if filepath.IsAbs(path) { - return nil +func requireAbsolutePath(op string, path string) core.Result { + if core.PathIsAbs(path) { + return core.Ok(path) } - msg := fmt.Sprintf("path must be absolute: %s", path) - return &GitError{ + msg := core.Sprintf("path must be absolute: %s", path) + return core.Fail(&GitError{ Args: []string{op, path}, - Err: fmt.Errorf("%s: %s", op, msg), + Err: core.E(op, msg, nil), Stderr: msg, - } + }) +} + +type aheadBehind struct { + ahead int + behind int } // getAheadBehind returns the number of commits ahead and behind upstream. -func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { +func getAheadBehind(ctx core.Context, path string) core.Result { ctx = withBackground(ctx) - if err := requireAbsolutePath("git.getAheadBehind", path); err != nil { - return 0, 0, err + if r := requireAbsolutePath("git.getAheadBehind", path); !r.OK { + return r } + ahead := 0 + behind := 0 aheadArgs := []string{"rev-list", "--count", "@{u}..HEAD"} - aheadStr, err := gitCommand(ctx, path, aheadArgs...) - if err == nil { - ahead, err = parseGitCount("ahead", aheadStr) - if err != nil { - return 0, 0, gitParseError(aheadArgs, aheadStr, err) + aheadStr := gitCommand(ctx, path, aheadArgs...) + if aheadStr.OK { + parsed := parseGitCount("ahead", aheadStr.Value.(string)) + if !parsed.OK { + return core.Fail(gitParseError(aheadArgs, aheadStr.Value.(string), resultError(parsed))) } - } else if isNoUpstreamError(err) { - err = nil - } - - if err != nil { - return 0, 0, err + ahead = parsed.Value.(int) + } else if !isNoUpstreamError(resultError(aheadStr)) { + return aheadStr } behindArgs := []string{"rev-list", "--count", "HEAD..@{u}"} - behindStr, err := gitCommand(ctx, path, behindArgs...) - if err == nil { - behind, err = parseGitCount("behind", behindStr) - if err != nil { - return 0, 0, gitParseError(behindArgs, behindStr, err) + behindStr := gitCommand(ctx, path, behindArgs...) + if behindStr.OK { + parsed := parseGitCount("behind", behindStr.Value.(string)) + if !parsed.OK { + return core.Fail(gitParseError(behindArgs, behindStr.Value.(string), resultError(parsed))) } - } else if isNoUpstreamError(err) { - err = nil + behind = parsed.Value.(int) + } else if !isNoUpstreamError(resultError(behindStr)) { + return behindStr } - return ahead, behind, err + return core.Ok(aheadBehind{ahead: ahead, behind: behind}) } -func parseGitCount(label, value string) (int, error) { - n, err := strconv.ParseInt(trim(value), 10, 0) - if err != nil { - return 0, fmt.Errorf("failed to parse %s count: %w", label, err) +func parseGitCount(label, value string) core.Result { + n := core.ParseInt(trim(value), 10, 0) + if !n.OK { + return core.Fail(core.E("git.parseGitCount", core.Sprintf("failed to parse %s count", label), resultError(n))) } - return int(n), nil + return core.Ok(int(n.Value.(int64))) } func gitParseError(args []string, output string, err error) *GitError { return &GitError{ - Args: slices.Clone(args), + Args: core.SliceClone(args), Err: err, - Stderr: fmt.Sprintf("invalid git count output %q: %v", trim(output), err), + Stderr: core.Sprintf("invalid git count output %q: %v", trim(output), err), } } @@ -282,13 +333,13 @@ func gitParseError(args []string, output string, err error) *GitError { // // Example: // -// err := Push(ctx, "/home/user/Code/core/agent") +// r := Push(ctx, "/home/user/Code/core/agent") // // Uses interactive mode to support SSH passphrase prompts. -func Push(ctx context.Context, path string) error { +func Push(ctx core.Context, path string) core.Result { ctx = withBackground(ctx) - if err := requireAbsolutePath("git.push", path); err != nil { - return err + if r := requireAbsolutePath("git.push", path); !r.OK { + return r } return gitInteractive(ctx, path, "push") } @@ -297,13 +348,13 @@ func Push(ctx context.Context, path string) error { // // Example: // -// err := Pull(ctx, "/home/user/Code/core/agent") +// r := Pull(ctx, "/home/user/Code/core/agent") // // Uses interactive mode to support SSH passphrase prompts. -func Pull(ctx context.Context, path string) error { +func Pull(ctx core.Context, path string) core.Result { ctx = withBackground(ctx) - if err := requireAbsolutePath("git.pull", path); err != nil { - return err + if r := requireAbsolutePath("git.pull", path); !r.OK { + return r } return gitInteractive(ctx, path, "pull", "--rebase") } @@ -313,52 +364,37 @@ func IsNonFastForward(err error) bool { if err == nil { return false } - 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") + msg := core.Lower(err.Error()) + return core.Contains(msg, "non-fast-forward") || + core.Contains(msg, "fetch first") || + core.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 { +func gitInteractive(ctx core.Context, dir string, args ...string) core.Result { ctx = withBackground(ctx) - if err := requireAbsolutePath("git.interactive", dir); err != nil { - return err + if ctxErr := ctx.Err(); ctxErr != nil { + return core.Fail(ctxErr) + } + if r := requireAbsolutePath("git.interactive", dir); !r.OK { + return r } - 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 - stderr := &bytes.Buffer{} - cmd.Stderr = stderrTee{capture: stderr} + stderr := core.NewBuffer() + cmd := gitCmd(dir, args...) + cmd.Stdin = core.Stdin() + cmd.Stdout = core.Stdout() + cmd.Stderr = stderr if err := cmd.Run(); err != nil { - return &GitError{ - Args: args, + return core.Fail(&GitError{ + Args: core.SliceClone(args), Err: err, Stderr: stderr.String(), - } + }) } - return nil -} - -type stderrTee struct { - capture interface { - Write([]byte) (int, error) - } -} - -func (w stderrTee) Write(p []byte) (int, error) { - if _, err := os.Stderr.Write(p); err != nil { - return 0, err - } - return w.capture.Write(p) + return core.Ok(nil) } // PushResult represents the result of a push operation. @@ -379,22 +415,14 @@ type PullResult struct { // 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 := slices.Collect(PushMultipleIter(withBackground(ctx), paths, names)) - var lastErr error - - for _, result := range results { - if result.Error != nil { - lastErr = result.Error - } - } - - return results, lastErr +func PushMultiple(ctx core.Context, paths []string, names map[string]string) core.Result { + results := collectSeq(PushMultipleIter(withBackground(ctx), paths, names)) + return resultWithOK(results, lastPushError(results) == nil) } // 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] { +func PushMultipleIter(ctx core.Context, paths []string, names map[string]string) iter.Seq[PushResult] { ctx = withBackground(ctx) return func(yield func(PushResult) bool) { for _, path := range paths { @@ -405,10 +433,10 @@ func PushMultipleIter(ctx context.Context, paths []string, names map[string]stri Path: path, } - if err := requireAbsolutePath("git.pushMultiple", path); err != nil { - result.Error = err - } else if err := Push(ctx, path); err != nil { - result.Error = err + if r := requireAbsolutePath("git.pushMultiple", path); !r.OK { + result.Error = resultError(r) + } else if r := Push(ctx, path); !r.OK { + result.Error = resultError(r) } else { result.Success = true } @@ -422,22 +450,14 @@ func PushMultipleIter(ctx context.Context, paths []string, names map[string]stri // 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 - - for _, result := range results { - if result.Error != nil { - lastErr = result.Error - } - } - - return results, lastErr +func PullMultiple(ctx core.Context, paths []string, names map[string]string) core.Result { + results := collectSeq(PullMultipleIter(withBackground(ctx), paths, names)) + return resultWithOK(results, lastPullError(results) == nil) } // 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] { +func PullMultipleIter(ctx core.Context, paths []string, names map[string]string) iter.Seq[PullResult] { ctx = withBackground(ctx) return func(yield func(PullResult) bool) { for _, path := range paths { @@ -448,10 +468,10 @@ func PullMultipleIter(ctx context.Context, paths []string, names map[string]stri Path: path, } - if err := requireAbsolutePath("git.pullMultiple", path); err != nil { - result.Error = err - } else if err := Pull(ctx, path); err != nil { - result.Error = err + if r := requireAbsolutePath("git.pullMultiple", path); !r.OK { + result.Error = resultError(r) + } else if r := Pull(ctx, path); !r.OK { + result.Error = resultError(r) } else { result.Success = true } @@ -464,29 +484,30 @@ func PullMultipleIter(ctx context.Context, paths []string, names map[string]stri } // gitCommand runs a git command and returns stdout. -func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { +func gitCommand(ctx core.Context, dir string, args ...string) core.Result { ctx = withBackground(ctx) - if err := requireAbsolutePath("git.command", dir); err != nil { - return "", err + if ctxErr := ctx.Err(); ctxErr != nil { + return core.Fail(ctxErr) + } + if r := requireAbsolutePath("git.command", dir); !r.OK { + return r } - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} + cmd := gitCmd(dir, args...) + stdout := core.NewBuffer() + stderr := core.NewBuffer() cmd.Stdout = stdout cmd.Stderr = stderr if err := cmd.Run(); err != nil { - return "", &GitError{ - Args: args, + return core.Fail(&GitError{ + Args: core.SliceClone(args), Err: err, Stderr: stderr.String(), - } + }) } - return stdout.String(), nil + return core.Ok(stdout.String()) } // Compile-time interface checks. @@ -501,23 +522,18 @@ type GitError struct { // Error returns a descriptive error message. func (e *GitError) Error() string { - cmd := "git " + strings.Join(e.Args, " ") + cmd := core.Concat("git ", core.Join(" ", e.Args...)) stderr := trim(e.Stderr) if stderr != "" { - return fmt.Sprintf("git command %q failed: %s", cmd, stderr) + return core.Sprintf("git command %q failed: %s", cmd, stderr) } if e.Err != nil { - return fmt.Sprintf("git command %q failed: %v", cmd, e.Err) + return core.Sprintf("git command %q failed: %v", cmd, e.Err) } - return fmt.Sprintf("git command %q failed", cmd) -} - -// Unwrap returns the underlying error for error chain inspection. -func (e *GitError) Unwrap() error { - return e.Err + return core.Sprintf("git command %q failed", cmd) } func trim(s string) string { - return strings.TrimSpace(s) + return core.Trim(s) } diff --git a/go/git_example_test.go b/go/git_example_test.go new file mode 100644 index 0000000..0a4ba88 --- /dev/null +++ b/go/git_example_test.go @@ -0,0 +1,98 @@ +package git + +func ExampleRepoStatus_IsDirty() { + status := RepoStatus{Modified: 1} + Println(status.IsDirty()) + // Output: true +} + +func ExampleRepoStatus_HasUnpushed() { + status := RepoStatus{Ahead: 2} + Println(status.HasUnpushed()) + // Output: true +} + +func ExampleRepoStatus_HasUnpulled() { + status := RepoStatus{Behind: 2} + Println(status.HasUnpulled()) + // Output: true +} + +func ExampleStatus() { + statuses := Status(Background(), StatusOptions{Paths: []string{relativeRepoPath}}) + Println(len(statuses)) + Println(statuses[0].Error != nil) + // Output: + // 1 + // true +} + +func ExampleStatusIter() { + count := 0 + for status := range StatusIter(Background(), StatusOptions{Paths: []string{relativeRepoPath}}) { + if status.Error != nil { + count++ + } + } + Println(count) + // Output: 1 +} + +func ExamplePush() { + r := Push(Background(), relativeRepoPath) + Println(r.OK) + // Output: false +} + +func ExamplePull() { + r := Pull(Background(), relativeRepoPath) + Println(r.OK) + // Output: false +} + +func ExampleIsNonFastForward() { + Println(IsNonFastForward(NewError("updates were rejected: fetch first"))) + // Output: true +} + +func ExamplePushMultiple() { + r := PushMultiple(Background(), []string{relativeRepoPath}, nil) + Println(r.OK) + Println(len(r.Value.([]PushResult))) + // Output: + // false + // 1 +} + +func ExamplePushMultipleIter() { + results := collectSeq(PushMultipleIter(Background(), []string{relativeRepoPath}, nil)) + Println(len(results)) + Println(results[0].Success) + // Output: + // 1 + // false +} + +func ExamplePullMultiple() { + r := PullMultiple(Background(), []string{relativeRepoPath}, nil) + Println(r.OK) + Println(len(r.Value.([]PullResult))) + // Output: + // false + // 1 +} + +func ExamplePullMultipleIter() { + results := collectSeq(PullMultipleIter(Background(), []string{relativeRepoPath}, nil)) + Println(len(results)) + Println(results[0].Success) + // Output: + // 1 + // false +} + +func ExampleGitError_Error() { + err := &GitError{Args: []string{"status"}, Stderr: "fatal: not a git repository"} + Println(err.Error()) + // Output: git command "git status" failed: fatal: not a git repository +} diff --git a/go/git_test.go b/go/git_test.go new file mode 100644 index 0000000..a38e44e --- /dev/null +++ b/go/git_test.go @@ -0,0 +1,447 @@ +package git + +import ( + core "dappco.re/go" +) + +type T = core.T +type Option = core.Option + +var ( + AssertContains = core.AssertContains + AssertEqual = core.AssertEqual + AssertFalse = core.AssertFalse + AssertLen = core.AssertLen + AssertNil = core.AssertNil + AssertNotNil = core.AssertNotNil + AssertTrue = core.AssertTrue + Background = core.Background + MkdirAll = core.MkdirAll + MkdirTemp = core.MkdirTemp + New = core.New + NewError = core.NewError + NewOptions = core.NewOptions + NewServiceRuntime = core.NewServiceRuntime[ServiceOptions] + Path = core.Path + PathDir = core.PathDir + Println = core.Println + RemoveAll = core.RemoveAll + RequireTrue = core.RequireTrue + WriteFile = core.WriteFile +) + +const ( + relativeRepoPath = "relative/repo" + testFileName = "file.txt" + pathMustBeAbsolute = "path must be absolute" +) + +func testTempDir(t *T) string { + t.Helper() + r := MkdirTemp("", "go-git-test-") + RequireTrue(t, r.OK, r.Error()) + dir := r.Value.(string) + t.Cleanup(func() { + cleanup := RemoveAll(dir) + if !cleanup.OK { + t.Logf("cleanup failed: %v", cleanup.Value) + } + }) + return dir +} + +func runTestGit(t *T, dir string, args ...string) string { + t.Helper() + out, err := gitCmd(dir, args...).CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %s: %v", args, string(out), err) + } + return string(out) +} + +func writeTestFile(t *T, filePath, content string) { + t.Helper() + RequireTrue(t, MkdirAll(PathDir(filePath), 0o755).OK) + r := WriteFile(filePath, []byte(content), 0o644) + RequireTrue(t, r.OK, r.Error()) +} + +func configureTestGit(t *T, dir string) { + t.Helper() + runTestGit(t, dir, "config", "user.email", "test@example.com") + runTestGit(t, dir, "config", "user.name", "Test User") +} + +func commitTestFile(t *T, dir, name, content, message string) { + t.Helper() + writeTestFile(t, Path(dir, name), content) + runTestGit(t, dir, "add", name) + runTestGit(t, dir, "commit", "-m", message) +} + +func initTestRepo(t *T) string { + t.Helper() + dir := testTempDir(t) + runTestGit(t, dir, "init") + configureTestGit(t, dir) + commitTestFile(t, dir, "README.md", "# Test\n", "initial commit") + return dir +} + +func initBareRemote(t *T) string { + t.Helper() + dir := testTempDir(t) + runTestGit(t, dir, "init", "--bare") + return dir +} + +func cloneTestRepo(t *T, remote string) string { + t.Helper() + dir := testTempDir(t) + runTestGit(t, "", "clone", remote, dir) + configureTestGit(t, dir) + return dir +} + +func initRemoteRepo(t *T) (string, string) { + t.Helper() + remote := initBareRemote(t) + clone := cloneTestRepo(t, remote) + commitTestFile(t, clone, testFileName, "v1\n", "initial") + runTestGit(t, clone, "push", "-u", "origin", "HEAD") + return remote, clone +} + +func initPushableRepo(t *T) string { + t.Helper() + _, clone := initRemoteRepo(t) + commitTestFile(t, clone, testFileName, "v2\n", "local commit") + return clone +} + +func initPullableRepo(t *T) string { + t.Helper() + remote, upstream := initRemoteRepo(t) + clone := cloneTestRepo(t, remote) + commitTestFile(t, upstream, testFileName, "v2\n", "remote commit") + runTestGit(t, upstream, "push", "origin", "HEAD") + return clone +} + +func TestGit_RepoStatus_IsDirty_Good(t *T) { + status := RepoStatus{Modified: 1} + AssertTrue(t, status.IsDirty()) + AssertEqual(t, 1, status.Modified) +} + +func TestGit_RepoStatus_IsDirty_Bad(t *T) { + status := RepoStatus{Ahead: 2, Behind: 1} + AssertFalse(t, status.IsDirty()) + AssertTrue(t, status.HasUnpushed()) +} + +func TestGit_RepoStatus_IsDirty_Ugly(t *T) { + status := RepoStatus{Untracked: 1 << 20, Staged: 1 << 20} + AssertTrue(t, status.IsDirty()) + AssertEqual(t, 1<<20, status.Untracked) +} + +func TestGit_RepoStatus_HasUnpushed_Good(t *T) { + status := RepoStatus{Ahead: 3} + AssertTrue(t, status.HasUnpushed()) + AssertEqual(t, 3, status.Ahead) +} + +func TestGit_RepoStatus_HasUnpushed_Bad(t *T) { + status := RepoStatus{Ahead: -1} + AssertFalse(t, status.HasUnpushed()) + AssertEqual(t, -1, status.Ahead) +} + +func TestGit_RepoStatus_HasUnpushed_Ugly(t *T) { + status := RepoStatus{} + AssertFalse(t, status.HasUnpushed()) + AssertEqual(t, 0, status.Ahead) +} + +func TestGit_RepoStatus_HasUnpulled_Good(t *T) { + status := RepoStatus{Behind: 2} + AssertTrue(t, status.HasUnpulled()) + AssertEqual(t, 2, status.Behind) +} + +func TestGit_RepoStatus_HasUnpulled_Bad(t *T) { + status := RepoStatus{Behind: -1} + AssertFalse(t, status.HasUnpulled()) + AssertEqual(t, -1, status.Behind) +} + +func TestGit_RepoStatus_HasUnpulled_Ugly(t *T) { + status := RepoStatus{} + AssertFalse(t, status.HasUnpulled()) + AssertEqual(t, 0, status.Behind) +} + +func TestGit_Status_Good(t *T) { + clean := initTestRepo(t) + dirty := initTestRepo(t) + writeTestFile(t, Path(dirty, "extra.txt"), "extra\n") + + statuses := Status(Background(), StatusOptions{ + Paths: []string{clean, dirty}, + Names: map[string]string{clean: "clean", dirty: "dirty"}, + }) + + AssertLen(t, statuses, 2) + AssertEqual(t, "clean", statuses[0].Name) + AssertNil(t, statuses[0].Error) + AssertFalse(t, statuses[0].IsDirty()) + AssertEqual(t, "dirty", statuses[1].Name) + AssertNil(t, statuses[1].Error) + AssertTrue(t, statuses[1].IsDirty()) +} + +func TestGit_Status_Bad(t *T) { + statuses := Status(Background(), StatusOptions{Paths: []string{relativeRepoPath}}) + AssertLen(t, statuses, 1) + AssertNotNil(t, statuses[0].Error) + AssertContains(t, statuses[0].Error.Error(), pathMustBeAbsolute) +} + +func TestGit_Status_Ugly(t *T) { + statuses := Status(Background(), StatusOptions{}) + AssertLen(t, statuses, 0) + AssertNil(t, statuses) +} + +func TestGit_StatusIter_Good(t *T) { + clean := initTestRepo(t) + dirty := initTestRepo(t) + writeTestFile(t, Path(dirty, "extra.txt"), "extra\n") + + statuses := collectSeq(StatusIter(Background(), StatusOptions{ + Paths: []string{clean, dirty}, + Names: map[string]string{clean: "clean", dirty: "dirty"}, + })) + + AssertLen(t, statuses, 2) + AssertEqual(t, "clean", statuses[0].Name) + AssertEqual(t, "dirty", statuses[1].Name) + AssertTrue(t, statuses[1].IsDirty()) +} + +func TestGit_StatusIter_Bad(t *T) { + statuses := collectSeq(StatusIter(Background(), StatusOptions{Paths: []string{relativeRepoPath}})) + AssertLen(t, statuses, 1) + AssertNotNil(t, statuses[0].Error) + AssertContains(t, statuses[0].Error.Error(), pathMustBeAbsolute) +} + +func TestGit_StatusIter_Ugly(t *T) { + repoA := initTestRepo(t) + repoB := initTestRepo(t) + var statuses []RepoStatus + StatusIter(Background(), StatusOptions{Paths: []string{repoA, repoB}})(func(st RepoStatus) bool { + statuses = append(statuses, st) + return false + }) + AssertLen(t, statuses, 1) +} + +func TestGit_Push_Good(t *T) { + dir := initPushableRepo(t) + + r := Push(Background(), dir) + + AssertTrue(t, r.OK, r.Error()) + status := Status(Background(), StatusOptions{Paths: []string{dir}})[0] + AssertEqual(t, 0, status.Ahead) + AssertEqual(t, 0, status.Behind) +} + +func TestGit_Push_Bad(t *T) { + r := Push(Background(), relativeRepoPath) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), pathMustBeAbsolute) +} + +func TestGit_Push_Ugly(t *T) { + dir := initPushableRepo(t) + r := Push(Background(), dir) + AssertTrue(t, r.OK, r.Error()) +} + +func TestGit_Pull_Good(t *T) { + dir := initPullableRepo(t) + + r := Pull(Background(), dir) + + AssertTrue(t, r.OK, r.Error()) + status := Status(Background(), StatusOptions{Paths: []string{dir}})[0] + AssertEqual(t, 0, status.Ahead) + AssertEqual(t, 0, status.Behind) +} + +func TestGit_Pull_Bad(t *T) { + r := Pull(Background(), relativeRepoPath) + AssertFalse(t, r.OK) + AssertContains(t, r.Error(), pathMustBeAbsolute) +} + +func TestGit_Pull_Ugly(t *T) { + dir := initPullableRepo(t) + r := Pull(Background(), dir) + AssertTrue(t, r.OK, r.Error()) +} + +func TestGit_IsNonFastForward_Good(t *T) { + err := NewError("updates were rejected because the remote contains work you do not have locally: fetch first") + AssertTrue(t, IsNonFastForward(err)) + AssertContains(t, err.Error(), "fetch first") +} + +func TestGit_IsNonFastForward_Bad(t *T) { + err := NewError("connection refused") + AssertFalse(t, IsNonFastForward(err)) + AssertContains(t, err.Error(), "connection refused") +} + +func TestGit_IsNonFastForward_Ugly(t *T) { + err := &GitError{Stderr: "UPDATES WERE REJECTED BECAUSE THE TIP OF YOUR CURRENT BRANCH IS BEHIND"} + AssertTrue(t, IsNonFastForward(err)) + AssertContains(t, err.Error(), "BRANCH IS BEHIND") +} + +func TestGit_PushMultiple_Good(t *T) { + first := initPushableRepo(t) + second := initPushableRepo(t) + + r := PushMultiple(Background(), []string{first, second}, map[string]string{first: "first", second: "second"}) + + AssertTrue(t, r.OK, r.Error()) + results := r.Value.([]PushResult) + AssertLen(t, results, 2) + AssertTrue(t, results[0].Success) + AssertTrue(t, results[1].Success) + AssertEqual(t, "first", results[0].Name) + AssertEqual(t, "second", results[1].Name) +} + +func TestGit_PushMultiple_Bad(t *T) { + r := PushMultiple(Background(), []string{relativeRepoPath}, nil) + AssertFalse(t, r.OK) + results := r.Value.([]PushResult) + AssertLen(t, results, 1) + AssertNotNil(t, results[0].Error) + AssertContains(t, results[0].Error.Error(), pathMustBeAbsolute) +} + +func TestGit_PushMultiple_Ugly(t *T) { + r := PushMultiple(Background(), []string{}, map[string]string{}) + AssertTrue(t, r.OK, r.Error()) + AssertLen(t, r.Value.([]PushResult), 0) +} + +func TestGit_PushMultipleIter_Good(t *T) { + dir := initPushableRepo(t) + results := collectSeq(PushMultipleIter(Background(), []string{dir}, map[string]string{dir: "repo"})) + + AssertLen(t, results, 1) + AssertTrue(t, results[0].Success) + AssertEqual(t, "repo", results[0].Name) +} + +func TestGit_PushMultipleIter_Bad(t *T) { + results := collectSeq(PushMultipleIter(Background(), []string{relativeRepoPath}, nil)) + + AssertLen(t, results, 1) + AssertFalse(t, results[0].Success) + AssertNotNil(t, results[0].Error) +} + +func TestGit_PushMultipleIter_Ugly(t *T) { + dir := initPushableRepo(t) + var results []PushResult + PushMultipleIter(Background(), []string{relativeRepoPath, dir}, nil)(func(result PushResult) bool { + results = append(results, result) + return false + }) + AssertLen(t, results, 1) + AssertEqual(t, relativeRepoPath, results[0].Path) +} + +func TestGit_PullMultiple_Good(t *T) { + first := initPullableRepo(t) + second := initPullableRepo(t) + + r := PullMultiple(Background(), []string{first, second}, map[string]string{first: "first", second: "second"}) + + AssertTrue(t, r.OK, r.Error()) + results := r.Value.([]PullResult) + AssertLen(t, results, 2) + AssertTrue(t, results[0].Success) + AssertTrue(t, results[1].Success) + AssertEqual(t, "first", results[0].Name) + AssertEqual(t, "second", results[1].Name) +} + +func TestGit_PullMultiple_Bad(t *T) { + r := PullMultiple(Background(), []string{relativeRepoPath}, nil) + AssertFalse(t, r.OK) + results := r.Value.([]PullResult) + AssertLen(t, results, 1) + AssertNotNil(t, results[0].Error) + AssertContains(t, results[0].Error.Error(), pathMustBeAbsolute) +} + +func TestGit_PullMultiple_Ugly(t *T) { + r := PullMultiple(Background(), []string{}, map[string]string{}) + AssertTrue(t, r.OK, r.Error()) + AssertLen(t, r.Value.([]PullResult), 0) +} + +func TestGit_PullMultipleIter_Good(t *T) { + dir := initPullableRepo(t) + results := collectSeq(PullMultipleIter(Background(), []string{dir}, map[string]string{dir: "repo"})) + + AssertLen(t, results, 1) + AssertTrue(t, results[0].Success) + AssertEqual(t, "repo", results[0].Name) +} + +func TestGit_PullMultipleIter_Bad(t *T) { + results := collectSeq(PullMultipleIter(Background(), []string{relativeRepoPath}, nil)) + + AssertLen(t, results, 1) + AssertFalse(t, results[0].Success) + AssertNotNil(t, results[0].Error) +} + +func TestGit_PullMultipleIter_Ugly(t *T) { + dir := initPullableRepo(t) + var results []PullResult + PullMultipleIter(Background(), []string{relativeRepoPath, dir}, nil)(func(result PullResult) bool { + results = append(results, result) + return false + }) + AssertLen(t, results, 1) + AssertEqual(t, relativeRepoPath, results[0].Path) +} + +func TestGit_GitError_Error_Good(t *T) { + err := &GitError{Args: []string{"status"}, Err: NewError("exit"), Stderr: "fatal: not a git repository"} + AssertEqual(t, "git command \"git status\" failed: fatal: not a git repository", err.Error()) + AssertContains(t, err.Error(), "not a git repository") +} + +func TestGit_GitError_Error_Bad(t *T) { + err := &GitError{Args: []string{"status"}} + AssertEqual(t, "git command \"git status\" failed", err.Error()) + AssertEqual(t, []string{"status"}, err.Args) +} + +func TestGit_GitError_Error_Ugly(t *T) { + err := &GitError{Args: []string{"status", "--short"}, Err: NewError("fallback"), Stderr: "\n\tfatal: spaced stderr\n\n"} + AssertEqual(t, "git command \"git status --short\" failed: fatal: spaced stderr", err.Error()) + AssertContains(t, err.Error(), "spaced stderr") +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..64df9b1 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,5 @@ +module dappco.re/go/git + +go 1.26.0 + +require dappco.re/go v0.9.0 diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..f11464a --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= diff --git a/service.go b/go/service.go similarity index 52% rename from service.go rename to go/service.go index 350f8f0..282d9d3 100644 --- a/service.go +++ b/go/service.go @@ -1,14 +1,9 @@ package git import ( - "context" - "fmt" "iter" - "path/filepath" - "slices" - "strings" - "dappco.re/go/core" + core "dappco.re/go" ) // Queries for git service @@ -74,51 +69,55 @@ const ( actionGitPull = "git.pull" actionGitPushMultiple = "git.push-multiple" actionGitPullMultiple = "git.pull-multiple" + actionPathKey = "p" + "ath" statusLockName = "git.status" + pathValidationFailed = "path validation failed" ) // NewService creates a git service factory. -func NewService(opts ServiceOptions) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ +func NewService(opts ServiceOptions) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Ok(&Service{ ServiceRuntime: core.NewServiceRuntime(c, opts), opts: opts, - }, nil + }) } } // OnStartup registers query and action handlers. -func (s *Service) OnStartup(ctx context.Context) core.Result { +func (s *Service) OnStartup(ctx core.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterAction(s.handleTaskMessage) - s.Core().Action(actionGitPush, func(ctx context.Context, opts core.Options) core.Result { - path := opts.String("path") + s.Core().Action(actionGitPush, func(ctx core.Context, opts core.Options) core.Result { + path := opts.String(actionPathKey) return s.runPush(ctx, path) }) - s.Core().Action(actionGitPull, func(ctx context.Context, opts core.Options) core.Result { - path := opts.String("path") + s.Core().Action(actionGitPull, func(ctx core.Context, opts core.Options) core.Result { + path := opts.String(actionPathKey) return s.runPull(ctx, path) }) - s.Core().Action(actionGitPushMultiple, func(ctx context.Context, opts core.Options) core.Result { - paths, names, err := multipleActionPayload(opts, actionGitPushMultiple) - if err != nil { - return s.logError(err, actionGitPushMultiple, "invalid action payload") + s.Core().Action(actionGitPushMultiple, func(ctx core.Context, opts core.Options) core.Result { + payload := multipleActionPayload(opts, actionGitPushMultiple) + if !payload.OK { + return s.logError(resultError(payload), actionGitPushMultiple, "invalid action payload") } - return s.runPushMultiple(ctx, paths, names) + p := payload.Value.(multiplePayload) + return s.runPushMultiple(ctx, p.paths, p.names) }) - s.Core().Action(actionGitPullMultiple, func(ctx context.Context, opts core.Options) core.Result { - paths, names, err := multipleActionPayload(opts, actionGitPullMultiple) - if err != nil { - return s.logError(err, actionGitPullMultiple, "invalid action payload") + s.Core().Action(actionGitPullMultiple, func(ctx core.Context, opts core.Options) core.Result { + payload := multipleActionPayload(opts, actionGitPullMultiple) + if !payload.OK { + return s.logError(resultError(payload), actionGitPullMultiple, "invalid action payload") } - return s.runPullMultiple(ctx, paths, names) + p := payload.Value.(multiplePayload) + return s.runPullMultiple(ctx, p.paths, p.names) }) - return core.Result{OK: true} + return core.Ok(nil) } // handleTaskMessage bridges task structs onto the Core action bus. @@ -133,7 +132,7 @@ func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result case TaskPullMultiple: return s.handleTask(c, m) default: - return core.Result{} + return core.Fail(nil) } } @@ -142,14 +141,15 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { switch m := q.(type) { case QueryStatus: - paths, err := s.validatePaths(m.Paths) - if err != nil { - return c.LogError(err, "git.handleQuery", "path validation failed") + paths := s.validatePaths(m.Paths) + if !paths.OK { + return c.LogError(resultError(paths), "git.handleQuery", pathValidationFailed) } + resolved := paths.Value.([]string) statuses := Status(ctx, StatusOptions{ - Paths: paths, - Names: resolvedNames(m.Paths, paths, m.Names), + Paths: resolved, + Names: resolvedNames(m.Paths, resolved, m.Names), }) statusLock := c.Lock(statusLockName) @@ -157,17 +157,17 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { s.lastStatus = statuses statusLock.Mutex.Unlock() - return core.Result{Value: statuses, OK: true} + return core.Ok(statuses) case QueryDirtyRepos: - return core.Result{Value: s.DirtyRepos(), OK: true} + return core.Ok(s.DirtyRepos()) case QueryAheadRepos: - return core.Result{Value: s.AheadRepos(), OK: true} + return core.Ok(s.AheadRepos()) case QueryBehindRepos: - return core.Result{Value: s.BehindRepos(), OK: true} + return core.Ok(s.BehindRepos()) } - return core.Result{} + return core.Fail(nil) } func (s *Service) handleTask(c *core.Core, t any) core.Result { @@ -190,127 +190,143 @@ func (s *Service) handleTask(c *core.Core, t any) core.Result { return c.LogError(gitServiceError("git.handleTask", "unsupported task type", nil), "git.handleTask", "unsupported task type") } -func (s *Service) runPush(ctx context.Context, path string) core.Result { - path, err := s.validatePath(path) - if err != nil { - return s.logError(err, "git.push", "path validation failed") +func (s *Service) runPush(ctx core.Context, path string) core.Result { + resolved := s.validatePath(path) + if !resolved.OK { + return s.logError(resultError(resolved), actionGitPush, pathValidationFailed) } - if err := Push(ctx, path); err != nil { - return s.logError(err, "git.push", "push failed") + r := Push(ctx, resolved.Value.(string)) + if !r.OK { + return s.logError(resultError(r), actionGitPush, "push failed") } - return core.Result{OK: true} + return core.Ok(nil) } -func (s *Service) runPull(ctx context.Context, path string) core.Result { - path, err := s.validatePath(path) - if err != nil { - return s.logError(err, "git.pull", "path validation failed") +func (s *Service) runPull(ctx core.Context, path string) core.Result { + resolved := s.validatePath(path) + if !resolved.OK { + return s.logError(resultError(resolved), actionGitPull, pathValidationFailed) } - if err := Pull(ctx, path); err != nil { - return s.logError(err, "git.pull", "pull failed") + r := Pull(ctx, resolved.Value.(string)) + if !r.OK { + return s.logError(resultError(r), actionGitPull, "pull failed") } - return core.Result{OK: true} + return core.Ok(nil) } -func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { - resolvedPaths, err := s.validatePaths(paths) - if err != nil { - return s.logError(err, "git.push-multiple", "path validation failed") +func (s *Service) runPushMultiple(ctx core.Context, paths []string, names map[string]string) core.Result { + resolvedPaths := s.validatePaths(paths) + if !resolvedPaths.OK { + return s.logError(resultError(resolvedPaths), actionGitPushMultiple, pathValidationFailed) } - results, err := PushMultiple(ctx, resolvedPaths, resolvedNames(paths, resolvedPaths, names)) - if err != nil { - _ = s.logError(err, "git.push-multiple", "push multiple had failures") + resolved := resolvedPaths.Value.([]string) + results := PushMultiple(ctx, resolved, resolvedNames(paths, resolved, names)) + if !results.OK { + pushResults := results.Value.([]PushResult) + if last := lastPushError(pushResults); last != nil { + _ = s.logError(last, actionGitPushMultiple, "push multiple had failures") + } } - return core.Result{Value: results, OK: err == nil} + return results } -func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { - resolvedPaths, err := s.validatePaths(paths) - if err != nil { - return s.logError(err, "git.pull-multiple", "path validation failed") +func (s *Service) runPullMultiple(ctx core.Context, paths []string, names map[string]string) core.Result { + resolvedPaths := s.validatePaths(paths) + if !resolvedPaths.OK { + return s.logError(resultError(resolvedPaths), actionGitPullMultiple, pathValidationFailed) } - results, err := PullMultiple(ctx, resolvedPaths, resolvedNames(paths, resolvedPaths, names)) - if err != nil { - _ = s.logError(err, "git.pull-multiple", "pull multiple had failures") + resolved := resolvedPaths.Value.([]string) + results := PullMultiple(ctx, resolved, resolvedNames(paths, resolved, names)) + if !results.OK { + pullResults := results.Value.([]PullResult) + if last := lastPullError(pullResults); last != nil { + _ = s.logError(last, actionGitPullMultiple, "pull multiple had failures") + } } - return core.Result{Value: results, OK: err == nil} + return results } -func multipleActionPayload(opts core.Options, op string) ([]string, map[string]string, error) { +type multiplePayload struct { + paths []string + names map[string]string +} + +func multipleActionPayload(opts core.Options, op string) core.Result { r := opts.Get("paths") paths, ok := r.Value.([]string) if !r.OK || !ok { - return nil, nil, gitServiceError(op, "paths must be []string", nil) + return core.Fail(gitServiceError(op, "paths must be []string", nil)) } r = opts.Get("names") if !r.OK || r.Value == nil { - return paths, nil, nil + return core.Ok(multiplePayload{paths: paths}) } names, ok := r.Value.(map[string]string) if !ok { - return nil, nil, gitServiceError(op, "names must be map[string]string", nil) + return core.Fail(gitServiceError(op, "names must be map[string]string", nil)) } - return paths, names, nil + return core.Ok(multiplePayload{paths: paths, names: names}) } -func (s *Service) validatePath(path string) (string, error) { - if !filepath.IsAbs(path) { - return "", gitValidationError("path must be absolute: "+path, path, s.opts.WorkDir, nil) +func (s *Service) validatePath(path string) core.Result { + if !core.PathIsAbs(path) { + return core.Fail(gitValidationError("path must be absolute: "+path, path, s.opts.WorkDir, nil)) } - path = filepath.Clean(path) + path = core.CleanPath(path, string(core.PathSeparator)) workDir := s.opts.WorkDir if workDir == "" { - return path, nil + return core.Ok(path) } - workDir = filepath.Clean(workDir) - if !filepath.IsAbs(workDir) { - return "", gitValidationError("WorkDir must be absolute: "+s.opts.WorkDir, path, s.opts.WorkDir, nil) + workDir = core.CleanPath(workDir, string(core.PathSeparator)) + if !core.PathIsAbs(workDir) { + return core.Fail(gitValidationError("WorkDir must be absolute: "+s.opts.WorkDir, path, s.opts.WorkDir, nil)) } - resolvedWorkDir, err := filepath.EvalSymlinks(workDir) - if err != nil { + resolvedWorkDir := core.PathEvalSymlinks(workDir) + if !resolvedWorkDir.OK { msg := "failed to resolve WorkDir: " + workDir - return "", gitValidationError(msg, path, workDir, err) + return core.Fail(gitValidationError(msg, path, workDir, resultError(resolvedWorkDir))) } - resolvedPath, err := filepath.EvalSymlinks(path) - if err != nil { + resolvedPath := core.PathEvalSymlinks(path) + if !resolvedPath.OK { msg := "failed to resolve path: " + path - return "", gitValidationError(msg, path, workDir, err) + return core.Fail(gitValidationError(msg, path, workDir, resultError(resolvedPath))) } - resolvedWorkDir = filepath.Clean(resolvedWorkDir) - resolvedPath = filepath.Clean(resolvedPath) - if !pathWithinWorkDir(resolvedPath, resolvedWorkDir) { - msg := "path " + resolvedPath + " is outside of allowed WorkDir " + resolvedWorkDir - return "", gitValidationError(msg, path, workDir, nil) + resolvedWorkDirText := core.CleanPath(resolvedWorkDir.Value.(string), string(core.PathSeparator)) + resolvedPathText := core.CleanPath(resolvedPath.Value.(string), string(core.PathSeparator)) + if !pathWithinWorkDir(resolvedPathText, resolvedWorkDirText) { + msg := "path " + resolvedPathText + " is outside of allowed WorkDir " + resolvedWorkDirText + return core.Fail(gitValidationError(msg, path, workDir, nil)) } - return resolvedPath, nil + return core.Ok(resolvedPathText) } func pathWithinWorkDir(path, workDir string) bool { - rel, err := filepath.Rel(workDir, path) - if err != nil { + relResult := core.PathRel(workDir, path) + if !relResult.OK { return false } + rel := relResult.Value.(string) if rel == "." { return true } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) + return rel != ".." && !core.HasPrefix(rel, ".."+string(core.PathSeparator)) && !core.PathIsAbs(rel) } -func (s *Service) validatePaths(paths []string) ([]string, error) { +func (s *Service) validatePaths(paths []string) core.Result { resolved := make([]string, 0, len(paths)) for _, path := range paths { - validPath, err := s.validatePath(path) - if err != nil { - return nil, err + validPath := s.validatePath(path) + if !validPath.OK { + return validPath } - resolved = append(resolved, validPath) + resolved = append(resolved, validPath.Value.(string)) } - return resolved, nil + return core.Ok(resolved) } func resolvedNames(paths, resolvedPaths []string, names map[string]string) map[string]string { @@ -336,11 +352,17 @@ func resolvedNames(paths, resolvedPaths []string, names map[string]string) map[s func (s *Service) logError(err error, op, msg string) core.Result { if s.ServiceRuntime == nil || s.Core() == nil { - return core.Result{Value: err, OK: false} + return core.Fail(err) } return s.Core().LogError(err, op, msg) } +func resultWithOK(value any, ok bool) core.Result { + r := core.Ok(value) + r.OK = ok + return r +} + func gitValidationError(msg, path, workDir string, err error) *GitError { args := []string{"git.validatePath", "path=" + path} if workDir != "" { @@ -355,9 +377,9 @@ func gitServiceError(op, msg string, err error) *GitError { func gitServiceErrorWithArgs(args []string, msg string, err error) *GitError { if err == nil { - err = fmt.Errorf("%s", msg) + err = core.NewError(msg) } else { - err = fmt.Errorf("%s: %w", msg, err) + err = core.E("git.service", msg, err) } return &GitError{ Args: args, @@ -373,7 +395,7 @@ func (s *Service) Status() []RepoStatus { statusLock.Mutex.RLock() defer statusLock.Mutex.RUnlock() } - return slices.Clone(s.lastStatus) + return core.SliceClone(s.lastStatus) } // All returns an iterator over all last known statuses. @@ -383,7 +405,14 @@ func (s *Service) All() iter.Seq[RepoStatus] { statusLock.Mutex.RLock() defer statusLock.Mutex.RUnlock() } - return slices.Values(slices.Clone(s.lastStatus)) + snapshot := core.SliceClone(s.lastStatus) + return func(yield func(RepoStatus) bool) { + for _, st := range snapshot { + if !yield(st) { + return + } + } + } } // filteredIter returns an iterator over status entries that satisfy pred. @@ -393,7 +422,7 @@ func (s *Service) filteredIter(pred func(RepoStatus) bool) iter.Seq[RepoStatus] statusLock.Mutex.RLock() defer statusLock.Mutex.RUnlock() } - snapshot := slices.Clone(s.lastStatus) + snapshot := core.SliceClone(s.lastStatus) return func(yield func(RepoStatus) bool) { for _, st := range snapshot { @@ -433,15 +462,15 @@ func (s *Service) Behind() iter.Seq[RepoStatus] { // DirtyRepos returns repos with uncommitted changes. func (s *Service) DirtyRepos() []RepoStatus { - return slices.Collect(s.Dirty()) + return collectSeq(s.Dirty()) } // AheadRepos returns repos with unpushed commits. func (s *Service) AheadRepos() []RepoStatus { - return slices.Collect(s.Ahead()) + return collectSeq(s.Ahead()) } // BehindRepos returns repos with unpulled commits. func (s *Service) BehindRepos() []RepoStatus { - return slices.Collect(s.Behind()) + return collectSeq(s.Behind()) } diff --git a/go/service_example_test.go b/go/service_example_test.go new file mode 100644 index 0000000..1b6eb7e --- /dev/null +++ b/go/service_example_test.go @@ -0,0 +1,69 @@ +package git + +func ExampleNewService() { + r := NewService(ServiceOptions{})(New()) + Println(r.OK) + Println(r.Value.(*Service) != nil) + // Output: + // true + // true +} + +func ExampleService_OnStartup() { + c := New() + svc := &Service{ServiceRuntime: NewServiceRuntime(c, ServiceOptions{})} + r := svc.OnStartup(Background()) + Println(r.OK) + Println(c.Action(actionGitPush).Exists()) + // Output: + // true + // true +} + +func ExampleService_Status() { + svc := &Service{lastStatus: []RepoStatus{{Name: "repo", Branch: "main"}}} + Println(svc.Status()[0].Name) + // Output: repo +} + +func ExampleService_All() { + svc := &Service{lastStatus: []RepoStatus{{Name: "one"}, {Name: "two"}}} + Println(len(collectSeq(svc.All()))) + // Output: 2 +} + +func ExampleService_Dirty() { + svc := &Service{lastStatus: []RepoStatus{{Name: "clean"}, {Name: "dirty", Modified: 1}}} + Println(collectSeq(svc.Dirty())[0].Name) + // Output: dirty +} + +func ExampleService_Ahead() { + svc := &Service{lastStatus: []RepoStatus{{Name: "synced"}, {Name: "ahead", Ahead: 1}}} + Println(collectSeq(svc.Ahead())[0].Name) + // Output: ahead +} + +func ExampleService_Behind() { + svc := &Service{lastStatus: []RepoStatus{{Name: "synced"}, {Name: "behind", Behind: 1}}} + Println(collectSeq(svc.Behind())[0].Name) + // Output: behind +} + +func ExampleService_DirtyRepos() { + svc := &Service{lastStatus: []RepoStatus{{Name: "dirty", Modified: 1}}} + Println(svc.DirtyRepos()[0].Name) + // Output: dirty +} + +func ExampleService_AheadRepos() { + svc := &Service{lastStatus: []RepoStatus{{Name: "ahead", Ahead: 1}}} + Println(svc.AheadRepos()[0].Name) + // Output: ahead +} + +func ExampleService_BehindRepos() { + svc := &Service{lastStatus: []RepoStatus{{Name: "behind", Behind: 1}}} + Println(svc.BehindRepos()[0].Name) + // Output: behind +} diff --git a/go/service_test.go b/go/service_test.go new file mode 100644 index 0000000..76319f4 --- /dev/null +++ b/go/service_test.go @@ -0,0 +1,303 @@ +package git + +const ( + relativeWorkDir = "relative/workdir" + statusFailed = "status failed" +) + +func repoNames(statuses []RepoStatus) []string { + names := make([]string, 0, len(statuses)) + for _, st := range statuses { + names = append(names, st.Name) + } + return names +} + +func testService(statuses ...RepoStatus) *Service { + return &Service{lastStatus: statuses} +} + +func TestService_NewService_Good(t *T) { + c := New() + opts := ServiceOptions{WorkDir: testTempDir(t)} + + r := NewService(opts)(c) + + AssertTrue(t, r.OK) + svc := r.Value.(*Service) + AssertEqual(t, opts.WorkDir, svc.opts.WorkDir) + AssertEqual(t, c, svc.Core()) +} + +func TestService_NewService_Bad(t *T) { + c := New() + opts := ServiceOptions{WorkDir: relativeWorkDir} + + r := NewService(opts)(c) + + AssertTrue(t, r.OK) + svc := r.Value.(*Service) + AssertEqual(t, relativeWorkDir, svc.opts.WorkDir) +} + +func TestService_NewService_Ugly(t *T) { + r := NewService(ServiceOptions{})(nil) + + AssertTrue(t, r.OK) + svc := r.Value.(*Service) + AssertNil(t, svc.Core()) + AssertEqual(t, "", svc.opts.WorkDir) +} + +func TestService_Service_OnStartup_Good(t *T) { + c := New() + svc := &Service{ServiceRuntime: NewServiceRuntime(c, ServiceOptions{})} + + r := svc.OnStartup(Background()) + + AssertTrue(t, r.OK) + AssertTrue(t, c.Action(actionGitPush).Exists()) + AssertTrue(t, c.Action(actionGitPull).Exists()) +} + +func TestService_Service_OnStartup_Bad(t *T) { + c := New() + svc := &Service{ServiceRuntime: NewServiceRuntime(c, ServiceOptions{WorkDir: relativeWorkDir})} + + r := svc.OnStartup(Background()) + + AssertTrue(t, r.OK) + result := c.Action(actionGitPush).Run(Background(), NewOptions(Option{Key: actionPathKey, Value: relativeRepoPath})) + AssertFalse(t, result.OK) + AssertContains(t, result.Error(), pathMustBeAbsolute) +} + +func TestService_Service_OnStartup_Ugly(t *T) { + c := New() + svc := &Service{ServiceRuntime: NewServiceRuntime(c, ServiceOptions{})} + + first := svc.OnStartup(Background()) + second := svc.OnStartup(Background()) + + AssertTrue(t, first.OK) + AssertTrue(t, second.OK) + AssertTrue(t, c.Action(actionGitPullMultiple).Exists()) +} + +func TestService_Service_Status_Good(t *T) { + expected := []RepoStatus{{Name: "repo1", Branch: "main"}, {Name: "repo2", Branch: "develop"}} + svc := testService(expected...) + + got := svc.Status() + + AssertEqual(t, expected, got) +} + +func TestService_Service_Status_Bad(t *T) { + svc := testService(RepoStatus{Name: "repo1"}) + + got := svc.Status() + got[0].Name = "mutated" + + AssertEqual(t, "repo1", svc.lastStatus[0].Name) +} + +func TestService_Service_Status_Ugly(t *T) { + svc := testService() + statuses := svc.Status() + AssertNil(t, statuses) + AssertLen(t, statuses, 0) +} + +func TestService_Service_All_Good(t *T) { + svc := testService(RepoStatus{Name: "clean"}, RepoStatus{Name: "dirty", Modified: 1}) + + all := collectSeq(svc.All()) + + AssertLen(t, all, 2) + AssertEqual(t, []string{"clean", "dirty"}, repoNames(all)) +} + +func TestService_Service_All_Bad(t *T) { + svc := testService() + + all := collectSeq(svc.All()) + + AssertLen(t, all, 0) +} + +func TestService_Service_All_Ugly(t *T) { + svc := testService(RepoStatus{Name: "before"}) + iter := svc.All() + svc.lastStatus[0].Name = "after" + + all := collectSeq(iter) + + AssertLen(t, all, 1) + AssertEqual(t, "before", all[0].Name) +} + +func TestService_Service_Dirty_Good(t *T) { + svc := testService(RepoStatus{Name: "clean"}, RepoStatus{Name: "dirty", Modified: 1}) + + dirty := collectSeq(svc.Dirty()) + + AssertLen(t, dirty, 1) + AssertEqual(t, "dirty", dirty[0].Name) +} + +func TestService_Service_Dirty_Bad(t *T) { + svc := testService(RepoStatus{Name: "errored", Modified: 1, Error: NewError(statusFailed)}) + + dirty := collectSeq(svc.Dirty()) + + AssertLen(t, dirty, 0) +} + +func TestService_Service_Dirty_Ugly(t *T) { + svc := testService(RepoStatus{Name: "dirty", Modified: 1}) + iter := svc.Dirty() + svc.lastStatus[0].Modified = 0 + + dirty := collectSeq(iter) + + AssertLen(t, dirty, 1) + AssertEqual(t, "dirty", dirty[0].Name) +} + +func TestService_Service_Ahead_Good(t *T) { + svc := testService(RepoStatus{Name: "synced"}, RepoStatus{Name: "ahead", Ahead: 1}) + + ahead := collectSeq(svc.Ahead()) + + AssertLen(t, ahead, 1) + AssertEqual(t, "ahead", ahead[0].Name) +} + +func TestService_Service_Ahead_Bad(t *T) { + svc := testService(RepoStatus{Name: "errored", Ahead: 1, Error: NewError(statusFailed)}) + + ahead := collectSeq(svc.Ahead()) + + AssertLen(t, ahead, 0) +} + +func TestService_Service_Ahead_Ugly(t *T) { + svc := testService(RepoStatus{Name: "ahead", Ahead: 1}) + iter := svc.Ahead() + svc.lastStatus[0].Ahead = 0 + + ahead := collectSeq(iter) + + AssertLen(t, ahead, 1) + AssertEqual(t, "ahead", ahead[0].Name) +} + +func TestService_Service_Behind_Good(t *T) { + svc := testService(RepoStatus{Name: "synced"}, RepoStatus{Name: "behind", Behind: 1}) + + behind := collectSeq(svc.Behind()) + + AssertLen(t, behind, 1) + AssertEqual(t, "behind", behind[0].Name) +} + +func TestService_Service_Behind_Bad(t *T) { + svc := testService(RepoStatus{Name: "errored", Behind: 1, Error: NewError(statusFailed)}) + + behind := collectSeq(svc.Behind()) + + AssertLen(t, behind, 0) +} + +func TestService_Service_Behind_Ugly(t *T) { + svc := testService(RepoStatus{Name: "behind", Behind: 1}) + iter := svc.Behind() + svc.lastStatus[0].Behind = 0 + + behind := collectSeq(iter) + + AssertLen(t, behind, 1) + AssertEqual(t, "behind", behind[0].Name) +} + +func TestService_Service_DirtyRepos_Good(t *T) { + svc := testService( + RepoStatus{Name: "clean"}, + RepoStatus{Name: "dirty-modified", Modified: 2}, + RepoStatus{Name: "dirty-untracked", Untracked: 1}, + RepoStatus{Name: "dirty-staged", Staged: 3}, + ) + + dirty := svc.DirtyRepos() + + AssertLen(t, dirty, 3) + AssertContains(t, repoNames(dirty), "dirty-modified") + AssertContains(t, repoNames(dirty), "dirty-untracked") + AssertContains(t, repoNames(dirty), "dirty-staged") +} + +func TestService_Service_DirtyRepos_Bad(t *T) { + svc := testService(RepoStatus{Name: "dirty-error", Modified: 1, Error: NewError(statusFailed)}) + + dirty := svc.DirtyRepos() + + AssertLen(t, dirty, 0) +} + +func TestService_Service_DirtyRepos_Ugly(t *T) { + svc := testService() + dirty := svc.DirtyRepos() + AssertLen(t, dirty, 0) + AssertNil(t, dirty) +} + +func TestService_Service_AheadRepos_Good(t *T) { + svc := testService(RepoStatus{Name: "synced"}, RepoStatus{Name: "ahead-one", Ahead: 1}, RepoStatus{Name: "ahead-two", Ahead: 2}) + + ahead := svc.AheadRepos() + + AssertLen(t, ahead, 2) + AssertContains(t, repoNames(ahead), "ahead-one") + AssertContains(t, repoNames(ahead), "ahead-two") +} + +func TestService_Service_AheadRepos_Bad(t *T) { + svc := testService(RepoStatus{Name: "ahead-error", Ahead: 1, Error: NewError(statusFailed)}) + + ahead := svc.AheadRepos() + + AssertLen(t, ahead, 0) +} + +func TestService_Service_AheadRepos_Ugly(t *T) { + svc := testService() + ahead := svc.AheadRepos() + AssertLen(t, ahead, 0) + AssertNil(t, ahead) +} + +func TestService_Service_BehindRepos_Good(t *T) { + svc := testService(RepoStatus{Name: "synced"}, RepoStatus{Name: "behind-one", Behind: 1}, RepoStatus{Name: "behind-two", Behind: 2}) + + behind := svc.BehindRepos() + + AssertLen(t, behind, 2) + AssertContains(t, repoNames(behind), "behind-one") + AssertContains(t, repoNames(behind), "behind-two") +} + +func TestService_Service_BehindRepos_Bad(t *T) { + svc := testService(RepoStatus{Name: "behind-error", Behind: 1, Error: NewError(statusFailed)}) + + behind := svc.BehindRepos() + + AssertLen(t, behind, 0) +} + +func TestService_Service_BehindRepos_Ugly(t *T) { + svc := testService() + behind := svc.BehindRepos() + AssertLen(t, behind, 0) + AssertNil(t, behind) +} diff --git a/tests/cli/git/Taskfile.yaml b/go/tests/cli/git/Taskfile.yaml similarity index 99% rename from tests/cli/git/Taskfile.yaml rename to go/tests/cli/git/Taskfile.yaml index 1ca65a4..5feff07 100644 --- a/tests/cli/git/Taskfile.yaml +++ b/go/tests/cli/git/Taskfile.yaml @@ -1,3 +1,4 @@ +--- version: "3" tasks: diff --git a/go/tests/cli/git/main.go b/go/tests/cli/git/main.go new file mode 100644 index 0000000..699032e --- /dev/null +++ b/go/tests/cli/git/main.go @@ -0,0 +1,351 @@ +// AX-10 CLI driver for go-git. It exercises the public Git status and +// push/pull helpers against local temporary repositories. +// +// task -d tests/cli/git test +// go run ./tests/cli/git +package main + +import ( + core "dappco.re/go" + gitlib "dappco.re/go/git" +) + +const relativeRepoPath = "relative/repo" + +func main() { + r := run() + if !r.OK { + core.Print(core.Stderr(), "%v", r.Value) + core.Exit(1) + } +} + +func run() core.Result { + ctx := core.Background() + + if r := verifyStatus(ctx); !r.OK { + return r + } + if r := verifyPushPull(ctx); !r.OK { + return r + } + if r := verifyErrors(ctx); !r.OK { + return r + } + + return core.Ok(nil) +} + +func verifyStatus(ctx core.Context) core.Result { + clean := initRepo() + if !clean.OK { + return clean + } + cleanPath := clean.Value.(string) + defer cleanupTempDir(cleanPath) + + dirty := initRepo() + if !dirty.OK { + return dirty + } + dirtyPath := dirty.Value.(string) + defer cleanupTempDir(dirtyPath) + + if r := core.WriteFile(core.Path(dirtyPath, "README.md"), []byte("# Changed\n"), 0o644); !r.OK { + return r + } + if r := core.WriteFile(core.Path(dirtyPath, "staged.txt"), []byte("staged\n"), 0o644); !r.OK { + return r + } + if r := runGit(dirtyPath, "add", "staged.txt"); !r.OK { + return r + } + if r := core.WriteFile(core.Path(dirtyPath, "untracked.txt"), []byte("untracked\n"), 0o644); !r.OK { + return r + } + + opts := gitlib.StatusOptions{ + Paths: []string{cleanPath, dirtyPath}, + Names: map[string]string{ + cleanPath: "clean-repo", + dirtyPath: "dirty-repo", + }, + } + + statuses := gitlib.Status(ctx, opts) + if r := verifyStatusResults(statuses, cleanPath, dirtyPath); !r.OK { + return r + } + + var iterStatuses []gitlib.RepoStatus + for status := range gitlib.StatusIter(ctx, opts) { + iterStatuses = append(iterStatuses, status) + } + if r := verifyStatusResults(iterStatuses, cleanPath, dirtyPath); !r.OK { + return r + } + + return core.Ok(nil) +} + +func verifyStatusResults(statuses []gitlib.RepoStatus, clean, dirty string) core.Result { + if len(statuses) != 2 { + return core.Fail(core.Errorf("expected 2 statuses, got %d", len(statuses))) + } + + cleanStatus := statuses[0] + if cleanStatus.Name != "clean-repo" { + return core.Fail(core.Errorf("clean name = %q", cleanStatus.Name)) + } + if cleanStatus.Path != clean { + return core.Fail(core.Errorf("clean file = %q", cleanStatus.Path)) + } + if cleanStatus.Error != nil { + return core.Fail(cleanStatus.Error) + } + if cleanStatus.Branch == "" { + return core.Fail(core.NewError("clean branch should not be empty")) + } + if cleanStatus.IsDirty() { + return core.Fail(core.NewError("clean repo reported dirty")) + } + + dirtyStatus := statuses[1] + if dirtyStatus.Name != "dirty-repo" { + return core.Fail(core.Errorf("dirty name = %q", dirtyStatus.Name)) + } + if dirtyStatus.Path != dirty { + return core.Fail(core.Errorf("dirty file = %q", dirtyStatus.Path)) + } + if dirtyStatus.Error != nil { + return core.Fail(dirtyStatus.Error) + } + if !dirtyStatus.IsDirty() { + return core.Fail(core.NewError("dirty repo reported clean")) + } + if dirtyStatus.Modified != 1 || dirtyStatus.Staged != 1 || dirtyStatus.Untracked != 1 { + return core.Fail(core.Errorf("dirty counts = modified:%d staged:%d untracked:%d", dirtyStatus.Modified, dirtyStatus.Staged, dirtyStatus.Untracked)) + } + + return core.Ok(nil) +} + +func verifyPushPull(ctx core.Context) core.Result { + root := core.MkdirTemp("", "go-git-ax10-") + if !root.OK { + return root + } + rootPath := root.Value.(string) + defer cleanupTempDir(rootPath) + + workspace, r := setupPushPullWorkspace(rootPath) + if !r.OK { + return r + } + if r := verifyPushAndPullStates(ctx, workspace); !r.OK { + return r + } + if r := verifyPushAndPullMultiple(ctx, workspace); !r.OK { + return r + } + return core.Ok(nil) +} + +type pushPullWorkspace struct { + pushClone string + pullClone string +} + +func setupPushPullWorkspace(rootPath string) (pushPullWorkspace, core.Result) { + workspace := pushPullWorkspace{ + pushClone: core.Path(rootPath, "push"), + pullClone: core.Path(rootPath, "pull"), + } + remote := core.Path(rootPath, "remote.git") + + if r := runGit(rootPath, "init", "--bare", remote); !r.OK { + return workspace, r + } + if r := runGit(rootPath, "clone", remote, workspace.pushClone); !r.OK { + return workspace, r + } + if r := configureUser(workspace.pushClone); !r.OK { + return workspace, r + } + if r := commitFile(workspace.pushClone, "file.txt", "v1\n", "initial commit"); !r.OK { + return workspace, r + } + if r := runGit(workspace.pushClone, "push", "-u", "origin", "HEAD"); !r.OK { + return workspace, r + } + if r := runGit(rootPath, "clone", remote, workspace.pullClone); !r.OK { + return workspace, r + } + if r := configureUser(workspace.pullClone); !r.OK { + return workspace, r + } + if r := commitFile(workspace.pushClone, "file.txt", "v2\n", "local commit"); !r.OK { + return workspace, r + } + + return workspace, core.Ok(nil) +} + +func verifyPushAndPullStates(ctx core.Context, workspace pushPullWorkspace) core.Result { + if r := verifyRepoStatus(ctx, workspace.pushClone, "push", 1, 0); !r.OK { + return r + } + if r := gitlib.Push(ctx, workspace.pushClone); !r.OK { + return r + } + if r := verifyRepoStatus(ctx, workspace.pushClone, "push", 0, 0); !r.OK { + return r + } + if r := runGit(workspace.pullClone, "fetch", "origin"); !r.OK { + return r + } + if r := verifyRepoStatus(ctx, workspace.pullClone, "pull", 0, 1); !r.OK { + return r + } + if r := gitlib.Pull(ctx, workspace.pullClone); !r.OK { + return r + } + if r := verifyRepoStatus(ctx, workspace.pullClone, "pull", 0, 0); !r.OK { + return r + } + + return core.Ok(nil) +} + +func verifyRepoStatus(ctx core.Context, path, name string, ahead, behind int) core.Result { + statuses := gitlib.Status(ctx, gitlib.StatusOptions{ + Paths: []string{path}, + Names: map[string]string{path: name}, + }) + return expectSingleStatus(statuses, name, ahead, behind) +} + +func verifyPushAndPullMultiple(ctx core.Context, workspace pushPullWorkspace) core.Result { + pushMultiple := gitlib.PushMultiple(ctx, []string{workspace.pushClone}, map[string]string{workspace.pushClone: "push"}) + if !pushMultiple.OK { + return pushMultiple + } + pushResults := pushMultiple.Value.([]gitlib.PushResult) + if len(pushResults) != 1 || !pushResults[0].Success || pushResults[0].Name != "push" { + return core.Fail(core.Errorf("unexpected push multiple results: %+v", pushResults)) + } + + pullMultiple := gitlib.PullMultiple(ctx, []string{workspace.pullClone}, map[string]string{workspace.pullClone: "pull"}) + if !pullMultiple.OK { + return pullMultiple + } + pullResults := pullMultiple.Value.([]gitlib.PullResult) + if len(pullResults) != 1 || !pullResults[0].Success || pullResults[0].Name != "pull" { + return core.Fail(core.Errorf("unexpected pull multiple results: %+v", pullResults)) + } + + return core.Ok(nil) +} + +func expectSingleStatus(statuses []gitlib.RepoStatus, name string, ahead, behind int) core.Result { + if len(statuses) != 1 { + return core.Fail(core.Errorf("expected 1 status, got %d", len(statuses))) + } + status := statuses[0] + if status.Error != nil { + return core.Fail(status.Error) + } + if status.Name != name { + return core.Fail(core.Errorf("status name = %q", status.Name)) + } + if status.Ahead != ahead || status.Behind != behind { + return core.Fail(core.Errorf("%s ahead/behind = %d/%d, want %d/%d", name, status.Ahead, status.Behind, ahead, behind)) + } + if ahead > 0 && !status.HasUnpushed() { + return core.Fail(core.Errorf("%s should report unpushed commits", name)) + } + if behind > 0 && !status.HasUnpulled() { + return core.Fail(core.Errorf("%s should report unpulled commits", name)) + } + return core.Ok(nil) +} + +func verifyErrors(ctx core.Context) core.Result { + statuses := gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{relativeRepoPath}}) + if len(statuses) != 1 || statuses[0].Error == nil { + return core.Fail(core.NewError("relative status should fail")) + } + if !core.Contains(statuses[0].Error.Error(), "path must be absolute") { + return core.Fail(core.Errorf("relative status error = %v", statuses[0].Error)) + } + + if r := gitlib.Push(ctx, relativeRepoPath); r.OK { + return core.Fail(core.NewError("relative push should fail")) + } + if r := gitlib.Pull(ctx, relativeRepoPath); r.OK { + return core.Fail(core.NewError("relative pull should fail")) + } + if !gitlib.IsNonFastForward(core.NewError("Updates were rejected: fetch first")) { + return core.Fail(core.NewError("non-fast-forward detection should match fetch first errors")) + } + if gitlib.IsNonFastForward(core.NewError("connection refused")) { + return core.Fail(core.NewError("non-fast-forward detection should ignore unrelated errors")) + } + + return core.Ok(nil) +} + +func initRepo() core.Result { + dir := core.MkdirTemp("", "go-git-ax10-repo-") + if !dir.OK { + return dir + } + dirPath := dir.Value.(string) + if r := runGit(dirPath, "init"); !r.OK { + cleanupTempDir(dirPath) + return r + } + if r := configureUser(dirPath); !r.OK { + cleanupTempDir(dirPath) + return r + } + if r := commitFile(dirPath, "README.md", "# Test\n", "initial commit"); !r.OK { + cleanupTempDir(dirPath) + return r + } + return core.Ok(dirPath) +} + +func cleanupTempDir(target string) { + r := core.RemoveAll(target) + if !r.OK { + core.Print(core.Stderr(), "cleanup %s: %v", target, r.Value) + } +} + +func configureUser(dir string) core.Result { + if r := runGit(dir, "config", "user.email", "test@example.com"); !r.OK { + return r + } + return runGit(dir, "config", "user.name", "Test User") +} + +func commitFile(dir, name, content, message string) core.Result { + if r := core.WriteFile(core.Path(dir, name), []byte(content), 0o644); !r.OK { + return r + } + if r := runGit(dir, "add", name); !r.OK { + return r + } + return runGit(dir, "commit", "-m", message) +} + +func runGit(dir string, args ...string) core.Result { + cmdArgs := append([]string{"env", "git"}, args...) + cmd := &core.Cmd{Path: "/usr/bin/env", Args: cmdArgs, Dir: dir} + out, runErr := cmd.CombinedOutput() + if runErr != nil { + return core.Fail(core.Errorf("git %s: %s: %w", core.Join(" ", args...), core.Trim(string(out)), runErr)) + } + return core.Ok(string(out)) +} diff --git a/service_extra_test.go b/service_extra_test.go deleted file mode 100644 index 0f17500..0000000 --- a/service_extra_test.go +++ /dev/null @@ -1,917 +0,0 @@ -package git - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "dappco.re/go/core" -) - -// --- validatePath tests --- - -func assertServiceGitError(t *testing.T, err error, want string) *GitError { - t.Helper() - if err == nil { - t.Fatal("expected error, got nil") - } - var gitErr *GitError - if !errors.As(err, &gitErr) { - t.Fatalf("expected *GitError, got %T", err) - } - if !strings.Contains(err.Error(), want) { - t.Fatalf("expected %v to contain %v", err.Error(), want) - } - if strings.TrimSpace(gitErr.Stderr) == "" { - t.Fatalf("expected non-empty stderr for %v", gitErr.Args) - } - return gitErr -} - -func TestService_ValidatePath_Bad_RelativePath(t *testing.T) { - svc := &Service{opts: ServiceOptions{WorkDir: t.TempDir()}} - _, err := svc.validatePath("relative/path") - assertServiceGitError(t, err, "path must be absolute") -} - -func TestService_ValidatePath_Bad_OutsideWorkDir(t *testing.T) { - workDir := t.TempDir() - outside := t.TempDir() - svc := &Service{opts: ServiceOptions{WorkDir: workDir}} - _, err := svc.validatePath(outside) - assertServiceGitError(t, err, "outside of allowed WorkDir") -} - -func TestService_ValidatePath_Bad_OutsideWorkDirPrefix(t *testing.T) { - base := t.TempDir() - workDir := filepath.Join(base, "repos") - outside := filepath.Join(base, "repos2") - if err := os.MkdirAll(workDir, 0755); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if err := os.MkdirAll(outside, 0755); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - svc := &Service{opts: ServiceOptions{WorkDir: workDir}} - _, err := svc.validatePath(outside) - assertServiceGitError(t, err, "outside of allowed WorkDir") -} - -func TestService_ValidatePath_Bad_WorkDirNotAbsolute(t *testing.T) { - svc := &Service{opts: ServiceOptions{WorkDir: "relative/workdir"}} - _, err := svc.validatePath(t.TempDir()) - assertServiceGitError(t, err, "WorkDir must be absolute") -} - -func TestService_ValidatePath_Bad_SymlinkEscape(t *testing.T) { - workDir := t.TempDir() - outside := t.TempDir() - link := filepath.Join(workDir, "outside-link") - if err := os.Symlink(outside, link); err != nil { - t.Skipf("symlink creation unavailable: %v", err) - } - - svc := &Service{opts: ServiceOptions{WorkDir: workDir}} - _, err := svc.validatePath(link) - assertServiceGitError(t, err, "outside of allowed WorkDir") -} - -func TestService_ValidatePath_Good_InsideWorkDir(t *testing.T) { - workDir := t.TempDir() - repoDir := filepath.Join(workDir, "my-project") - if err := os.MkdirAll(repoDir, 0755); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - svc := &Service{opts: ServiceOptions{WorkDir: workDir}} - got, err := svc.validatePath(repoDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want, err := filepath.EvalSymlinks(repoDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != want { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestService_ValidatePath_Good_NoWorkDir(t *testing.T) { - svc := &Service{opts: ServiceOptions{}} - _, err := svc.validatePath("/any/absolute/path") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -// --- handleQuery path validation --- - -func TestService_HandleQuery_Bad_InvalidPath(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), - opts: ServiceOptions{WorkDir: "/home/repos"}, - } - - result := svc.handleQuery(c, QueryStatus{ - Paths: []string{"/outside/path"}, - Names: map[string]string{"/outside/path": "bad"}, - }) - if result.OK { - t.Fatal("expected false") - } -} - -// --- handleTask path validation --- - -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 := c.Action("git.push").Run(context.Background(), core.NewOptions( - core.Option{Key: "path", Value: "relative/path"}, - )) - if result.OK { - t.Fatal("expected false") - } -} - -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 := c.Action("git.pull").Run(context.Background(), core.NewOptions( - core.Option{Key: "path", Value: "/etc/passwd"}, - )) - if result.OK { - t.Fatal("expected false") - } -} - -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()) - - 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) - if result.OK { - t.Fatal("expected false") - } -} - -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) - if result.OK { - t.Fatal("expected false") - } -} - -func TestService_Action_Bad_PushMultipleInvalidPayload(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - opts: ServiceOptions{}, - } - svc.OnStartup(context.Background()) - - opts := core.NewOptions() - opts.Set("paths", []any{"/tmp/repo"}) - result := c.Action("git.push-multiple").Run(context.Background(), opts) - if result.OK { - t.Fatal("expected false") - } - err, ok := result.Value.(error) - if !ok { - t.Fatalf("expected error, got %T", result.Value) - } - assertServiceGitError(t, err, "paths must be []string") -} - -func TestService_Action_Bad_PullMultipleInvalidNames(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - opts: ServiceOptions{}, - } - svc.OnStartup(context.Background()) - - opts := core.NewOptions() - opts.Set("paths", []string{}) - opts.Set("names", []string{"bad"}) - result := c.Action("git.pull-multiple").Run(context.Background(), opts) - if result.OK { - t.Fatal("expected false") - } - err, ok := result.Value.(error) - if !ok { - t.Fatalf("expected error, got %T", result.Value) - } - assertServiceGitError(t, err, "names must be map[string]string") -} - -func TestNewService_Good(t *testing.T) { - opts := ServiceOptions{WorkDir: t.TempDir()} - factory := NewService(opts) - if factory == nil { - t.Fatal("expected non-nil") - } - - // Create a minimal Core to test the factory. - c := core.New() - - svc, err := factory(c) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if svc == nil { - t.Fatal("expected non-nil") - } - - service, ok := svc.(*Service) - if !ok { - t.Fatal("expected true") - } - if service == nil { - t.Fatal("expected non-nil") - } -} - -func TestService_OnStartup_Good(t *testing.T) { - c := core.New() - - opts := ServiceOptions{WorkDir: t.TempDir()} - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - opts: opts, - } - - result := svc.OnStartup(context.Background()) - if !result.OK { - t.Fatal("expected true") - } -} - -func TestService_HandleQuery_Good_Status(t *testing.T) { - dir, _ := filepath.Abs(initTestRepo(t)) - - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - // Call handleQuery directly. - result := svc.handleQuery(c, QueryStatus{ - Paths: []string{dir}, - Names: map[string]string{dir: "test-repo"}, - }) - - if !result.OK { - t.Fatal("expected true") - } - - statuses, ok := result.Value.([]RepoStatus) - if !ok { - t.Fatal("expected true") - } - if len(statuses) != 1 { - t.Fatalf("want %v, got %v", 1, len(statuses)) - } - if "test-repo" != statuses[0].Name { - t.Fatalf("want %v, got %v", "test-repo", statuses[0].Name) - } - - // Verify lastStatus was updated. - if len(svc.lastStatus) != 1 { - t.Fatalf("want %v, got %v", 1, len(svc.lastStatus)) - } -} - -func TestService_HandleQuery_Good_DirtyRepos(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - lastStatus: []RepoStatus{ - {Name: "clean"}, - {Name: "dirty", Modified: 1}, - }, - } - - result := svc.handleQuery(c, QueryDirtyRepos{}) - if !result.OK { - t.Fatal("expected true") - } - - dirty, ok := result.Value.([]RepoStatus) - if !ok { - t.Fatal("expected true") - } - if len(dirty) != 1 { - t.Fatalf("want %v, got %v", 1, len(dirty)) - } - if "dirty" != dirty[0].Name { - t.Fatalf("want %v, got %v", "dirty", dirty[0].Name) - } -} - -func TestService_HandleQuery_Good_AheadRepos(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - lastStatus: []RepoStatus{ - {Name: "synced"}, - {Name: "ahead", Ahead: 3}, - }, - } - - result := svc.handleQuery(c, QueryAheadRepos{}) - if !result.OK { - t.Fatal("expected true") - } - - ahead, ok := result.Value.([]RepoStatus) - if !ok { - t.Fatal("expected true") - } - if len(ahead) != 1 { - t.Fatalf("want %v, got %v", 1, len(ahead)) - } - if "ahead" != ahead[0].Name { - t.Fatalf("want %v, got %v", "ahead", ahead[0].Name) - } -} - -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{}) - if !result.OK { - t.Fatal("expected true") - } - - behind, ok := result.Value.([]RepoStatus) - if !ok { - t.Fatal("expected true") - } - if len(behind) != 1 { - t.Fatalf("want %v, got %v", 1, len(behind)) - } - if "behind" != behind[0].Name { - t.Fatalf("want %v, got %v", "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 - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd = exec.Command("git", "clone", bareDir, cloneDir) - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - 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 - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - 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() - if err != nil { - t.Fatalf("command %v failed: %s: %v", args, string(out), err) - } - } - - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "second commit"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - c := core.New() - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleTaskMessage(c, TaskPush{Path: cloneDir}) - if !result.OK { - t.Fatal("expected true") - } -} - -func TestService_HandleTaskMessage_Ignores_UnknownTask(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleTaskMessage(c, struct{}{}) - if result.OK { - t.Fatal("expected false") - } - if result.Value != nil { - t.Fatalf("expected nil, got %v", 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{}{}) - if result.OK { - t.Fatal("expected false") - } - err, ok := result.Value.(error) - if !ok { - t.Fatalf("expected error, got %T", result.Value) - } - if !strings.Contains(err.Error(), "unsupported task type") { - t.Fatalf("expected %v to contain %v", err.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 - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd = exec.Command("git", "clone", bareDir, cloneDir) - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - 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 - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - 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() - if err != nil { - t.Fatalf("command %v failed: %s: %v", args, string(out), err) - } - } - - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "second commit"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - c := core.New() - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - svc.OnStartup(context.Background()) - - result := c.ACTION(TaskPush{Path: cloneDir}) - if !result.OK { - t.Fatal("expected true") - } - - ahead, behind, err := getAheadBehind(context.Background(), cloneDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if 0 != ahead { - t.Fatalf("want %v, got %v", 0, ahead) - } - if 0 != behind { - t.Fatalf("want %v, got %v", 0, behind) - } -} - -func TestService_HandleQuery_Ignores_UnknownQuery(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleQuery(c, "unknown query type") - if result.OK { - t.Fatal("expected false") - } - if result.Value != nil { - t.Fatalf("expected nil, got %v", result.Value) - } -} - -func TestService_Action_Bad_PushNoRemote(t *testing.T) { - dir, _ := filepath.Abs(initTestRepo(t)) - - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - svc.OnStartup(context.Background()) - - result := c.Action("git.push").Run(context.Background(), core.NewOptions( - core.Option{Key: "path", Value: dir}, - )) - if result.OK { - t.Fatal("push without remote should fail: expected false") - } -} - -func TestService_Action_Bad_PullNoRemote(t *testing.T) { - dir, _ := filepath.Abs(initTestRepo(t)) - - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - svc.OnStartup(context.Background()) - - result := c.Action("git.pull").Run(context.Background(), core.NewOptions( - core.Option{Key: "path", Value: dir}, - )) - if result.OK { - t.Fatal("pull without remote should fail: expected false") - } -} - -func TestService_Action_Bad_PushMultiple_NoRemote(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) - - // PushMultiple returns results even when individual pushes fail, but the - // overall action should still report failure. - if result.OK { - t.Fatal("expected false") - } - - results, ok := result.Value.([]PushResult) - if !ok { - t.Fatal("expected true") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if results[0].Success { // No remote - t.Fatal("expected false") - } -} - -func TestService_Action_Bad_PullMultiple_NoRemote(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) - - if result.OK { - t.Fatal("expected false") - } - results, ok := result.Value.([]PullResult) - if !ok { - t.Fatal("expected true") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if "test" != results[0].Name { - t.Fatalf("want %v, got %v", "test", results[0].Name) - } - if results[0].Success { - t.Fatal("expected false") - } - if results[0].Error == nil { - t.Fatal("expected error, got nil") - } -} - -func TestService_HandleTask_Bad_PushMultiple(t *testing.T) { - dir, _ := filepath.Abs(initTestRepo(t)) - - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleTask(c, TaskPushMultiple{ - Paths: []string{dir}, - Names: map[string]string{dir: "test"}, - }) - - if result.OK { - t.Fatal("expected false") - } - results, ok := result.Value.([]PushResult) - if !ok { - t.Fatal("expected true") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if "test" != results[0].Name { - t.Fatalf("want %v, got %v", "test", results[0].Name) - } - if results[0].Success { - t.Fatal("expected false") - } - if results[0].Error == nil { - t.Fatal("expected error, got nil") - } -} - -func TestService_HandleTask_Bad_PullMultiple(t *testing.T) { - dir, _ := filepath.Abs(initTestRepo(t)) - - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleTask(c, TaskPullMultiple{ - Paths: []string{dir}, - Names: map[string]string{dir: "test"}, - }) - - if result.OK { - t.Fatal("expected false") - } - results, ok := result.Value.([]PullResult) - if !ok { - t.Fatal("expected true") - } - if len(results) != 1 { - t.Fatalf("want %v, got %v", 1, len(results)) - } - if "test" != results[0].Name { - t.Fatalf("want %v, got %v", "test", results[0].Name) - } - if results[0].Success { - t.Fatal("expected false") - } - if results[0].Error == nil { - t.Fatal("expected error, got nil") - } -} - -// --- Additional git operation tests --- - -func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) { - // A repo without a tracking branch should return 0 ahead/behind. - dir, _ := filepath.Abs(initTestRepo(t)) - - status := getStatus(context.Background(), dir, "no-upstream") - if status.Error != nil { - t.Fatalf("unexpected error: %v", status.Error) - } - if 0 != status.Ahead { - t.Fatalf("want %v, got %v", 0, status.Ahead) - } - if 0 != status.Behind { - t.Fatalf("want %v, got %v", 0, status.Behind) - } -} - -func TestPushMultiple_Good_Empty(t *testing.T) { - results, err := PushMultiple(context.Background(), []string{}, map[string]string{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(results) != 0 { - t.Fatalf("want %v, got %v", 0, len(results)) - } -} - -func TestPushMultiple_Good_MultiplePaths(t *testing.T) { - dir1, _ := filepath.Abs(initTestRepo(t)) - dir2, _ := filepath.Abs(initTestRepo(t)) - - results, err := PushMultiple(context.Background(), []string{dir1, dir2}, map[string]string{ - dir1: "repo-1", - dir2: "repo-2", - }) - if err == nil { - t.Fatal("expected error, got nil") - } - - if len(results) != 2 { - t.Fatalf("want %v, got %v", 2, len(results)) - } - if "repo-1" != results[0].Name { - t.Fatalf("want %v, got %v", "repo-1", results[0].Name) - } - if "repo-2" != results[1].Name { - t.Fatalf("want %v, got %v", "repo-2", results[1].Name) - } - // Both should fail (no remote). - if results[0].Success { - t.Fatal("expected false") - } - if results[1].Success { - t.Fatal("expected false") - } -} - -func TestPush_Good_WithRemote(t *testing.T) { - // Create a bare remote and a clone. - bareDir, _ := filepath.Abs(t.TempDir()) - cloneDir, _ := filepath.Abs(t.TempDir()) - - cmd := exec.Command("git", "init", "--bare") - cmd.Dir = bareDir - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd = exec.Command("git", "clone", bareDir, cloneDir) - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - 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 - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - 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() - if err != nil { - t.Fatalf("failed: %v: %s: %v", args, string(out), err) - } - } - - // Make a local commit. - if err := os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, args := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "second commit"}, - } { - cmd = exec.Command(args[0], args[1:]...) - cmd.Dir = cloneDir - if err := cmd.Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - } - - // Push should succeed. - err := Push(context.Background(), cloneDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify ahead count is now 0. - ahead, behind, err := getAheadBehind(context.Background(), cloneDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if 0 != ahead { - t.Fatalf("want %v, got %v", 0, ahead) - } - if 0 != behind { - t.Fatalf("want %v, got %v", 0, behind) - } -} diff --git a/service_test.go b/service_test.go deleted file mode 100644 index be9d763..0000000 --- a/service_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package git - -import ( - "context" - "errors" - "reflect" - "slices" - "testing" -) - -func statusNames(statuses []RepoStatus) []string { - names := make([]string, 0, len(statuses)) - for _, st := range statuses { - names = append(names, st.Name) - } - return names -} - -func TestService_DirtyRepos_Good(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "clean"}, - {Name: "dirty-modified", Modified: 2}, - {Name: "dirty-untracked", Untracked: 1}, - {Name: "dirty-staged", Staged: 3}, - {Name: "ahead-only", Ahead: 4}, - {Name: "behind-only", Behind: 5}, - }, - } - - dirty := s.DirtyRepos() - if len(dirty) != 3 { - t.Fatalf("want %v, got %v", 3, len(dirty)) - } - - names := statusNames(dirty) - for _, name := range []string{"dirty-modified", "dirty-untracked", "dirty-staged"} { - if !slices.Contains(names, name) { - t.Fatalf("expected %v to contain %v", names, name) - } - } -} - -func TestService_DirtyRepos_Bad(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "dirty-error", Modified: 1, Error: errors.New("status failed")}, - {Name: "invalid-negative", Modified: -1, Untracked: -1, Staged: -1}, - }, - } - - dirty := s.DirtyRepos() - if len(dirty) != 0 { - t.Fatalf("want %v, got %v", 0, len(dirty)) - } -} - -func TestService_DirtyRepos_Ugly(t *testing.T) { - tests := []struct { - name string - svc *Service - }{ - {name: "nil status slice", svc: &Service{}}, - {name: "empty status slice", svc: &Service{lastStatus: []RepoStatus{}}}, - {name: "only clean repos", svc: &Service{lastStatus: []RepoStatus{{Name: "clean1"}, {Name: "clean2"}}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dirty := tt.svc.DirtyRepos() - if len(dirty) != 0 { - t.Fatalf("want %v, got %v", 0, len(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}, - }, - } - - ahead := s.AheadRepos() - if len(ahead) != 2 { - t.Fatalf("want %v, got %v", 2, len(ahead)) - } - - names := statusNames(ahead) - for _, name := range []string{"ahead-by-one", "ahead-by-five"} { - if !slices.Contains(names, name) { - t.Fatalf("expected %v to contain %v", names, name) - } - } -} - -func TestService_AheadRepos_Bad(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "ahead-error", Ahead: 2, Error: errors.New("status failed")}, - {Name: "invalid-negative", Ahead: -1}, - }, - } - - ahead := s.AheadRepos() - if len(ahead) != 0 { - t.Fatalf("want %v, got %v", 0, len(ahead)) - } -} - -func TestService_AheadRepos_Ugly(t *testing.T) { - tests := []struct { - name string - svc *Service - }{ - {name: "nil status slice", svc: &Service{}}, - {name: "empty status slice", svc: &Service{lastStatus: []RepoStatus{}}}, - {name: "only synced repos", svc: &Service{lastStatus: []RepoStatus{{Name: "synced1"}, {Name: "synced2"}}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ahead := tt.svc.AheadRepos() - if len(ahead) != 0 { - t.Fatalf("want %v, got %v", 0, len(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}, - }, - } - - behind := s.BehindRepos() - if len(behind) != 2 { - t.Fatalf("want %v, got %v", 2, len(behind)) - } - - names := statusNames(behind) - for _, name := range []string{"behind-by-one", "behind-by-five"} { - if !slices.Contains(names, name) { - t.Fatalf("expected %v to contain %v", names, name) - } - } -} - -func TestService_BehindRepos_Bad(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "behind-error", Behind: 2, Error: errors.New("status failed")}, - {Name: "invalid-negative", Behind: -1}, - }, - } - - behind := s.BehindRepos() - if len(behind) != 0 { - t.Fatalf("want %v, got %v", 0, len(behind)) - } -} - -func TestService_BehindRepos_Ugly(t *testing.T) { - tests := []struct { - name string - svc *Service - }{ - {name: "nil status slice", svc: &Service{}}, - {name: "empty status slice", svc: &Service{lastStatus: []RepoStatus{}}}, - {name: "only synced repos", svc: &Service{lastStatus: []RepoStatus{{Name: "synced1"}, {Name: "synced2"}}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - behind := tt.svc.BehindRepos() - if len(behind) != 0 { - t.Fatalf("want %v, got %v", 0, len(behind)) - } - }) - } -} - -func TestService_Iterators_Good(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "clean"}, - {Name: "dirty", Modified: 1}, - {Name: "ahead", Ahead: 2}, - {Name: "behind", Behind: 3}, - }, - } - - all := slices.Collect(s.All()) - if len(all) != 4 { - t.Fatalf("want %v, got %v", 4, len(all)) - } - - dirty := slices.Collect(s.Dirty()) - if len(dirty) != 1 || dirty[0].Name != "dirty" { - t.Fatalf("want dirty repo only, got %v", dirty) - } - - ahead := slices.Collect(s.Ahead()) - if len(ahead) != 1 || ahead[0].Name != "ahead" { - t.Fatalf("want ahead repo only, got %v", ahead) - } - - behind := slices.Collect(s.Behind()) - if len(behind) != 1 || behind[0].Name != "behind" { - t.Fatalf("want behind repo only, got %v", behind) - } -} - -func TestService_Iterators_Bad(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "dirty-error", Modified: 1, Error: errors.New("status failed")}, - {Name: "ahead-error", Ahead: 1, Error: errors.New("status failed")}, - {Name: "behind-error", Behind: 1, Error: errors.New("status failed")}, - }, - } - - if dirty := slices.Collect(s.Dirty()); len(dirty) != 0 { - t.Fatalf("want %v, got %v", 0, len(dirty)) - } - if ahead := slices.Collect(s.Ahead()); len(ahead) != 0 { - t.Fatalf("want %v, got %v", 0, len(ahead)) - } - if behind := slices.Collect(s.Behind()); len(behind) != 0 { - t.Fatalf("want %v, got %v", 0, len(behind)) - } - - all := slices.Collect(s.All()) - if len(all) != 3 { - t.Fatalf("All should keep errored repos: want %v, got %v", 3, len(all)) - } -} - -func TestService_Iterators_Ugly(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "dirty", Modified: 1}, - {Name: "ahead", Ahead: 1}, - }, - } - - allIter := s.All() - dirtyIter := s.Dirty() - s.lastStatus[0].Name = "mutated" - s.lastStatus[0].Modified = 0 - - all := slices.Collect(allIter) - if len(all) != 2 || all[0].Name != "dirty" { - t.Fatalf("iterator should use a snapshot, got %v", all) - } - - dirty := slices.Collect(dirtyIter) - if len(dirty) != 1 || dirty[0].Name != "dirty" { - t.Fatalf("filtered iterator should use a snapshot, got %v", dirty) - } -} - -func TestService_Status_Good(t *testing.T) { - expected := []RepoStatus{ - {Name: "repo1", Branch: "main"}, - {Name: "repo2", Branch: "develop"}, - } - s := &Service{lastStatus: expected} - - if got := s.Status(); !reflect.DeepEqual(expected, got) { - t.Fatalf("want %v, got %v", expected, got) - } -} - -func TestService_Status_Bad(t *testing.T) { - s := &Service{lastStatus: []RepoStatus{{Name: "repo1", Branch: "main"}}} - - statuses := s.Status() - statuses[0].Name = "mutated" - - if got := s.lastStatus[0].Name; got != "repo1" { - t.Fatalf("Status should return a clone: want %v, got %v", "repo1", got) - } -} - -func TestService_Status_Ugly(t *testing.T) { - s := &Service{} - if got := s.Status(); got != nil { - t.Fatalf("expected nil, got %v", got) - } -} - -func TestService_QueryStatus_Good(t *testing.T) { - q := QueryStatus{ - Paths: []string{"/path/a", "/path/b"}, - Names: map[string]string{"/path/a": "repo-a"}, - } - - opts := StatusOptions(q) - if !slices.Equal(q.Paths, opts.Paths) { - t.Fatalf("want %v, got %v", q.Paths, opts.Paths) - } - if !reflect.DeepEqual(q.Names, opts.Names) { - t.Fatalf("want %v, got %v", q.Names, opts.Names) - } -} - -func TestService_QueryStatus_Bad(t *testing.T) { - var q QueryStatus - - opts := StatusOptions(q) - if opts.Paths != nil { - t.Fatalf("expected nil paths, got %v", opts.Paths) - } - if opts.Names != nil { - t.Fatalf("expected nil names, got %v", opts.Names) - } -} - -func TestService_QueryStatus_Ugly(t *testing.T) { - q := QueryStatus{ - Paths: []string{}, - Names: map[string]string{}, - } - - opts := StatusOptions(q) - if opts.Paths == nil { - t.Fatal("expected empty but non-nil paths") - } - if opts.Names == nil { - t.Fatal("expected empty but non-nil names") - } -} - -func TestService_QueryBehindRepos_Good(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "synced"}, - {Name: "behind", Behind: 2}, - {Name: "ahead", Ahead: 2}, - }, - } - - behind := s.BehindRepos() - if len(behind) != 1 { - t.Fatalf("want %v, got %v", 1, len(behind)) - } - if "behind" != behind[0].Name { - t.Fatalf("want %v, got %v", "behind", behind[0].Name) - } -} - -func TestService_QueryBehindRepos_Bad(t *testing.T) { - s := &Service{ - lastStatus: []RepoStatus{ - {Name: "behind-error", Behind: 1, Error: errors.New("status failed")}, - {Name: "behind-negative", Behind: -1}, - }, - } - - if behind := s.BehindRepos(); len(behind) != 0 { - t.Fatalf("want %v, got %v", 0, len(behind)) - } -} - -func TestService_QueryBehindRepos_Ugly(t *testing.T) { - s := &Service{} - if behind := s.BehindRepos(); len(behind) != 0 { - t.Fatalf("want %v, got %v", 0, len(behind)) - } -} - -func TestService_TaskPullMultiple_Good(t *testing.T) { - s := &Service{} - - result := s.runPullMultiple(context.Background(), []string{}, nil) - if !result.OK { - t.Fatalf("expected true, got %v", result.Value) - } - pulls, ok := result.Value.([]PullResult) - if !ok { - t.Fatalf("expected []PullResult, got %T", result.Value) - } - if len(pulls) != 0 { - t.Fatalf("want %v, got %v", 0, len(pulls)) - } -} - -func TestService_TaskPullMultiple_Bad(t *testing.T) { - s := &Service{} - - result := s.runPullMultiple(context.Background(), []string{"relative/path"}, nil) - if result.OK { - t.Fatal("expected false") - } - err, ok := result.Value.(error) - if !ok { - t.Fatalf("expected error, got %T", result.Value) - } - var gitErr *GitError - if !errors.As(err, &gitErr) { - t.Fatalf("expected *GitError, got %T", err) - } - if !slices.Contains(gitErr.Args, "path=relative/path") { - t.Fatalf("expected args %v to contain relative path", gitErr.Args) - } -} - -func TestService_TaskPullMultiple_Ugly(t *testing.T) { - task := TaskPullMultiple{ - Paths: []string{}, - Names: map[string]string{}, - } - - if task.Paths == nil { - t.Fatal("expected empty but non-nil paths") - } - if task.Names == nil { - t.Fatal("expected empty but non-nil names") - } -} - -func TestService_ServiceOptions_Good(t *testing.T) { - workDir := "/tmp/test-repos" - opts := ServiceOptions{WorkDir: workDir} - if workDir != opts.WorkDir { - t.Fatalf("want %v, got %v", workDir, opts.WorkDir) - } -} - -func TestService_ServiceOptions_Bad(t *testing.T) { - opts := ServiceOptions{WorkDir: "relative/workdir"} - if opts.WorkDir != "relative/workdir" { - t.Fatalf("want %v, got %v", "relative/workdir", opts.WorkDir) - } -} - -func TestService_ServiceOptions_Ugly(t *testing.T) { - var opts ServiceOptions - if opts.WorkDir != "" { - t.Fatalf("want empty WorkDir, got %v", opts.WorkDir) - } -} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..a4775da --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,8 @@ +sonar.projectKey=core_go-git +sonar.projectName=core/go-git +sonar.sources=. +sonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/**,**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js +sonar.tests=. +sonar.test.inclusions=**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js +sonar.test.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/** +sonar.go.coverage.reportPaths=go/coverage.out diff --git a/tests/cli/git/main.go b/tests/cli/git/main.go deleted file mode 100644 index 4864dc2..0000000 --- a/tests/cli/git/main.go +++ /dev/null @@ -1,317 +0,0 @@ -// AX-10 CLI driver for go-git. It exercises the public Git status and -// push/pull helpers against local temporary repositories. -// -// task -d tests/cli/git test -// go run ./tests/cli/git -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - - gitlib "dappco.re/go/git" -) - -func main() { - if err := run(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - ctx := context.Background() - - if err := verifyStatus(ctx); err != nil { - return fmt.Errorf("status: %w", err) - } - if err := verifyPushPull(ctx); err != nil { - return fmt.Errorf("push/pull: %w", err) - } - if err := verifyErrors(ctx); err != nil { - return fmt.Errorf("errors: %w", err) - } - - return nil -} - -func verifyStatus(ctx context.Context) error { - clean, err := initRepo() - if err != nil { - return err - } - defer func() { - _ = os.RemoveAll(clean) - }() - - dirty, err := initRepo() - if err != nil { - return err - } - defer func() { - _ = os.RemoveAll(dirty) - }() - - if err := os.WriteFile(filepath.Join(dirty, "README.md"), []byte("# Changed\n"), 0644); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(dirty, "staged.txt"), []byte("staged\n"), 0644); err != nil { - return err - } - if err := runGit(dirty, "add", "staged.txt"); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(dirty, "untracked.txt"), []byte("untracked\n"), 0644); err != nil { - return err - } - - opts := gitlib.StatusOptions{ - Paths: []string{clean, dirty}, - Names: map[string]string{ - clean: "clean-repo", - dirty: "dirty-repo", - }, - } - - statuses := gitlib.Status(ctx, opts) - if err := verifyStatusResults(statuses, clean, dirty); err != nil { - return err - } - - iterStatuses := slices.Collect(gitlib.StatusIter(ctx, opts)) - if err := verifyStatusResults(iterStatuses, clean, dirty); err != nil { - return fmt.Errorf("iterator: %w", err) - } - - return nil -} - -func verifyStatusResults(statuses []gitlib.RepoStatus, clean, dirty string) error { - if len(statuses) != 2 { - return fmt.Errorf("expected 2 statuses, got %d", len(statuses)) - } - - cleanStatus := statuses[0] - if cleanStatus.Name != "clean-repo" { - return fmt.Errorf("clean name = %q", cleanStatus.Name) - } - if cleanStatus.Path != clean { - return fmt.Errorf("clean path = %q", cleanStatus.Path) - } - if cleanStatus.Error != nil { - return cleanStatus.Error - } - if cleanStatus.Branch == "" { - return errors.New("clean branch should not be empty") - } - if cleanStatus.IsDirty() { - return errors.New("clean repo reported dirty") - } - - dirtyStatus := statuses[1] - if dirtyStatus.Name != "dirty-repo" { - return fmt.Errorf("dirty name = %q", dirtyStatus.Name) - } - if dirtyStatus.Path != dirty { - return fmt.Errorf("dirty path = %q", dirtyStatus.Path) - } - if dirtyStatus.Error != nil { - return dirtyStatus.Error - } - if !dirtyStatus.IsDirty() { - return errors.New("dirty repo reported clean") - } - if dirtyStatus.Modified != 1 || dirtyStatus.Staged != 1 || dirtyStatus.Untracked != 1 { - return fmt.Errorf("dirty counts = modified:%d staged:%d untracked:%d", dirtyStatus.Modified, dirtyStatus.Staged, dirtyStatus.Untracked) - } - - return nil -} - -func verifyPushPull(ctx context.Context) error { - root, err := os.MkdirTemp("", "go-git-ax10-") - if err != nil { - return err - } - defer func() { - _ = os.RemoveAll(root) - }() - - remote := filepath.Join(root, "remote.git") - pushClone := filepath.Join(root, "push") - pullClone := filepath.Join(root, "pull") - - if err := runGit(root, "init", "--bare", remote); err != nil { - return err - } - if err := runGit(root, "clone", remote, pushClone); err != nil { - return err - } - if err := configureUser(pushClone); err != nil { - return err - } - if err := commitFile(pushClone, "file.txt", "v1\n", "initial commit"); err != nil { - return err - } - if err := runGit(pushClone, "push", "-u", "origin", "HEAD"); err != nil { - return err - } - - if err := runGit(root, "clone", remote, pullClone); err != nil { - return err - } - if err := configureUser(pullClone); err != nil { - return err - } - - if err := commitFile(pushClone, "file.txt", "v2\n", "local commit"); err != nil { - return err - } - statuses := gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{pushClone}, Names: map[string]string{pushClone: "push"}}) - if err := expectSingleStatus(statuses, "push", 1, 0); err != nil { - return err - } - - if err := gitlib.Push(ctx, pushClone); err != nil { - return err - } - statuses = gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{pushClone}, Names: map[string]string{pushClone: "push"}}) - if err := expectSingleStatus(statuses, "push", 0, 0); err != nil { - return err - } - - if err := runGit(pullClone, "fetch", "origin"); err != nil { - return err - } - statuses = gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{pullClone}, Names: map[string]string{pullClone: "pull"}}) - if err := expectSingleStatus(statuses, "pull", 0, 1); err != nil { - return err - } - - if err := gitlib.Pull(ctx, pullClone); err != nil { - return err - } - statuses = gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{pullClone}, Names: map[string]string{pullClone: "pull"}}) - if err := expectSingleStatus(statuses, "pull", 0, 0); err != nil { - return err - } - - pushResults, err := gitlib.PushMultiple(ctx, []string{pushClone}, map[string]string{pushClone: "push"}) - if err != nil { - return err - } - if len(pushResults) != 1 || !pushResults[0].Success || pushResults[0].Name != "push" { - return fmt.Errorf("unexpected push multiple results: %+v", pushResults) - } - - pullResults, err := gitlib.PullMultiple(ctx, []string{pullClone}, map[string]string{pullClone: "pull"}) - if err != nil { - return err - } - if len(pullResults) != 1 || !pullResults[0].Success || pullResults[0].Name != "pull" { - return fmt.Errorf("unexpected pull multiple results: %+v", pullResults) - } - - return nil -} - -func expectSingleStatus(statuses []gitlib.RepoStatus, name string, ahead, behind int) error { - if len(statuses) != 1 { - return fmt.Errorf("expected 1 status, got %d", len(statuses)) - } - status := statuses[0] - if status.Error != nil { - return status.Error - } - if status.Name != name { - return fmt.Errorf("status name = %q", status.Name) - } - if status.Ahead != ahead || status.Behind != behind { - return fmt.Errorf("%s ahead/behind = %d/%d, want %d/%d", name, status.Ahead, status.Behind, ahead, behind) - } - if ahead > 0 && !status.HasUnpushed() { - return fmt.Errorf("%s should report unpushed commits", name) - } - if behind > 0 && !status.HasUnpulled() { - return fmt.Errorf("%s should report unpulled commits", name) - } - return nil -} - -func verifyErrors(ctx context.Context) error { - statuses := gitlib.Status(ctx, gitlib.StatusOptions{Paths: []string{"relative/path"}}) - if len(statuses) != 1 || statuses[0].Error == nil { - return errors.New("relative status path should fail") - } - if !strings.Contains(statuses[0].Error.Error(), "path must be absolute") { - return fmt.Errorf("relative status error = %v", statuses[0].Error) - } - - if err := gitlib.Push(ctx, "relative/path"); err == nil { - return errors.New("relative push path should fail") - } - if err := gitlib.Pull(ctx, "relative/path"); err == nil { - return errors.New("relative pull path should fail") - } - if !gitlib.IsNonFastForward(errors.New("Updates were rejected: fetch first")) { - return errors.New("non-fast-forward detection should match fetch first errors") - } - if gitlib.IsNonFastForward(errors.New("connection refused")) { - return errors.New("non-fast-forward detection should ignore unrelated errors") - } - - return nil -} - -func initRepo() (string, error) { - dir, err := os.MkdirTemp("", "go-git-ax10-repo-") - if err != nil { - return "", err - } - if err := runGit(dir, "init"); err != nil { - _ = os.RemoveAll(dir) - return "", err - } - if err := configureUser(dir); err != nil { - _ = os.RemoveAll(dir) - return "", err - } - if err := commitFile(dir, "README.md", "# Test\n", "initial commit"); err != nil { - _ = os.RemoveAll(dir) - return "", err - } - return dir, nil -} - -func configureUser(dir string) error { - if err := runGit(dir, "config", "user.email", "test@example.com"); err != nil { - return err - } - return runGit(dir, "config", "user.name", "Test User") -} - -func commitFile(dir, name, content, message string) error { - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { - return err - } - if err := runGit(dir, "add", name); err != nil { - return err - } - return runGit(dir, "commit", "-m", message) -} - -func runGit(dir string, args ...string) error { - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), strings.TrimSpace(string(out)), err) - } - return nil -}