Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
13 changed files with 131 additions and 401 deletions
|
|
@ -1,24 +0,0 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: go-git
|
||||
description: Git operations
|
||||
binary: ""
|
||||
|
||||
build:
|
||||
cgo: false
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: linux
|
||||
arch: arm64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
- os: windows
|
||||
arch: amd64
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
version: 1
|
||||
|
||||
project:
|
||||
name: go-git
|
||||
repository: core/go-git
|
||||
|
||||
publishers: []
|
||||
|
||||
changelog:
|
||||
include:
|
||||
- feat
|
||||
- fix
|
||||
- perf
|
||||
- refactor
|
||||
exclude:
|
||||
- chore
|
||||
- docs
|
||||
- style
|
||||
- test
|
||||
- ci
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{md,yml,yaml,json,txt}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
## Requirements
|
||||
- **Go Version**: 1.26 or higher is required.
|
||||
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
|
||||
|
||||
## Development Workflow
|
||||
1. **Testing**: Ensure all tests pass before submitting changes.
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
2. **Code Style**: All code must follow standard Go formatting.
|
||||
```bash
|
||||
gofmt -w .
|
||||
go vet ./...
|
||||
```
|
||||
3. **Linting**: We use `golangci-lint` to maintain code quality.
|
||||
```bash
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
## Commit Message Format
|
||||
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `refactor`: A code change that neither fixes a bug nor adds a feature
|
||||
- `chore`: Changes to the build process or auxiliary tools and libraries
|
||||
|
||||
Example: `feat: add new endpoint for health check`
|
||||
|
||||
## License
|
||||
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.
|
||||
11
README.md
11
README.md
|
|
@ -1,11 +0,0 @@
|
|||
[](https://pkg.go.dev/forge.lthn.ai/core/go-git)
|
||||
[](LICENSE.md)
|
||||
[](go.mod)
|
||||
|
||||
# go-git
|
||||
|
||||
Go module: `forge.lthn.ai/core/go-git`
|
||||
|
||||
## License
|
||||
|
||||
[EUPL-1.2](LICENSE.md)
|
||||
73
git.go
73
git.go
|
|
@ -4,12 +4,9 @@ package git
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -79,12 +76,6 @@ func getStatus(ctx context.Context, path, name string) RepoStatus {
|
|||
Path: path,
|
||||
}
|
||||
|
||||
// Validate path to prevent directory traversal
|
||||
if !filepath.IsAbs(path) {
|
||||
status.Error = fmt.Errorf("path must be absolute: %s", path)
|
||||
return status
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
branch, err := gitCommand(ctx, path, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
|
|
@ -114,22 +105,18 @@ func getStatus(ctx context.Context, path, name string) RepoStatus {
|
|||
}
|
||||
|
||||
// Staged (index has changes)
|
||||
if slices.Contains([]byte{'A', 'D', 'R', 'M'}, x) {
|
||||
if x == 'A' || x == 'D' || x == 'R' || x == 'M' {
|
||||
status.Staged++
|
||||
}
|
||||
|
||||
// Modified in working tree
|
||||
if slices.Contains([]byte{'M', 'D'}, y) {
|
||||
if y == 'M' || y == 'D' {
|
||||
status.Modified++
|
||||
}
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
ahead, behind, err := getAheadBehind(ctx, path)
|
||||
if err != nil {
|
||||
// We don't fail the whole status if ahead/behind fails (might be no upstream)
|
||||
// but we could log it or store it if needed. For now, we just keep 0.
|
||||
}
|
||||
ahead, behind := getAheadBehind(ctx, path)
|
||||
status.Ahead = ahead
|
||||
status.Behind = behind
|
||||
|
||||
|
|
@ -137,33 +124,20 @@ func getStatus(ctx context.Context, path, name string) RepoStatus {
|
|||
}
|
||||
|
||||
// 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 context.Context, path string) (ahead, behind int) {
|
||||
// Try to get ahead count
|
||||
aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD")
|
||||
if err == nil {
|
||||
ahead, _ = strconv.Atoi(strings.TrimSpace(aheadStr))
|
||||
} else {
|
||||
// If it failed because of no upstream, don't return error
|
||||
if strings.Contains(err.Error(), "no upstream") || strings.Contains(err.Error(), "No upstream") {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Try to get behind count
|
||||
behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}")
|
||||
if err == nil {
|
||||
behind, _ = strconv.Atoi(strings.TrimSpace(behindStr))
|
||||
} else {
|
||||
if strings.Contains(err.Error(), "no upstream") || strings.Contains(err.Error(), "No upstream") {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
return ahead, behind, err
|
||||
return ahead, behind
|
||||
}
|
||||
|
||||
// Push pushes commits for a single repository.
|
||||
|
|
@ -203,11 +177,10 @@ func gitInteractive(ctx context.Context, dir string, args ...string) error {
|
|||
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return &GitError{
|
||||
Args: args,
|
||||
Err: err,
|
||||
Stderr: stderr.String(),
|
||||
if stderr.Len() > 0 {
|
||||
return &GitError{Err: err, Stderr: stderr.String()}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -223,9 +196,8 @@ type PushResult 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) {
|
||||
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult {
|
||||
results := make([]PushResult, len(paths))
|
||||
var lastErr error
|
||||
|
||||
for i, path := range paths {
|
||||
name := names[path]
|
||||
|
|
@ -241,7 +213,6 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string)
|
|||
err := Push(ctx, path)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
lastErr = err
|
||||
} else {
|
||||
result.Success = true
|
||||
}
|
||||
|
|
@ -249,7 +220,7 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string)
|
|||
results[i] = result
|
||||
}
|
||||
|
||||
return results, lastErr
|
||||
return results
|
||||
}
|
||||
|
||||
// gitCommand runs a git command and returns stdout.
|
||||
|
|
@ -262,32 +233,30 @@ func gitCommand(ctx context.Context, dir string, args ...string) (string, error)
|
|||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", &GitError{
|
||||
Args: args,
|
||||
Err: err,
|
||||
Stderr: stderr.String(),
|
||||
// Include stderr in error message for better diagnostics
|
||||
if stderr.Len() > 0 {
|
||||
return "", &GitError{Err: err, Stderr: stderr.String()}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// GitError wraps a git command error with stderr output and command context.
|
||||
// GitError wraps a git command error with stderr output.
|
||||
type GitError struct {
|
||||
Args []string
|
||||
Err error
|
||||
Stderr string
|
||||
}
|
||||
|
||||
// Error returns a descriptive error message.
|
||||
// Error returns the git error message, preferring stderr output.
|
||||
func (e *GitError) Error() string {
|
||||
cmd := "git " + strings.Join(e.Args, " ")
|
||||
stderr := strings.TrimSpace(e.Stderr)
|
||||
|
||||
if stderr != "" {
|
||||
return fmt.Errorf("git command %q failed: %s", cmd, stderr).Error()
|
||||
// Return just the stderr message, trimmed
|
||||
msg := strings.TrimSpace(e.Stderr)
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
return fmt.Errorf("git command %q failed: %w", cmd, e.Err).Error()
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for error chain inspection.
|
||||
|
|
|
|||
67
git_test.go
67
git_test.go
|
|
@ -169,13 +169,18 @@ func TestGitError_Error(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
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",
|
||||
err: &GitError{Err: errors.New("exit 1"), Stderr: "fatal: not a git repository"},
|
||||
expected: "fatal: not a git repository",
|
||||
},
|
||||
{
|
||||
name: "falls back to underlying error",
|
||||
err: &GitError{Args: []string{"status"}, Err: errors.New("exit status 128"), Stderr: ""},
|
||||
expected: "git command \"git status\" failed: exit status 128",
|
||||
err: &GitError{Err: errors.New("exit status 128"), Stderr: ""},
|
||||
expected: "exit status 128",
|
||||
},
|
||||
{
|
||||
name: "trims whitespace from stderr",
|
||||
err: &GitError{Err: errors.New("exit 1"), Stderr: " error message \n"},
|
||||
expected: "error message",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +243,7 @@ func TestIsNonFastForward(t *testing.T) {
|
|||
// --- gitCommand tests with real git repos ---
|
||||
|
||||
func TestGitCommand_Good(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
out, err := gitCommand(context.Background(), dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -253,7 +258,7 @@ func TestGitCommand_Bad_InvalidDir(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGitCommand_Bad_NotARepo(t *testing.T) {
|
||||
dir, _ := filepath.Abs(t.TempDir())
|
||||
dir := t.TempDir()
|
||||
_, err := gitCommand(context.Background(), dir, "status")
|
||||
require.Error(t, err)
|
||||
|
||||
|
|
@ -261,14 +266,13 @@ func TestGitCommand_Bad_NotARepo(t *testing.T) {
|
|||
var gitErr *GitError
|
||||
if errors.As(err, &gitErr) {
|
||||
assert.Contains(t, gitErr.Stderr, "not a git repository")
|
||||
assert.Equal(t, []string{"status"}, gitErr.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// --- getStatus integration tests ---
|
||||
|
||||
func TestGetStatus_Good_CleanRepo(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
status := getStatus(context.Background(), dir, "test-repo")
|
||||
require.NoError(t, status.Error)
|
||||
|
|
@ -279,7 +283,7 @@ func TestGetStatus_Good_CleanRepo(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_ModifiedFile(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Modify the existing tracked file.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Modified\n"), 0644))
|
||||
|
|
@ -291,7 +295,7 @@ func TestGetStatus_Good_ModifiedFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_UntrackedFile(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Create a new untracked file.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "newfile.txt"), []byte("hello"), 0644))
|
||||
|
|
@ -303,7 +307,7 @@ func TestGetStatus_Good_UntrackedFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_StagedFile(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Create and stage a new file.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644))
|
||||
|
|
@ -318,7 +322,7 @@ func TestGetStatus_Good_StagedFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_MixedChanges(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Create untracked file.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644))
|
||||
|
|
@ -341,7 +345,7 @@ func TestGetStatus_Good_MixedChanges(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_DeletedTrackedFile(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Delete the tracked file (unstaged deletion).
|
||||
require.NoError(t, os.Remove(filepath.Join(dir, "README.md")))
|
||||
|
|
@ -353,7 +357,7 @@ func TestGetStatus_Good_DeletedTrackedFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetStatus_Good_StagedDeletion(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Stage a deletion.
|
||||
cmd := exec.Command("git", "rm", "README.md")
|
||||
|
|
@ -375,8 +379,8 @@ func TestGetStatus_Bad_InvalidPath(t *testing.T) {
|
|||
// --- Status (parallel multi-repo) tests ---
|
||||
|
||||
func TestStatus_Good_MultipleRepos(t *testing.T) {
|
||||
dir1, _ := filepath.Abs(initTestRepo(t))
|
||||
dir2, _ := filepath.Abs(initTestRepo(t))
|
||||
dir1 := initTestRepo(t)
|
||||
dir2 := initTestRepo(t)
|
||||
|
||||
// Make dir2 dirty.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir2, "extra.txt"), []byte("extra"), 0644))
|
||||
|
|
@ -408,7 +412,7 @@ func TestStatus_Good_EmptyPaths(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStatus_Good_NameFallback(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// No name mapping — path should be used as name.
|
||||
results := Status(context.Background(), StatusOptions{
|
||||
|
|
@ -421,8 +425,8 @@ func TestStatus_Good_NameFallback(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStatus_Good_WithErrors(t *testing.T) {
|
||||
validDir, _ := filepath.Abs(initTestRepo(t))
|
||||
invalidDir, _ := filepath.Abs("/nonexistent/path")
|
||||
validDir := initTestRepo(t)
|
||||
invalidDir := "/nonexistent/path"
|
||||
|
||||
results := Status(context.Background(), StatusOptions{
|
||||
Paths: []string{validDir, invalidDir},
|
||||
|
|
@ -441,12 +445,11 @@ func TestStatus_Good_WithErrors(t *testing.T) {
|
|||
|
||||
func TestPushMultiple_Good_NoRemote(t *testing.T) {
|
||||
// Push without a remote will fail but we can test the result structure.
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
results, err := PushMultiple(context.Background(), []string{dir}, map[string]string{
|
||||
results := PushMultiple(context.Background(), []string{dir}, map[string]string{
|
||||
dir: "test-repo",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "test-repo", results[0].Name)
|
||||
|
|
@ -457,10 +460,9 @@ func TestPushMultiple_Good_NoRemote(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPushMultiple_Good_NameFallback(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
results, err := PushMultiple(context.Background(), []string{dir}, map[string]string{})
|
||||
assert.Error(t, err)
|
||||
results := PushMultiple(context.Background(), []string{dir}, map[string]string{})
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, dir, results[0].Name, "name should fall back to path")
|
||||
|
|
@ -469,7 +471,7 @@ func TestPushMultiple_Good_NameFallback(t *testing.T) {
|
|||
// --- Pull tests ---
|
||||
|
||||
func TestPull_Bad_NoRemote(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
err := Pull(context.Background(), dir)
|
||||
assert.Error(t, err, "pull without remote should fail")
|
||||
}
|
||||
|
|
@ -477,7 +479,7 @@ func TestPull_Bad_NoRemote(t *testing.T) {
|
|||
// --- Push tests ---
|
||||
|
||||
func TestPush_Bad_NoRemote(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
err := Push(context.Background(), dir)
|
||||
assert.Error(t, err, "push without remote should fail")
|
||||
}
|
||||
|
|
@ -485,7 +487,7 @@ func TestPush_Bad_NoRemote(t *testing.T) {
|
|||
// --- Context cancellation test ---
|
||||
|
||||
func TestGetStatus_Good_ContextCancellation(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately.
|
||||
|
|
@ -499,8 +501,8 @@ func TestGetStatus_Good_ContextCancellation(t *testing.T) {
|
|||
|
||||
func TestGetAheadBehind_Good_WithUpstream(t *testing.T) {
|
||||
// Create a bare remote and a clone to test ahead/behind counts.
|
||||
bareDir, _ := filepath.Abs(t.TempDir())
|
||||
cloneDir, _ := filepath.Abs(t.TempDir())
|
||||
bareDir := t.TempDir()
|
||||
cloneDir := t.TempDir()
|
||||
|
||||
// Initialise the bare repo.
|
||||
cmd := exec.Command("git", "init", "--bare")
|
||||
|
|
@ -545,8 +547,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) {
|
|||
require.NoError(t, cmd.Run())
|
||||
}
|
||||
|
||||
ahead, behind, err := getAheadBehind(context.Background(), cloneDir)
|
||||
assert.NoError(t, err)
|
||||
ahead, behind := getAheadBehind(context.Background(), cloneDir)
|
||||
assert.Equal(t, 1, ahead, "should be 1 commit ahead")
|
||||
assert.Equal(t, 0, behind, "should not be behind")
|
||||
}
|
||||
|
|
@ -554,7 +555,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) {
|
|||
// --- Renamed file detection ---
|
||||
|
||||
func TestGetStatus_Good_RenamedFile(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
// Rename via git mv (stages the rename).
|
||||
cmd := exec.Command("git", "mv", "README.md", "GUIDE.md")
|
||||
|
|
|
|||
7
go.mod
7
go.mod
|
|
@ -2,13 +2,14 @@ module forge.lthn.ai/core/go-git
|
|||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -1,3 +1,5 @@
|
|||
forge.lthn.ai/core/go v0.0.1 h1:6DFABiGUccu3iQz2avpYbh0X24xccIsve6TSipziKT4=
|
||||
forge.lthn.ai/core/go v0.0.1/go.mod h1:vr4W9GMcyKbOJWmo22zQ9KmzLbdr2s17Q6LkVjpOeFU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
137
service.go
137
service.go
|
|
@ -2,14 +2,8 @@ package git
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
)
|
||||
|
||||
// Queries for git service
|
||||
|
|
@ -53,18 +47,15 @@ type ServiceOptions struct {
|
|||
|
||||
// Service provides git operations as a Core service.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[ServiceOptions]
|
||||
opts ServiceOptions
|
||||
mu sync.RWMutex
|
||||
*framework.ServiceRuntime[ServiceOptions]
|
||||
lastStatus []RepoStatus
|
||||
}
|
||||
|
||||
// NewService creates a git service factory.
|
||||
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
opts: opts,
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -76,24 +67,11 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
ctx := context.Background() // TODO: core should pass context to handlers
|
||||
|
||||
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryStatus:
|
||||
// Validate all paths before execution
|
||||
for _, path := range m.Paths {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
}
|
||||
|
||||
statuses := Status(ctx, StatusOptions(m))
|
||||
|
||||
s.mu.Lock()
|
||||
statuses := Status(context.Background(), StatusOptions(m))
|
||||
s.lastStatus = statuses
|
||||
s.mu.Unlock()
|
||||
|
||||
return statuses, true, nil
|
||||
|
||||
case QueryDirtyRepos:
|
||||
|
|
@ -105,105 +83,44 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
ctx := context.Background() // TODO: core should pass context to handlers
|
||||
|
||||
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case TaskPush:
|
||||
if err := s.validatePath(m.Path); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
err := Push(ctx, m.Path)
|
||||
err := Push(context.Background(), m.Path)
|
||||
return nil, true, err
|
||||
|
||||
case TaskPull:
|
||||
if err := s.validatePath(m.Path); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
err := Pull(ctx, m.Path)
|
||||
err := Pull(context.Background(), m.Path)
|
||||
return nil, true, err
|
||||
|
||||
case TaskPushMultiple:
|
||||
for _, path := range m.Paths {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
}
|
||||
results, err := PushMultiple(ctx, m.Paths, m.Names)
|
||||
return results, true, err
|
||||
results := PushMultiple(context.Background(), m.Paths, m.Names)
|
||||
return results, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) validatePath(path string) error {
|
||||
if !filepath.IsAbs(path) {
|
||||
return fmt.Errorf("path must be absolute: %s", path)
|
||||
}
|
||||
|
||||
workDir := s.opts.WorkDir
|
||||
if workDir != "" {
|
||||
rel, err := filepath.Rel(workDir, path)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("path %s is outside of allowed WorkDir %s", path, workDir)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns last status result.
|
||||
func (s *Service) Status() []RepoStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return slices.Clone(s.lastStatus)
|
||||
}
|
||||
|
||||
// All returns an iterator over all last known statuses.
|
||||
func (s *Service) All() iter.Seq[RepoStatus] {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return slices.Values(slices.Clone(s.lastStatus))
|
||||
}
|
||||
|
||||
// Dirty returns an iterator over repos with uncommitted changes.
|
||||
func (s *Service) Dirty() iter.Seq[RepoStatus] {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
lastStatus := slices.Clone(s.lastStatus)
|
||||
|
||||
return func(yield func(RepoStatus) bool) {
|
||||
for _, st := range lastStatus {
|
||||
if st.Error == nil && st.IsDirty() {
|
||||
if !yield(st) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead returns an iterator over repos with unpushed commits.
|
||||
func (s *Service) Ahead() iter.Seq[RepoStatus] {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
lastStatus := slices.Clone(s.lastStatus)
|
||||
|
||||
return func(yield func(RepoStatus) bool) {
|
||||
for _, st := range lastStatus {
|
||||
if st.Error == nil && st.HasUnpushed() {
|
||||
if !yield(st) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *Service) Status() []RepoStatus { return s.lastStatus }
|
||||
|
||||
// DirtyRepos returns repos with uncommitted changes.
|
||||
func (s *Service) DirtyRepos() []RepoStatus {
|
||||
return slices.Collect(s.Dirty())
|
||||
var dirty []RepoStatus
|
||||
for _, st := range s.lastStatus {
|
||||
if st.Error == nil && st.IsDirty() {
|
||||
dirty = append(dirty, st)
|
||||
}
|
||||
}
|
||||
return dirty
|
||||
}
|
||||
|
||||
// AheadRepos returns repos with unpushed commits.
|
||||
func (s *Service) AheadRepos() []RepoStatus {
|
||||
return slices.Collect(s.Ahead())
|
||||
var ahead []RepoStatus
|
||||
for _, st := range s.lastStatus {
|
||||
if st.Error == nil && st.HasUnpushed() {
|
||||
ahead = append(ahead, st)
|
||||
}
|
||||
}
|
||||
return ahead
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
)
|
||||
|
||||
func TestNewService_Good(t *testing.T) {
|
||||
opts := ServiceOptions{WorkDir: t.TempDir()}
|
||||
opts := ServiceOptions{WorkDir: "/tmp/test"}
|
||||
factory := NewService(opts)
|
||||
assert.NotNil(t, factory)
|
||||
|
||||
// Create a minimal Core to test the factory.
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := factory(c)
|
||||
|
|
@ -32,13 +32,12 @@ func TestNewService_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := ServiceOptions{WorkDir: t.TempDir()}
|
||||
opts := ServiceOptions{WorkDir: "/tmp"}
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
opts: opts,
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
}
|
||||
|
||||
err = svc.OnStartup(context.Background())
|
||||
|
|
@ -46,13 +45,13 @@ func TestService_OnStartup_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleQuery_Good_Status(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
// Call handleQuery directly.
|
||||
|
|
@ -74,11 +73,11 @@ func TestService_HandleQuery_Good_Status(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleQuery_Good_DirtyRepos(t *testing.T) {
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
lastStatus: []RepoStatus{
|
||||
{Name: "clean"},
|
||||
{Name: "dirty", Modified: 1},
|
||||
|
|
@ -96,11 +95,11 @@ func TestService_HandleQuery_Good_DirtyRepos(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleQuery_Good_AheadRepos(t *testing.T) {
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
lastStatus: []RepoStatus{
|
||||
{Name: "synced"},
|
||||
{Name: "ahead", Ahead: 3},
|
||||
|
|
@ -118,11 +117,11 @@ func TestService_HandleQuery_Good_AheadRepos(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) {
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
result, handled, err := svc.handleQuery(c, "unknown query type")
|
||||
|
|
@ -132,13 +131,13 @@ func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleTask_Good_Push(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
// Push without a remote will fail, but handleTask should still handle it.
|
||||
|
|
@ -148,13 +147,13 @@ func TestService_HandleTask_Good_Push(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleTask_Good_Pull(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
_, handled, err := svc.handleTask(c, TaskPull{Path: dir, Name: "test"})
|
||||
|
|
@ -163,13 +162,13 @@ func TestService_HandleTask_Good_Pull(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleTask_Good_PushMultiple(t *testing.T) {
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
result, handled, err := svc.handleTask(c, TaskPushMultiple{
|
||||
|
|
@ -178,7 +177,7 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) {
|
|||
})
|
||||
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err) // PushMultiple returns errors directly
|
||||
assert.NoError(t, err) // PushMultiple does not return errors directly
|
||||
|
||||
results, ok := result.([]PushResult)
|
||||
require.True(t, ok)
|
||||
|
|
@ -187,11 +186,11 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestService_HandleTask_Good_UnknownTask(t *testing.T) {
|
||||
c, err := core.New()
|
||||
c, err := framework.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}),
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, ServiceOptions{}),
|
||||
}
|
||||
|
||||
result, handled, err := svc.handleTask(c, "unknown task")
|
||||
|
|
@ -204,7 +203,7 @@ func TestService_HandleTask_Good_UnknownTask(t *testing.T) {
|
|||
|
||||
func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) {
|
||||
// A repo without a tracking branch should return 0 ahead/behind.
|
||||
dir, _ := filepath.Abs(initTestRepo(t))
|
||||
dir := initTestRepo(t)
|
||||
|
||||
status := getStatus(context.Background(), dir, "no-upstream")
|
||||
require.NoError(t, status.Error)
|
||||
|
|
@ -213,20 +212,18 @@ func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPushMultiple_Good_Empty(t *testing.T) {
|
||||
results, err := PushMultiple(context.Background(), []string{}, map[string]string{})
|
||||
assert.NoError(t, err)
|
||||
results := PushMultiple(context.Background(), []string{}, map[string]string{})
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestPushMultiple_Good_MultiplePaths(t *testing.T) {
|
||||
dir1, _ := filepath.Abs(initTestRepo(t))
|
||||
dir2, _ := filepath.Abs(initTestRepo(t))
|
||||
dir1 := initTestRepo(t)
|
||||
dir2 := initTestRepo(t)
|
||||
|
||||
results, err := PushMultiple(context.Background(), []string{dir1, dir2}, map[string]string{
|
||||
results := PushMultiple(context.Background(), []string{dir1, dir2}, map[string]string{
|
||||
dir1: "repo-1",
|
||||
dir2: "repo-2",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "repo-1", results[0].Name)
|
||||
|
|
@ -238,8 +235,8 @@ func TestPushMultiple_Good_MultiplePaths(t *testing.T) {
|
|||
|
||||
func TestPush_Good_WithRemote(t *testing.T) {
|
||||
// Create a bare remote and a clone.
|
||||
bareDir, _ := filepath.Abs(t.TempDir())
|
||||
cloneDir, _ := filepath.Abs(t.TempDir())
|
||||
bareDir := t.TempDir()
|
||||
cloneDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command("git", "init", "--bare")
|
||||
cmd.Dir = bareDir
|
||||
|
|
@ -285,8 +282,6 @@ func TestPush_Good_WithRemote(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
// Verify ahead count is now 0.
|
||||
ahead, behind, err := getAheadBehind(context.Background(), cloneDir)
|
||||
assert.NoError(t, err)
|
||||
ahead, _ := getAheadBehind(context.Background(), cloneDir)
|
||||
assert.Equal(t, 0, ahead)
|
||||
assert.Equal(t, 0, behind)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -24,13 +23,10 @@ func TestService_DirtyRepos_Good(t *testing.T) {
|
|||
dirty := s.DirtyRepos()
|
||||
assert.Len(t, dirty, 3)
|
||||
|
||||
names := slices.Collect(func(yield func(string) bool) {
|
||||
for _, d := range dirty {
|
||||
if !yield(d.Name) {
|
||||
return
|
||||
names := make([]string, len(dirty))
|
||||
for i, d := range dirty {
|
||||
names[i] = d.Name
|
||||
}
|
||||
}
|
||||
})
|
||||
assert.Contains(t, names, "dirty-modified")
|
||||
assert.Contains(t, names, "dirty-untracked")
|
||||
assert.Contains(t, names, "dirty-staged")
|
||||
|
|
@ -68,13 +64,10 @@ func TestService_AheadRepos_Good(t *testing.T) {
|
|||
ahead := s.AheadRepos()
|
||||
assert.Len(t, ahead, 2)
|
||||
|
||||
names := slices.Collect(func(yield func(string) bool) {
|
||||
for _, a := range ahead {
|
||||
if !yield(a.Name) {
|
||||
return
|
||||
names := make([]string, len(ahead))
|
||||
for i, a := range ahead {
|
||||
names[i] = a.Name
|
||||
}
|
||||
}
|
||||
})
|
||||
assert.Contains(t, names, "ahead-by-one")
|
||||
assert.Contains(t, names, "ahead-by-five")
|
||||
}
|
||||
|
|
@ -97,30 +90,6 @@ func TestService_AheadRepos_Good_EmptyStatus(t *testing.T) {
|
|||
assert.Empty(t, ahead)
|
||||
}
|
||||
|
||||
func TestService_Iterators_Good(t *testing.T) {
|
||||
s := &Service{
|
||||
lastStatus: []RepoStatus{
|
||||
{Name: "clean"},
|
||||
{Name: "dirty", Modified: 1},
|
||||
{Name: "ahead", Ahead: 2},
|
||||
},
|
||||
}
|
||||
|
||||
// Test All()
|
||||
all := slices.Collect(s.All())
|
||||
assert.Len(t, all, 3)
|
||||
|
||||
// Test Dirty()
|
||||
dirty := slices.Collect(s.Dirty())
|
||||
assert.Len(t, dirty, 1)
|
||||
assert.Equal(t, "dirty", dirty[0].Name)
|
||||
|
||||
// Test Ahead()
|
||||
ahead := slices.Collect(s.Ahead())
|
||||
assert.Len(t, ahead, 1)
|
||||
assert.Equal(t, "ahead", ahead[0].Name)
|
||||
}
|
||||
|
||||
func TestService_Status_Good(t *testing.T) {
|
||||
expected := []RepoStatus{
|
||||
{Name: "repo1", Branch: "main"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue