Compare commits

..

4 commits
v0.0.1 ... main

Author SHA1 Message Date
Snider
616c181efc chore: bump forge.lthn.ai dep versions to latest tags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-26 05:34:22 +00:00
Snider
22969d0ae2 chore: bump forge.lthn.ai dep versions to latest tags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:49:41 +00:00
Snider
8d3c5f0100 chore: add Go repo norms (badges, contributing, lint, taskfile, editorconfig)
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:45:35 +00:00
Snider
bf8e37d522 feat: modernise to Go 1.26 — iterators and slices stdlib
- Add All(), Dirty(), Ahead() iter.Seq[RepoStatus] iterators
- Reimplement DirtyRepos/AheadRepos via slices.Collect
- Use slices.Contains for git status character matching
- Refresh go.sum

Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 05:06:48 +00:00
10 changed files with 206 additions and 27 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
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

22
.golangci.yml Normal file
View file

@ -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

35
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,35 @@
# 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 Normal file
View file

@ -0,0 +1,11 @@
[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-git.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-git)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md)
[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod)
# go-git
Go module: `forge.lthn.ai/core/go-git`
## License
[EUPL-1.2](LICENSE.md)

46
Taskfile.yml Normal file
View file

@ -0,0 +1,46 @@
version: '3'
tasks:
test:
desc: Run all tests
cmds:
- go test ./...
lint:
desc: Run golangci-lint
cmds:
- golangci-lint run ./...
fmt:
desc: Format all Go files
cmds:
- gofmt -w .
vet:
desc: Run go vet
cmds:
- go vet ./...
build:
desc: Build all Go packages
cmds:
- go build ./...
cov:
desc: Run tests with coverage and open HTML report
cmds:
- go test -coverprofile=coverage.out ./...
- go tool cover -html=coverage.out
tidy:
desc: Tidy go.mod
cmds:
- go mod tidy
check:
desc: Run fmt, vet, lint, and test in sequence
cmds:
- task: fmt
- task: vet
- task: lint
- task: test

5
git.go
View file

@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -105,12 +106,12 @@ func getStatus(ctx context.Context, path, name string) RepoStatus {
} }
// Staged (index has changes) // Staged (index has changes)
if x == 'A' || x == 'D' || x == 'R' || x == 'M' { if slices.Contains([]byte{'A', 'D', 'R', 'M'}, x) {
status.Staged++ status.Staged++
} }
// Modified in working tree // Modified in working tree
if y == 'M' || y == 'D' { if slices.Contains([]byte{'M', 'D'}, y) {
status.Modified++ status.Modified++
} }
} }

2
go.mod
View file

@ -3,7 +3,7 @@ module forge.lthn.ai/core/go-git
go 1.26.0 go 1.26.0
require ( require (
forge.lthn.ai/core/go v0.0.1 forge.lthn.ai/core/go v0.1.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )

4
go.sum
View file

@ -1,5 +1,5 @@
forge.lthn.ai/core/go v0.0.1 h1:6DFABiGUccu3iQz2avpYbh0X24xccIsve6TSipziKT4= forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
forge.lthn.ai/core/go v0.0.1/go.mod h1:vr4W9GMcyKbOJWmo22zQ9KmzLbdr2s17Q6LkVjpOeFU= forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -2,6 +2,8 @@ package git
import ( import (
"context" "context"
"iter"
"slices"
"forge.lthn.ai/core/go/pkg/framework" "forge.lthn.ai/core/go/pkg/framework"
) )
@ -103,24 +105,43 @@ func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, er
// Status returns last status result. // Status returns last status result.
func (s *Service) Status() []RepoStatus { return s.lastStatus } func (s *Service) Status() []RepoStatus { return s.lastStatus }
// DirtyRepos returns repos with uncommitted changes. // All returns an iterator over all last known statuses.
func (s *Service) DirtyRepos() []RepoStatus { func (s *Service) All() iter.Seq[RepoStatus] {
var dirty []RepoStatus return slices.Values(s.lastStatus)
for _, st := range s.lastStatus { }
if st.Error == nil && st.IsDirty() {
dirty = append(dirty, st) // Dirty returns an iterator over repos with uncommitted changes.
func (s *Service) Dirty() iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
for _, st := range s.lastStatus {
if st.Error == nil && st.IsDirty() {
if !yield(st) {
return
}
}
} }
} }
return dirty }
// Ahead returns an iterator over repos with unpushed commits.
func (s *Service) Ahead() iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
for _, st := range s.lastStatus {
if st.Error == nil && st.HasUnpushed() {
if !yield(st) {
return
}
}
}
}
}
// DirtyRepos returns repos with uncommitted changes.
func (s *Service) DirtyRepos() []RepoStatus {
return slices.Collect(s.Dirty())
} }
// AheadRepos returns repos with unpushed commits. // AheadRepos returns repos with unpushed commits.
func (s *Service) AheadRepos() []RepoStatus { func (s *Service) AheadRepos() []RepoStatus {
var ahead []RepoStatus return slices.Collect(s.Ahead())
for _, st := range s.lastStatus {
if st.Error == nil && st.HasUnpushed() {
ahead = append(ahead, st)
}
}
return ahead
} }

View file

@ -1,6 +1,7 @@
package git package git
import ( import (
"slices"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -23,10 +24,13 @@ func TestService_DirtyRepos_Good(t *testing.T) {
dirty := s.DirtyRepos() dirty := s.DirtyRepos()
assert.Len(t, dirty, 3) assert.Len(t, dirty, 3)
names := make([]string, len(dirty)) names := slices.Collect(func(yield func(string) bool) {
for i, d := range dirty { for _, d := range dirty {
names[i] = d.Name if !yield(d.Name) {
} return
}
}
})
assert.Contains(t, names, "dirty-modified") assert.Contains(t, names, "dirty-modified")
assert.Contains(t, names, "dirty-untracked") assert.Contains(t, names, "dirty-untracked")
assert.Contains(t, names, "dirty-staged") assert.Contains(t, names, "dirty-staged")
@ -64,10 +68,13 @@ func TestService_AheadRepos_Good(t *testing.T) {
ahead := s.AheadRepos() ahead := s.AheadRepos()
assert.Len(t, ahead, 2) assert.Len(t, ahead, 2)
names := make([]string, len(ahead)) names := slices.Collect(func(yield func(string) bool) {
for i, a := range ahead { for _, a := range ahead {
names[i] = a.Name if !yield(a.Name) {
} return
}
}
})
assert.Contains(t, names, "ahead-by-one") assert.Contains(t, names, "ahead-by-one")
assert.Contains(t, names, "ahead-by-five") assert.Contains(t, names, "ahead-by-five")
} }
@ -90,6 +97,30 @@ func TestService_AheadRepos_Good_EmptyStatus(t *testing.T) {
assert.Empty(t, ahead) 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) { func TestService_Status_Good(t *testing.T) {
expected := []RepoStatus{ expected := []RepoStatus{
{Name: "repo1", Branch: "main"}, {Name: "repo1", Branch: "main"},