From 4f60fc74543e04e499d5b139671a4b9212b20f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 02:35:29 +0000 Subject: [PATCH 01/26] chore: update dependencies to dappco.re tagged versions Migrate forge.lthn.ai/core/go-log to dappco.re/go/core/log v0.1.0 and bump dappco.re/go/core from v0.4.7 to v0.5.0. Remove coreerr dependency from git.go (stdlib-only by design). Extract filterStatuses helper in service.go to deduplicate Dirty/Ahead iterators. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 (1M context) --- git.go | 4 +--- go.mod | 4 ++-- go.sum | 8 ++++---- service.go | 27 ++++++++++----------------- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/git.go b/git.go index 2865ede..37ea134 100644 --- a/git.go +++ b/git.go @@ -13,8 +13,6 @@ import ( "strconv" "strings" "sync" - - coreerr "forge.lthn.ai/core/go-log" ) // RepoStatus represents the git status of a single repository. @@ -83,7 +81,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { // Validate path to prevent directory traversal if !filepath.IsAbs(path) { - status.Error = coreerr.E("git.getStatus", "path must be absolute: "+path, nil) + status.Error = fmt.Errorf("git.getStatus: path must be absolute: %s", path) return status } diff --git a/go.mod b/go.mod index 5d52bdd..d2d4c88 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module forge.lthn.ai/core/go-git go 1.26.0 require ( - dappco.re/go/core v0.4.7 - forge.lthn.ai/core/go-log v0.0.4 + dappco.re/go/core v0.5.0 + dappco.re/go/core/log v0.1.0 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index 32c259a..fa7571a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= -dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= +dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/service.go b/service.go index 7b2a68c..6423912 100644 --- a/service.go +++ b/service.go @@ -9,7 +9,7 @@ import ( "sync" "dappco.re/go/core" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) // Queries for git service @@ -175,15 +175,15 @@ func (s *Service) All() iter.Seq[RepoStatus] { return slices.Values(slices.Clone(s.lastStatus)) } -// Dirty returns an iterator over repos with uncommitted changes. -func (s *Service) Dirty() iter.Seq[RepoStatus] { +// filterStatuses returns an iterator over statuses matching pred, with no error. +func (s *Service) filterStatuses(pred func(RepoStatus) bool) 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 st.Error == nil && pred(st) { if !yield(st) { return } @@ -192,21 +192,14 @@ func (s *Service) Dirty() iter.Seq[RepoStatus] { } } +// Dirty returns an iterator over repos with uncommitted changes. +func (s *Service) Dirty() iter.Seq[RepoStatus] { + return s.filterStatuses(func(st RepoStatus) bool { return st.IsDirty() }) +} + // Ahead returns an iterator over repos with unpushed commits. func (s *Service) Ahead() iter.Seq[RepoStatus] { - s.mu.RLock() - defer s.mu.RUnlock() - lastStatus := slices.Clone(s.lastStatus) - - return func(yield func(RepoStatus) bool) { - for _, st := range lastStatus { - if st.Error == nil && st.HasUnpushed() { - if !yield(st) { - return - } - } - } - } + return s.filterStatuses(func(st RepoStatus) bool { return st.HasUnpushed() }) } // DirtyRepos returns repos with uncommitted changes. From b280022a4c8a9bd5c03df2911cbc8f63e0728cff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 02:53:42 +0000 Subject: [PATCH 02/26] chore: update dependencies to dappco.re tagged versions Co-Authored-By: Virgil --- git.go | 21 ++++++++++----------- service.go | 12 ++++++------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/git.go b/git.go index 37ea134..966cc6a 100644 --- a/git.go +++ b/git.go @@ -136,31 +136,30 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { return status } +// isNoUpstreamError reports whether an error is due to a missing tracking branch. +func isNoUpstreamError(err error) bool { + msg := err.Error() + return strings.Contains(msg, "no upstream") || strings.Contains(msg, "No upstream") +} + // getAheadBehind returns the number of commits ahead and behind upstream. func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { - // Try to get ahead count 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 - } + } else if isNoUpstreamError(err) { + err = nil } if err != nil { return 0, 0, err } - // Try to get behind count behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") if err == nil { behind, _ = strconv.Atoi(strings.TrimSpace(behindStr)) - } else { - if strings.Contains(err.Error(), "no upstream") || strings.Contains(err.Error(), "No upstream") { - err = nil - } + } else if isNoUpstreamError(err) { + err = nil } return ahead, behind, err diff --git a/service.go b/service.go index 6423912..1546156 100644 --- a/service.go +++ b/service.go @@ -175,14 +175,14 @@ func (s *Service) All() iter.Seq[RepoStatus] { return slices.Values(slices.Clone(s.lastStatus)) } -// filterStatuses returns an iterator over statuses matching pred, with no error. -func (s *Service) filterStatuses(pred func(RepoStatus) bool) iter.Seq[RepoStatus] { +// filteredIter returns an iterator over status entries that satisfy pred. +func (s *Service) filteredIter(pred func(RepoStatus) bool) iter.Seq[RepoStatus] { s.mu.RLock() defer s.mu.RUnlock() - lastStatus := slices.Clone(s.lastStatus) + snapshot := slices.Clone(s.lastStatus) return func(yield func(RepoStatus) bool) { - for _, st := range lastStatus { + for _, st := range snapshot { if st.Error == nil && pred(st) { if !yield(st) { return @@ -194,12 +194,12 @@ func (s *Service) filterStatuses(pred func(RepoStatus) bool) iter.Seq[RepoStatus // Dirty returns an iterator over repos with uncommitted changes. func (s *Service) Dirty() iter.Seq[RepoStatus] { - return s.filterStatuses(func(st RepoStatus) bool { return st.IsDirty() }) + return s.filteredIter(func(st RepoStatus) bool { return st.IsDirty() }) } // Ahead returns an iterator over repos with unpushed commits. func (s *Service) Ahead() iter.Seq[RepoStatus] { - return s.filterStatuses(func(st RepoStatus) bool { return st.HasUnpushed() }) + return s.filteredIter(func(st RepoStatus) bool { return st.HasUnpushed() }) } // DirtyRepos returns repos with uncommitted changes. From 1e0303394274e5f9384a172aeb938b46d76ff7f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 13:33:09 +0000 Subject: [PATCH 03/26] feat: upgrade to core v0.8.0-alpha.1, migrate to Action-based API Migrate from RegisterTask to named Actions (git.push, git.pull, git.push-multiple). OnStartup now returns core.Result. Replace banned stdlib imports with Core primitives. Co-Authored-By: Claude Opus 4.6 (1M context) --- git.go | 33 ++++++++-------- git_test.go | 27 +++++++------ go.mod | 2 +- go.sum | 4 +- service.go | 92 +++++++++++++++++++++---------------------- service_extra_test.go | 74 ++++++++++++++++++---------------- 6 files changed, 119 insertions(+), 113 deletions(-) diff --git a/git.go b/git.go index 966cc6a..16a67ac 100644 --- a/git.go +++ b/git.go @@ -4,15 +4,16 @@ package git import ( "bytes" "context" - "fmt" goio "io" "os" "os/exec" - "path/filepath" "slices" "strconv" "strings" "sync" + + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // RepoStatus represents the git status of a single repository. @@ -80,8 +81,8 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Validate path to prevent directory traversal - if !filepath.IsAbs(path) { - status.Error = fmt.Errorf("git.getStatus: path must be absolute: %s", path) + if !core.PathIsAbs(path) { + status.Error = coreerr.E("git.getStatus", "path must be absolute: "+path, nil) return status } @@ -91,7 +92,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { status.Error = err return status } - status.Branch = strings.TrimSpace(branch) + status.Branch = core.Trim(branch) // Get porcelain status porcelain, err := gitCommand(ctx, path, "status", "--porcelain") @@ -139,14 +140,14 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { // isNoUpstreamError reports whether an error is due to a missing tracking branch. func isNoUpstreamError(err error) bool { msg := err.Error() - return strings.Contains(msg, "no upstream") || strings.Contains(msg, "No upstream") + return core.Contains(msg, "no upstream") || core.Contains(msg, "No upstream") } // getAheadBehind returns the number of commits ahead and behind upstream. func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") if err == nil { - ahead, _ = strconv.Atoi(strings.TrimSpace(aheadStr)) + ahead, _ = strconv.Atoi(core.Trim(aheadStr)) } else if isNoUpstreamError(err) { err = nil } @@ -157,7 +158,7 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") if err == nil { - behind, _ = strconv.Atoi(strings.TrimSpace(behindStr)) + behind, _ = strconv.Atoi(core.Trim(behindStr)) } else if isNoUpstreamError(err) { err = nil } @@ -183,9 +184,9 @@ func IsNonFastForward(err error) bool { return false } msg := err.Error() - return strings.Contains(msg, "non-fast-forward") || - strings.Contains(msg, "fetch first") || - strings.Contains(msg, "tip of your current branch is behind") + 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. @@ -283,16 +284,16 @@ type GitError struct { // Error returns a descriptive error message. func (e *GitError) Error() string { - cmd := "git " + strings.Join(e.Args, " ") - stderr := strings.TrimSpace(e.Stderr) + cmd := "git " + core.Join(" ", e.Args...) + stderr := core.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) + return core.Sprintf("git command %q failed", cmd) } // Unwrap returns the underlying error for error chain inspection. diff --git a/git_test.go b/git_test.go index 5c7d972..94683d7 100644 --- a/git_test.go +++ b/git_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +32,7 @@ func initTestRepo(t *testing.T) string { } // Create a file and commit it so HEAD exists. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Test\n"), 0644)) cmds = [][]string{ {"git", "add", "README.md"}, @@ -190,7 +191,7 @@ func TestGitError_Unwrap(t *testing.T) { inner := errors.New("underlying error") gitErr := &GitError{Err: inner, Stderr: "stderr output"} assert.Equal(t, inner, gitErr.Unwrap()) - assert.True(t, errors.Is(gitErr, inner)) + assert.True(t, core.Is(gitErr, inner)) } // --- IsNonFastForward tests --- @@ -259,7 +260,7 @@ func TestGitCommand_Bad_NotARepo(t *testing.T) { // Should be a GitError with stderr. var gitErr *GitError - if errors.As(err, &gitErr) { + if core.As(err, &gitErr) { assert.Contains(t, gitErr.Stderr, "not a git repository") assert.Equal(t, []string{"status"}, gitErr.Args) } @@ -282,7 +283,7 @@ func TestGetStatus_Good_ModifiedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Modify the existing tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Modified\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Modified\n"), 0644)) status := getStatus(context.Background(), dir, "modified-repo") require.NoError(t, status.Error) @@ -294,7 +295,7 @@ func TestGetStatus_Good_UntrackedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create a new untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "newfile.txt"), []byte("hello"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "newfile.txt"), []byte("hello"), 0644)) status := getStatus(context.Background(), dir, "untracked-repo") require.NoError(t, status.Error) @@ -306,7 +307,7 @@ func TestGetStatus_Good_StagedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create and stage a new file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "staged.txt"), []byte("staged"), 0644)) cmd := exec.Command("git", "add", "staged.txt") cmd.Dir = dir require.NoError(t, cmd.Run()) @@ -321,13 +322,13 @@ func TestGetStatus_Good_MixedChanges(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Create untracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "untracked.txt"), []byte("new"), 0644)) // Modify tracked file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Changed\n"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Changed\n"), 0644)) // Create and stage another file. - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir, "staged.txt"), []byte("staged"), 0644)) cmd := exec.Command("git", "add", "staged.txt") cmd.Dir = dir require.NoError(t, cmd.Run()) @@ -344,7 +345,7 @@ func TestGetStatus_Good_DeletedTrackedFile(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) // Delete the tracked file (unstaged deletion). - require.NoError(t, os.Remove(filepath.Join(dir, "README.md"))) + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) status := getStatus(context.Background(), dir, "deleted-repo") require.NoError(t, status.Error) @@ -386,7 +387,7 @@ func TestStatus_Good_MultipleRepos(t *testing.T) { dir2, _ := filepath.Abs(initTestRepo(t)) // Make dir2 dirty. - require.NoError(t, os.WriteFile(filepath.Join(dir2, "extra.txt"), []byte("extra"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(dir2, "extra.txt"), []byte("extra"), 0644)) results := Status(context.Background(), StatusOptions{ Paths: []string{dir1, dir2}, @@ -529,7 +530,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) { } // Create initial commit and push. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "initial"}, @@ -542,7 +543,7 @@ func TestGetAheadBehind_Good_WithUpstream(t *testing.T) { } // Make a local commit without pushing (ahead by 1). - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "local commit"}, diff --git a/go.mod b/go.mod index d2d4c88..b1cd093 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module forge.lthn.ai/core/go-git go 1.26.0 require ( - dappco.re/go/core v0.5.0 + dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/log v0.1.0 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index fa7571a..872f5dc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= -dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/service.go b/service.go index 1546156..e33639b 100644 --- a/service.go +++ b/service.go @@ -3,9 +3,7 @@ package git import ( "context" "iter" - "path/filepath" "slices" - "strings" "sync" "dappco.re/go/core" @@ -72,11 +70,50 @@ func NewService(opts ServiceOptions) func(*core.Core) (any, error) { } } -// OnStartup registers query and task handlers. -func (s *Service) OnStartup(ctx context.Context) error { +// OnStartup registers query and action handlers. +func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil + + s.Core().Action("git.push", func(ctx context.Context, opts core.Options) core.Result { + path := opts.String("path") + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.push", "path validation failed") + } + if err := Push(ctx, path); err != nil { + return s.Core().LogError(err, "git.push", "push failed") + } + return core.Result{OK: true} + }) + + s.Core().Action("git.pull", func(ctx context.Context, opts core.Options) core.Result { + path := opts.String("path") + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.pull", "path validation failed") + } + if err := Pull(ctx, path); err != nil { + return s.Core().LogError(err, "git.pull", "pull failed") + } + return core.Result{OK: true} + }) + + s.Core().Action("git.push-multiple", func(ctx context.Context, opts core.Options) core.Result { + r := opts.Get("paths") + paths, _ := r.Value.([]string) + r = opts.Get("names") + names, _ := r.Value.(map[string]string) + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.push-multiple", "path validation failed") + } + } + results, err := PushMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.push-multiple", "push multiple had failures") + } + return core.Result{Value: results, OK: true} + }) + + return core.Result{OK: true} } func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { @@ -108,53 +145,14 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { return core.Result{} } -func (s *Service) handleTask(c *core.Core, t core.Task) core.Result { - ctx := context.Background() // TODO: core should pass context to handlers - - switch m := t.(type) { - case TaskPush: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Push(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "push failed") - } - return core.Result{OK: true} - - case TaskPull: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Pull(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "pull failed") - } - return core.Result{OK: true} - - case TaskPushMultiple: - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - } - results, err := PushMultiple(ctx, m.Paths, m.Names) - if err != nil { - // Log for observability; partial results are still returned. - _ = c.LogError(err, "git.handleTask", "push multiple had failures") - } - return core.Result{Value: results, OK: true} - } - return core.Result{} -} - func (s *Service) validatePath(path string) error { - if !filepath.IsAbs(path) { + if !core.PathIsAbs(path) { return coreerr.E("git.validatePath", "path must be absolute: "+path, nil) } workDir := s.opts.WorkDir if workDir != "" { - rel, err := filepath.Rel(workDir, path) - if err != nil || strings.HasPrefix(rel, "..") { + if !core.HasPrefix(path, workDir) { return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) } } diff --git a/service_extra_test.go b/service_extra_test.go index 3d6ddf3..7fc7698 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -60,42 +60,52 @@ func TestService_HandleQuery_Bad_InvalidPath(t *testing.T) { // --- handleTask path validation --- -func TestService_HandleTask_Bad_PushInvalidPath(t *testing.T) { +func TestService_Action_Bad_PushInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPush{Path: "relative/path"}) + result := c.Action("git.push").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: "relative/path"}, + )) + _ = svc assert.False(t, result.OK) } -func TestService_HandleTask_Bad_PullInvalidPath(t *testing.T) { +func TestService_Action_Bad_PullInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPull{Path: "/etc/passwd"}) + result := c.Action("git.pull").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: "/etc/passwd"}, + )) + _ = svc assert.False(t, result.OK) } -func TestService_HandleTask_Bad_PushMultipleInvalidPath(t *testing.T) { +func TestService_Action_Bad_PushMultipleInvalidPath(t *testing.T) { c := core.New() svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), opts: ServiceOptions{WorkDir: "/home/repos"}, } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPushMultiple{ - Paths: []string{"/home/repos/ok", "/etc/bad"}, - Names: map[string]string{}, - }) + opts := core.NewOptions() + opts.Set("paths", []string{"/home/repos/ok", "/etc/bad"}) + opts.Set("names", map[string]string{}) + result := c.Action("git.push-multiple").Run(context.Background(), opts) + _ = svc assert.False(t, result.OK) } @@ -125,8 +135,8 @@ func TestService_OnStartup_Good(t *testing.T) { opts: opts, } - err := svc.OnStartup(context.Background()) - assert.NoError(t, err) + result := svc.OnStartup(context.Background()) + assert.True(t, result.OK) } func TestService_HandleQuery_Good_Status(t *testing.T) { @@ -207,7 +217,7 @@ func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { assert.Nil(t, result.Value) } -func TestService_HandleTask_Bad_PushNoRemote(t *testing.T) { +func TestService_Action_Bad_PushNoRemote(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) c := core.New() @@ -215,12 +225,15 @@ func TestService_HandleTask_Bad_PushNoRemote(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPush{Path: dir, Name: "test"}) + result := c.Action("git.push").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: dir}, + )) assert.False(t, result.OK, "push without remote should fail") } -func TestService_HandleTask_Bad_PullNoRemote(t *testing.T) { +func TestService_Action_Bad_PullNoRemote(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) c := core.New() @@ -228,12 +241,15 @@ func TestService_HandleTask_Bad_PullNoRemote(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPull{Path: dir, Name: "test"}) + result := c.Action("git.pull").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: dir}, + )) assert.False(t, result.OK, "pull without remote should fail") } -func TestService_HandleTask_Good_PushMultiple(t *testing.T) { +func TestService_Action_Good_PushMultiple(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) c := core.New() @@ -241,11 +257,13 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } + svc.OnStartup(context.Background()) - result := svc.handleTask(c, TaskPushMultiple{ - Paths: []string{dir}, - Names: map[string]string{dir: "test"}, - }) + opts := core.NewOptions() + opts.Set("paths", []string{dir}) + opts.Set("names", map[string]string{dir: "test"}) + result := c.Action("git.push-multiple").Run(context.Background(), opts) + _ = svc // PushMultiple returns results even when individual pushes fail. assert.True(t, result.OK) @@ -256,18 +274,6 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) { assert.False(t, results[0].Success) // No remote } -func TestService_HandleTask_Good_UnknownTask(t *testing.T) { - c := core.New() - - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - - result := svc.handleTask(c, "unknown task") - assert.False(t, result.OK) - assert.Nil(t, result.Value) -} - // --- Additional git operation tests --- func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) { @@ -325,7 +331,7 @@ func TestPush_Good_WithRemote(t *testing.T) { require.NoError(t, cmd.Run()) } - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v1"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "initial"}, @@ -338,7 +344,7 @@ func TestPush_Good_WithRemote(t *testing.T) { } // Make a local commit. - require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("v2"), 0644)) + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) for _, args := range [][]string{ {"git", "add", "."}, {"git", "commit", "-m", "second commit"}, From a4c489f01c45e6d4a098d501563e381e338aaa06 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 29 Mar 2026 23:38:22 +0000 Subject: [PATCH 04/26] fix: enforce absolute-path and upstream validation Co-Authored-By: Virgil --- git.go | 59 ++++++++++++++++++++++++++++++++++--------- git_test.go | 15 +++++++++++ service.go | 17 ++++++++++--- service_extra_test.go | 14 ++++++++++ 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/git.go b/git.go index 16a67ac..274f7ce 100644 --- a/git.go +++ b/git.go @@ -80,9 +80,8 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { Path: path, } - // Validate path to prevent directory traversal - if !core.PathIsAbs(path) { - status.Error = coreerr.E("git.getStatus", "path must be absolute: "+path, nil) + if err := requireAbsolutePath("git.getStatus", path); err != nil { + status.Error = err return status } @@ -128,8 +127,9 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { // Get ahead/behind counts ahead, behind, err := getAheadBehind(ctx, path) if err != nil { - // We don't fail the whole status if ahead/behind fails (might be no upstream) - // but we could log it or store it if needed. For now, we just keep 0. + // We don't fail the whole status for missing upstream branches. + // We do surface other ahead/behind failures on the result. + status.Error = err } status.Ahead = ahead status.Behind = behind @@ -139,15 +139,32 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { // isNoUpstreamError reports whether an error is due to a missing tracking branch. func isNoUpstreamError(err error) bool { - msg := err.Error() - return core.Contains(msg, "no upstream") || core.Contains(msg, "No upstream") + if err == nil { + return false + } + msg := strings.ToLower(core.Trim(err.Error())) + return strings.Contains(msg, "no upstream") +} + +func requireAbsolutePath(op string, path string) error { + if core.PathIsAbs(path) { + return nil + } + return coreerr.E(op, "path must be absolute: "+path, nil) } // getAheadBehind returns the number of commits ahead and behind upstream. func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { + if err := requireAbsolutePath("git.getAheadBehind", path); err != nil { + return 0, 0, err + } + aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") if err == nil { - ahead, _ = strconv.Atoi(core.Trim(aheadStr)) + ahead, err = strconv.Atoi(core.Trim(aheadStr)) + if err != nil { + return 0, 0, coreerr.E("git.getAheadBehind", "failed to parse ahead count", err) + } } else if isNoUpstreamError(err) { err = nil } @@ -158,7 +175,10 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") if err == nil { - behind, _ = strconv.Atoi(core.Trim(behindStr)) + behind, err = strconv.Atoi(core.Trim(behindStr)) + if err != nil { + return 0, 0, coreerr.E("git.getAheadBehind", "failed to parse behind count", err) + } } else if isNoUpstreamError(err) { err = nil } @@ -169,12 +189,18 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er // Push pushes commits for a single repository. // Uses interactive mode to support SSH passphrase prompts. func Push(ctx context.Context, path string) error { + if err := requireAbsolutePath("git.push", path); err != nil { + return err + } return gitInteractive(ctx, path, "push") } // Pull pulls changes for a single repository. // Uses interactive mode to support SSH passphrase prompts. func Pull(ctx context.Context, path string) error { + if err := requireAbsolutePath("git.pull", path); err != nil { + return err + } return gitInteractive(ctx, path, "pull", "--rebase") } @@ -184,13 +210,18 @@ func IsNonFastForward(err error) bool { return false } msg := err.Error() - return core.Contains(msg, "non-fast-forward") || - core.Contains(msg, "fetch first") || - core.Contains(msg, "tip of your current branch is behind") + msg = strings.ToLower(msg) + return strings.Contains(msg, "non-fast-forward") || + strings.Contains(msg, "fetch first") || + strings.Contains(msg, "tip of your current branch is behind") } // gitInteractive runs a git command with terminal attached for user interaction. func gitInteractive(ctx context.Context, dir string, args ...string) error { + if err := requireAbsolutePath("git.interactive", dir); err != nil { + return err + } + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir @@ -254,6 +285,10 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string) // gitCommand runs a git command and returns stdout. func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { + if err := requireAbsolutePath("git.command", dir); err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir diff --git a/git_test.go b/git_test.go index 94683d7..3724b1f 100644 --- a/git_test.go +++ b/git_test.go @@ -266,6 +266,21 @@ func TestGitCommand_Bad_NotARepo(t *testing.T) { } } +func TestGitCommand_Bad_RelativePath(t *testing.T) { + _, err := gitCommand(context.Background(), "relative/path", "status") + assert.Error(t, err) +} + +func TestPush_Bad_RelativePath(t *testing.T) { + err := Push(context.Background(), "relative/path") + assert.Error(t, err) +} + +func TestPull_Bad_RelativePath(t *testing.T) { + err := Pull(context.Background(), "relative/path") + assert.Error(t, err) +} + // --- getStatus integration tests --- func TestGetStatus_Good_CleanRepo(t *testing.T) { diff --git a/service.go b/service.go index e33639b..55e0550 100644 --- a/service.go +++ b/service.go @@ -3,7 +3,9 @@ package git import ( "context" "iter" + "path/filepath" "slices" + "strings" "sync" "dappco.re/go/core" @@ -151,10 +153,17 @@ func (s *Service) validatePath(path string) error { } workDir := s.opts.WorkDir - if workDir != "" { - if !core.HasPrefix(path, workDir) { - return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) - } + if workDir == "" { + return nil + } + + workDir = filepath.Clean(workDir) + if !core.PathIsAbs(workDir) { + return coreerr.E("git.validatePath", "WorkDir must be absolute: "+s.opts.WorkDir, nil) + } + rel, err := filepath.Rel(workDir, filepath.Clean(path)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) } return nil } diff --git a/service_extra_test.go b/service_extra_test.go index 7fc7698..d2fd6e9 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -29,6 +29,20 @@ func TestService_ValidatePath_Bad_OutsideWorkDir(t *testing.T) { assert.Contains(t, err.Error(), "outside of allowed WorkDir") } +func TestService_ValidatePath_Bad_OutsideWorkDirPrefix(t *testing.T) { + svc := &Service{opts: ServiceOptions{WorkDir: "/home/repos"}} + err := svc.validatePath("/home/repos2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "outside of allowed WorkDir") +} + +func TestService_ValidatePath_Bad_WorkDirNotAbsolute(t *testing.T) { + svc := &Service{opts: ServiceOptions{WorkDir: "relative/workdir"}} + err := svc.validatePath("/any/absolute/path") + assert.Error(t, err) + assert.Contains(t, err.Error(), "WorkDir must be absolute") +} + func TestService_ValidatePath_Good_InsideWorkDir(t *testing.T) { svc := &Service{opts: ServiceOptions{WorkDir: "/home/repos"}} err := svc.validatePath("/home/repos/my-project") From 6f6e19b3e8000c28f812d9fc34293667a8d7cbfa Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 05:45:23 +0000 Subject: [PATCH 05/26] refactor(ax): add usage examples to git API docs Co-Authored-By: Virgil --- git.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/git.go b/git.go index 274f7ce..f45fb34 100644 --- a/git.go +++ b/git.go @@ -53,6 +53,10 @@ type StatusOptions struct { } // Status checks git status for multiple repositories in parallel. +// +// Example: +// +// statuses := Status(ctx, StatusOptions{Paths: []string{"/home/user/Code/core/agent"}}) func Status(ctx context.Context, opts StatusOptions) []RepoStatus { var wg sync.WaitGroup results := make([]RepoStatus, len(opts.Paths)) @@ -187,6 +191,11 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er } // Push pushes commits for a single repository. +// +// Example: +// +// err := Push(ctx, "/home/user/Code/core/agent") +// // Uses interactive mode to support SSH passphrase prompts. func Push(ctx context.Context, path string) error { if err := requireAbsolutePath("git.push", path); err != nil { @@ -196,6 +205,11 @@ func Push(ctx context.Context, path string) error { } // Pull pulls changes for a single repository. +// +// Example: +// +// err := Pull(ctx, "/home/user/Code/core/agent") +// // Uses interactive mode to support SSH passphrase prompts. func Pull(ctx context.Context, path string) error { if err := requireAbsolutePath("git.pull", path); err != nil { From a14d7d574957309328fd580901ade435a9e60db7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 06:39:43 +0000 Subject: [PATCH 06/26] fix: use core context in git query handler --- service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service.go b/service.go index 55e0550..7796421 100644 --- a/service.go +++ b/service.go @@ -119,7 +119,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { } func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { - ctx := context.Background() // TODO: core should pass context to handlers + ctx := c.Context() switch m := q.(type) { case QueryStatus: From 484ad8e6bc2dd7bc3bfbcf6b22edac8711c5999d Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 04:45:13 +0000 Subject: [PATCH 07/26] fix: restore git task query handling Co-Authored-By: Virgil --- service.go | 41 +++++++++++++++++++++++ service_extra_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/service.go b/service.go index 7796421..ee86c57 100644 --- a/service.go +++ b/service.go @@ -143,10 +143,51 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { case QueryAheadRepos: return core.Result{Value: s.AheadRepos(), OK: true} + case TaskPush, TaskPull, TaskPushMultiple: + return s.handleTask(c, m) } return core.Result{} } +func (s *Service) handleTask(c *core.Core, t any) core.Result { + ctx := c.Context() + + switch m := t.(type) { + case TaskPush: + if err := s.validatePath(m.Path); err != nil { + return c.LogError(err, "git.handleTask", "path validation failed") + } + if err := Push(ctx, m.Path); err != nil { + return c.LogError(err, "git.handleTask", "push failed") + } + return core.Result{OK: true} + + case TaskPull: + if err := s.validatePath(m.Path); err != nil { + return c.LogError(err, "git.handleTask", "path validation failed") + } + if err := Pull(ctx, m.Path); err != nil { + return c.LogError(err, "git.handleTask", "pull failed") + } + return core.Result{OK: true} + + case TaskPushMultiple: + for _, path := range m.Paths { + if err := s.validatePath(path); err != nil { + return c.LogError(err, "git.handleTask", "path validation failed") + } + } + results, err := PushMultiple(ctx, m.Paths, m.Names) + if err != nil { + // Log for observability; partial results are still returned. + _ = c.LogError(err, "git.handleTask", "push multiple had failures") + } + return core.Result{Value: results, OK: true} + } + + return core.Result{} +} + func (s *Service) validatePath(path string) error { if !core.PathIsAbs(path) { return coreerr.E("git.validatePath", "path must be absolute: "+path, nil) diff --git a/service_extra_test.go b/service_extra_test.go index d2fd6e9..98b5eab 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -219,6 +219,58 @@ func TestService_HandleQuery_Good_AheadRepos(t *testing.T) { assert.Equal(t, "ahead", ahead[0].Name) } +func TestService_HandleQuery_Good_TaskPush(t *testing.T) { + bareDir, _ := filepath.Abs(t.TempDir()) + cloneDir, _ := filepath.Abs(t.TempDir()) + + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = bareDir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "clone", bareDir, cloneDir) + require.NoError(t, cmd.Run()) + + for _, args := range [][]string{ + {"git", "config", "user.email", "test@example.com"}, + {"git", "config", "user.name", "Test User"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + require.NoError(t, cmd.Run()) + } + + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) + for _, args := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-m", "initial"}, + {"git", "push", "origin", "HEAD"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "command %v failed: %s", args, string(out)) + } + + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) + for _, args := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-m", "second commit"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + require.NoError(t, cmd.Run()) + } + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + result := c.Query(TaskPush{Path: cloneDir}) + assert.True(t, result.OK) +} + func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { c := core.New() @@ -288,6 +340,29 @@ func TestService_Action_Good_PushMultiple(t *testing.T) { assert.False(t, results[0].Success) // No remote } +func TestService_HandleTask_Good_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"}, + }) + + assert.True(t, result.OK) + results, ok := result.Value.([]PushResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + // --- Additional git operation tests --- func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) { From 35edd29f072c1c57c31bdaa4b2c150dee02f8caa Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 05:38:57 +0000 Subject: [PATCH 08/26] feat: add behind repos query and iterator Co-Authored-By: Virgil --- docs/architecture.md | 5 +++- docs/index.md | 6 ++-- go.sum | 1 + service.go | 15 ++++++++++ service_extra_test.go | 20 +++++++++++++ service_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index debf877..78c0883 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,7 @@ func (s *Service) OnStartup(ctx context.Context) error { | `QueryStatus` | `[]RepoStatus` | Checks Git status for a set of paths (runs in parallel). Updates the cached `lastStatus`. | | `QueryDirtyRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with uncommitted changes. | | `QueryAheadRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with unpushed commits. | +| `QueryBehindRepos` | `[]RepoStatus` | Filters `lastStatus` for repos with unpulled commits. | `QueryStatus` has the same fields as `StatusOptions` and can be type-converted directly: @@ -193,10 +194,12 @@ The `Service` caches the most recent `QueryStatus` result in `lastStatus` (prote | `All()` | `iter.Seq[RepoStatus]` | Iterator over all cached statuses. | | `Dirty()` | `iter.Seq[RepoStatus]` | Iterator over repos where `IsDirty()` is true and `Error` is nil. | | `Ahead()` | `iter.Seq[RepoStatus]` | Iterator over repos where `HasUnpushed()` is true and `Error` is nil. | +| `Behind()` | `iter.Seq[RepoStatus]` | Iterator over repos where `HasUnpulled()` is true and `Error` is nil. | | `DirtyRepos()` | `[]RepoStatus` | Collects `Dirty()` into a slice. | | `AheadRepos()` | `[]RepoStatus` | Collects `Ahead()` into a slice. | +| `BehindRepos()` | `[]RepoStatus` | Collects `Behind()` into a slice. | -Errored repositories are excluded from `Dirty()` and `Ahead()` iterators. +Errored repositories are excluded from `Dirty()`, `Ahead()`, and `Behind()` iterators. ## Concurrency model diff --git a/docs/index.md b/docs/index.md index 4d64d3f..fafc7b6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -89,7 +89,7 @@ func main() { statuses := result.([]git.RepoStatus) for _, s := range statuses { - fmt.Printf("%s: dirty=%v ahead=%v\n", s.Name, s.IsDirty(), s.HasUnpushed()) + fmt.Printf("%s: dirty=%v ahead=%v behind=%v\n", s.Name, s.IsDirty(), s.HasUnpushed(), s.HasUnpulled()) } } ``` @@ -99,9 +99,9 @@ func main() { | File | Purpose | |------|---------| | `git.go` | Standalone Git operations -- `Status`, `Push`, `Pull`, `PushMultiple`, error types. Zero framework dependencies. | -| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`). | +| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`). | | `git_test.go` | Tests for standalone operations using real temporary Git repositories. | -| `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, iterators). | +| `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, `BehindRepos`, iterators). | | `service_extra_test.go` | Integration tests for `Service` query/task handlers against the Core framework. | ## Dependencies diff --git a/go.sum b/go.sum index 872f5dc..25c4a3b 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/service.go b/service.go index ee86c57..92c8bfb 100644 --- a/service.go +++ b/service.go @@ -26,6 +26,9 @@ type QueryDirtyRepos struct{} // QueryAheadRepos requests repos with unpushed commits. type QueryAheadRepos struct{} +// QueryBehindRepos requests repos with unpulled commits. +type QueryBehindRepos struct{} + // Tasks for git service // TaskPush requests git push for a path. @@ -143,6 +146,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { case QueryAheadRepos: return core.Result{Value: s.AheadRepos(), OK: true} + case QueryBehindRepos: + return core.Result{Value: s.BehindRepos(), OK: true} case TaskPush, TaskPull, TaskPushMultiple: return s.handleTask(c, m) } @@ -250,6 +255,11 @@ func (s *Service) Ahead() iter.Seq[RepoStatus] { return s.filteredIter(func(st RepoStatus) bool { return st.HasUnpushed() }) } +// Behind returns an iterator over repos with unpulled commits. +func (s *Service) Behind() iter.Seq[RepoStatus] { + return s.filteredIter(func(st RepoStatus) bool { return st.HasUnpulled() }) +} + // DirtyRepos returns repos with uncommitted changes. func (s *Service) DirtyRepos() []RepoStatus { return slices.Collect(s.Dirty()) @@ -259,3 +269,8 @@ func (s *Service) DirtyRepos() []RepoStatus { func (s *Service) AheadRepos() []RepoStatus { return slices.Collect(s.Ahead()) } + +// BehindRepos returns repos with unpulled commits. +func (s *Service) BehindRepos() []RepoStatus { + return slices.Collect(s.Behind()) +} diff --git a/service_extra_test.go b/service_extra_test.go index 98b5eab..2c00cec 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -219,6 +219,26 @@ func TestService_HandleQuery_Good_AheadRepos(t *testing.T) { assert.Equal(t, "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{}) + assert.True(t, result.OK) + + behind, ok := result.Value.([]RepoStatus) + require.True(t, ok) + assert.Len(t, behind, 1) + assert.Equal(t, "behind", behind[0].Name) +} + func TestService_HandleQuery_Good_TaskPush(t *testing.T) { bareDir, _ := filepath.Abs(t.TempDir()) cloneDir, _ := filepath.Abs(t.TempDir()) diff --git a/service_test.go b/service_test.go index b199126..0428d8a 100644 --- a/service_test.go +++ b/service_test.go @@ -97,6 +97,49 @@ func TestService_AheadRepos_Good_EmptyStatus(t *testing.T) { assert.Empty(t, ahead) } +func TestService_BehindRepos_Good(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "synced", Behind: 0}, + {Name: "behind-by-one", Behind: 1}, + {Name: "behind-by-five", Behind: 5}, + {Name: "ahead-only", Ahead: 3}, + {Name: "errored-behind", Behind: 2, Error: assert.AnError}, + }, + } + + behind := s.BehindRepos() + assert.Len(t, behind, 2) + + names := slices.Collect(func(yield func(string) bool) { + for _, b := range behind { + if !yield(b.Name) { + return + } + } + }) + assert.Contains(t, names, "behind-by-one") + assert.Contains(t, names, "behind-by-five") +} + +func TestService_BehindRepos_Good_NoneFound(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "synced1"}, + {Name: "synced2"}, + }, + } + + behind := s.BehindRepos() + assert.Empty(t, behind) +} + +func TestService_BehindRepos_Good_EmptyStatus(t *testing.T) { + s := &Service{} + behind := s.BehindRepos() + assert.Empty(t, behind) +} + func TestService_Iterators_Good(t *testing.T) { s := &Service{ lastStatus: []RepoStatus{ @@ -119,6 +162,10 @@ func TestService_Iterators_Good(t *testing.T) { ahead := slices.Collect(s.Ahead()) assert.Len(t, ahead, 1) assert.Equal(t, "ahead", ahead[0].Name) + + // Test Behind() + behind := slices.Collect(s.Behind()) + assert.Len(t, behind, 0) } func TestService_Status_Good(t *testing.T) { @@ -150,6 +197,11 @@ func TestQueryStatus_MapsToStatusOptions(t *testing.T) { assert.Equal(t, q.Names, opts.Names) } +func TestQueryBehindRepos_TypeExists(t *testing.T) { + var q QueryBehindRepos + assert.IsType(t, QueryBehindRepos{}, q) +} + func TestServiceOptions_WorkDir(t *testing.T) { opts := ServiceOptions{WorkDir: "/home/claude/repos"} assert.Equal(t, "/home/claude/repos", opts.WorkDir) @@ -184,3 +236,18 @@ func TestService_AheadRepos_Good_ExcludesErrors(t *testing.T) { assert.Len(t, ahead, 1) assert.Equal(t, "ahead-ok", ahead[0].Name) } + +// --- BehindRepos excludes errored repos --- + +func TestService_BehindRepos_Good_ExcludesErrors(t *testing.T) { + s := &Service{ + lastStatus: []RepoStatus{ + {Name: "behind-ok", Behind: 2}, + {Name: "behind-error", Behind: 3, Error: assert.AnError}, + }, + } + + behind := s.BehindRepos() + assert.Len(t, behind, 1) + assert.Equal(t, "behind-ok", behind[0].Name) +} From 7528e201dad5629de0b894de01e47d7e012a61e7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:00:28 +0000 Subject: [PATCH 09/26] fix: count git type changes in status Co-Authored-By: Virgil --- git.go | 4 ++-- git_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/git.go b/git.go index f45fb34..096fc4e 100644 --- a/git.go +++ b/git.go @@ -118,12 +118,12 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Staged (index has changes) - if slices.Contains([]byte{'A', 'D', 'R', 'M'}, x) { + if slices.Contains([]byte{'A', 'C', 'D', 'R', 'M', 'T'}, x) { status.Staged++ } // Modified in working tree - if slices.Contains([]byte{'M', 'D'}, y) { + if slices.Contains([]byte{'M', 'D', 'T'}, y) { status.Modified++ } } diff --git a/git_test.go b/git_test.go index 3724b1f..e11e3c9 100644 --- a/git_test.go +++ b/git_test.go @@ -589,3 +589,33 @@ func TestGetStatus_Good_RenamedFile(t *testing.T) { assert.Equal(t, 1, status.Staged, "rename should count as staged") assert.True(t, status.IsDirty()) } + +func TestGetStatus_Good_TypeChangedFile_WorkingTree(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Replace the tracked file with a symlink to trigger a working-tree type change. + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) + require.NoError(t, os.Symlink("/etc/hosts", core.JoinPath(dir, "README.md"))) + + status := getStatus(context.Background(), dir, "typechanged-working-tree") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Modified, "type change in working tree counts as modified") + assert.True(t, status.IsDirty()) +} + +func TestGetStatus_Good_TypeChangedFile_Staged(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Stage a type change by replacing the tracked file with a symlink and adding it. + require.NoError(t, os.Remove(core.JoinPath(dir, "README.md"))) + require.NoError(t, os.Symlink("/etc/hosts", core.JoinPath(dir, "README.md"))) + + cmd := exec.Command("git", "add", "README.md") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + status := getStatus(context.Background(), dir, "typechanged-staged") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Staged, "type change in the index counts as staged") + assert.True(t, status.IsDirty()) +} From 2fd68778e0a098d897761959554bac14dc5eb37a Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:26:58 +0000 Subject: [PATCH 10/26] fix: count conflicted git status entries Co-Authored-By: Virgil --- docs/architecture.md | 6 +++--- git.go | 23 ++++++++++++++++++--- git_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 78c0883..75ffc5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,10 +88,10 @@ The `--porcelain` output is parsed character by character. Each line has a two-c | Position X (index) | Position Y (working tree) | Interpretation | |---------------------|---------------------------|----------------| | `?` | `?` | Untracked file | -| `A`, `D`, `R`, `M` | any | Staged change | -| any | `M`, `D` | Working tree modification | +| `A`, `D`, `R`, `M`, `U` | any | Staged change | +| any | `M`, `D`, `U` | Working tree modification | -A single file can increment both `Staged` and `Modified` if it has been staged and then further modified. +A single file can increment both `Staged` and `Modified` if it has been staged and then further modified. Unmerged paths (`U`) increment both counters, which keeps conflicted repositories visibly dirty. ### Interactive push and pull diff --git a/git.go b/git.go index 096fc4e..831b44a 100644 --- a/git.go +++ b/git.go @@ -7,7 +7,6 @@ import ( goio "io" "os" "os/exec" - "slices" "strconv" "strings" "sync" @@ -118,12 +117,12 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Staged (index has changes) - if slices.Contains([]byte{'A', 'C', 'D', 'R', 'M', 'T'}, x) { + if isStagedStatus(x) { status.Staged++ } // Modified in working tree - if slices.Contains([]byte{'M', 'D', 'T'}, y) { + if isModifiedStatus(y) { status.Modified++ } } @@ -141,6 +140,24 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { return status } +func isStagedStatus(ch byte) bool { + switch ch { + case 'A', 'C', 'D', 'R', 'M', 'T', 'U': + return true + default: + return false + } +} + +func isModifiedStatus(ch byte) bool { + switch ch { + case 'M', 'D', 'T', 'U': + return true + default: + return false + } +} + // isNoUpstreamError reports whether an error is due to a missing tracking branch. func isNoUpstreamError(err error) bool { if err == nil { diff --git a/git_test.go b/git_test.go index e11e3c9..feaec3c 100644 --- a/git_test.go +++ b/git_test.go @@ -382,6 +382,54 @@ func TestGetStatus_Good_StagedDeletion(t *testing.T) { assert.True(t, status.IsDirty()) } +func TestGetStatus_Good_MergeConflict(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + // Create a conflicting change on a feature branch. + cmd := exec.Command("git", "checkout", "-b", "feature") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Feature\n"), 0644)) + for _, args := range [][]string{ + {"git", "add", "README.md"}, + {"git", "commit", "-m", "feature change"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "failed to run %v: %s", args, string(out)) + } + + // Return to the original branch and create a divergent change. + cmd = exec.Command("git", "checkout", "-") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(core.JoinPath(dir, "README.md"), []byte("# Main\n"), 0644)) + for _, args := range [][]string{ + {"git", "add", "README.md"}, + {"git", "commit", "-m", "main change"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "failed to run %v: %s", args, string(out)) + } + + cmd = exec.Command("git", "merge", "feature") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.Error(t, err, "expected the merge to conflict") + assert.Contains(t, string(out), "CONFLICT") + + status := getStatus(context.Background(), dir, "conflicted-repo") + require.NoError(t, status.Error) + assert.Equal(t, 1, status.Staged, "unmerged paths count as staged") + assert.Equal(t, 1, status.Modified, "unmerged paths count as modified") + assert.True(t, status.IsDirty()) +} + func TestGetStatus_Bad_InvalidPath(t *testing.T) { status := getStatus(context.Background(), "/nonexistent/path", "bad-repo") assert.Error(t, status.Error) From a80466e180fe2d5499740eaaf167ddc4590a2298 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:34:18 +0000 Subject: [PATCH 11/26] fix: enforce absolute paths in push multiple Co-Authored-By: Virgil --- git.go | 7 +++++++ git_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/git.go b/git.go index 831b44a..621e1cd 100644 --- a/git.go +++ b/git.go @@ -300,6 +300,13 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string) Path: path, } + if err := requireAbsolutePath("git.pushMultiple", path); err != nil { + result.Error = err + lastErr = err + results[i] = result + continue + } + err := Push(ctx, path) if err != nil { result.Error = err diff --git a/git_test.go b/git_test.go index feaec3c..e0bf030 100644 --- a/git_test.go +++ b/git_test.go @@ -537,6 +537,22 @@ func TestPushMultiple_Good_NameFallback(t *testing.T) { assert.Equal(t, dir, results[0].Name, "name should fall back to path") } +func TestPushMultiple_Bad_RelativePath(t *testing.T) { + validDir, _ := filepath.Abs(initTestRepo(t)) + relativePath := "relative/repo" + + results, err := PushMultiple(context.Background(), []string{relativePath, validDir}, map[string]string{ + validDir: "valid-repo", + }) + + assert.Error(t, err) + require.Len(t, results, 2) + assert.Equal(t, relativePath, results[0].Path) + assert.Error(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), "path must be absolute") + assert.Equal(t, validDir, results[1].Path) +} + // --- Pull tests --- func TestPull_Bad_NoRemote(t *testing.T) { From e2d8c7ca432db26ab6f098c80b986f9b190487fa Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:43:08 +0000 Subject: [PATCH 12/26] feat: handle git task messages on action bus Co-Authored-By: Virgil --- service.go | 15 ++++++++++++ service_extra_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/service.go b/service.go index 92c8bfb..a64061a 100644 --- a/service.go +++ b/service.go @@ -78,6 +78,7 @@ func NewService(opts ServiceOptions) func(*core.Core) (any, error) { // OnStartup registers query and action handlers. func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterAction(s.handleTaskMessage) s.Core().Action("git.push", func(ctx context.Context, opts core.Options) core.Result { path := opts.String("path") @@ -121,6 +122,20 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { return core.Result{OK: true} } +// handleTaskMessage bridges task structs onto the Core action bus. +func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result { + switch m := msg.(type) { + case TaskPush: + return s.handleTask(c, m) + case TaskPull: + return s.handleTask(c, m) + case TaskPushMultiple: + return s.handleTask(c, m) + default: + return core.Result{OK: true} + } +} + func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { ctx := c.Context() diff --git a/service_extra_test.go b/service_extra_test.go index 2c00cec..caa6704 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -291,6 +291,63 @@ func TestService_HandleQuery_Good_TaskPush(t *testing.T) { assert.True(t, result.OK) } +func TestService_Action_Good_TaskPush(t *testing.T) { + bareDir, _ := filepath.Abs(t.TempDir()) + cloneDir, _ := filepath.Abs(t.TempDir()) + + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = bareDir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "clone", bareDir, cloneDir) + require.NoError(t, cmd.Run()) + + for _, args := range [][]string{ + {"git", "config", "user.email", "test@example.com"}, + {"git", "config", "user.name", "Test User"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + require.NoError(t, cmd.Run()) + } + + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v1"), 0644)) + for _, args := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-m", "initial"}, + {"git", "push", "origin", "HEAD"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "command %v failed: %s", args, string(out)) + } + + require.NoError(t, os.WriteFile(core.JoinPath(cloneDir, "file.txt"), []byte("v2"), 0644)) + for _, args := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-m", "second commit"}, + } { + cmd = exec.Command(args[0], args[1:]...) + cmd.Dir = cloneDir + require.NoError(t, cmd.Run()) + } + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + result := c.ACTION(TaskPush{Path: cloneDir}) + assert.True(t, result.OK) + + ahead, behind, err := getAheadBehind(context.Background(), cloneDir) + require.NoError(t, err) + assert.Equal(t, 0, ahead) + assert.Equal(t, 0, behind) +} + func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { c := core.New() From de3a93fc3b34d40ff16ccf0fe73d803ccd20fb4c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:49:43 +0000 Subject: [PATCH 13/26] refactor(go-git): align with AX string helpers Co-Authored-By: Virgil --- git.go | 16 +++++++--------- service.go | 3 +-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/git.go b/git.go index 621e1cd..fceb7b1 100644 --- a/git.go +++ b/git.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "strconv" - "strings" "sync" core "dappco.re/go/core" @@ -104,7 +103,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Parse status output - for line := range strings.SplitSeq(porcelain, "\n") { + for _, line := range core.Split(porcelain, "\n") { if len(line) < 2 { continue } @@ -163,8 +162,8 @@ func isNoUpstreamError(err error) bool { if err == nil { return false } - msg := strings.ToLower(core.Trim(err.Error())) - return strings.Contains(msg, "no upstream") + msg := core.Lower(core.Trim(err.Error())) + return core.Contains(msg, "no upstream") } func requireAbsolutePath(op string, path string) error { @@ -240,11 +239,10 @@ func IsNonFastForward(err error) bool { if err == nil { return false } - msg := err.Error() - msg = strings.ToLower(msg) - 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. diff --git a/service.go b/service.go index a64061a..3938778 100644 --- a/service.go +++ b/service.go @@ -5,7 +5,6 @@ import ( "iter" "path/filepath" "slices" - "strings" "sync" "dappco.re/go/core" @@ -223,7 +222,7 @@ func (s *Service) validatePath(path string) error { return coreerr.E("git.validatePath", "WorkDir must be absolute: "+s.opts.WorkDir, nil) } rel, err := filepath.Rel(workDir, filepath.Clean(path)) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if err != nil || rel == ".." || core.HasPrefix(rel, ".."+string(filepath.Separator)) { return coreerr.E("git.validatePath", "path "+path+" is outside of allowed WorkDir "+workDir, nil) } return nil From acb79d42279829c7bd3bd6ca0bd7ae7b3df03b99 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:57:03 +0000 Subject: [PATCH 14/26] feat(git): add iterator helpers for status and push multiple Co-Authored-By: Virgil --- docs/index.md | 2 +- git.go | 101 ++++++++++++++++++++++++++++++-------------------- git_test.go | 33 +++++++++++++++++ 3 files changed, 95 insertions(+), 41 deletions(-) diff --git a/docs/index.md b/docs/index.md index fafc7b6..8f5acc3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,7 +98,7 @@ func main() { | File | Purpose | |------|---------| -| `git.go` | Standalone Git operations -- `Status`, `Push`, `Pull`, `PushMultiple`, error types. Zero framework dependencies. | +| `git.go` | Standalone Git operations -- `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, error types. Zero framework dependencies. | | `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`). | | `git_test.go` | Tests for standalone operations using real temporary Git repositories. | | `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, `BehindRepos`, iterators). | diff --git a/git.go b/git.go index fceb7b1..a6c11e1 100644 --- a/git.go +++ b/git.go @@ -5,8 +5,10 @@ import ( "bytes" "context" goio "io" + "iter" "os" "os/exec" + "slices" "strconv" "sync" @@ -56,23 +58,35 @@ type StatusOptions struct { // // statuses := Status(ctx, StatusOptions{Paths: []string{"/home/user/Code/core/agent"}}) func Status(ctx context.Context, opts StatusOptions) []RepoStatus { - var wg sync.WaitGroup - results := make([]RepoStatus, len(opts.Paths)) - - for i, path := range opts.Paths { - wg.Add(1) - go func(idx int, repoPath string) { - defer wg.Done() - name := opts.Names[repoPath] - if name == "" { - name = repoPath + return slices.Collect(StatusIter(ctx, opts)) +} + +// 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] { + return func(yield func(RepoStatus) bool) { + var wg sync.WaitGroup + results := make([]RepoStatus, len(opts.Paths)) + + for i, path := range opts.Paths { + wg.Add(1) + go func(idx int, repoPath string) { + defer wg.Done() + name := opts.Names[repoPath] + if name == "" { + name = repoPath + } + results[idx] = getStatus(ctx, repoPath, name) + }(i, path) + } + + wg.Wait() + for _, result := range results { + if !yield(result) { + return } - results[idx] = getStatus(ctx, repoPath, name) - }(i, path) + } } - - wg.Wait() - return results } // getStatus gets the git status for a single repository. @@ -284,39 +298,46 @@ 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) { - results := make([]PushResult, len(paths)) + results := slices.Collect(PushMultipleIter(ctx, paths, names)) var lastErr error - for i, path := range paths { - name := names[path] - if name == "" { - name = path + for _, result := range results { + if result.Error != nil { + lastErr = result.Error } + } - result := PushResult{ - Name: name, - Path: path, - } + return results, lastErr +} - if err := requireAbsolutePath("git.pushMultiple", path); err != nil { - result.Error = err - lastErr = err - results[i] = result - continue - } +// 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] { + return func(yield func(PushResult) bool) { + for _, path := range paths { + name := names[path] + if name == "" { + name = path + } - err := Push(ctx, path) - if err != nil { - result.Error = err - lastErr = err - } else { - result.Success = true - } + result := PushResult{ + Name: name, + Path: path, + } - results[i] = result - } + if err := requireAbsolutePath("git.pushMultiple", path); err != nil { + result.Error = err + } else if err := Push(ctx, path); err != nil { + result.Error = err + } else { + result.Success = true + } - return results, lastErr + if !yield(result) { + return + } + } + } } // gitCommand runs a git command and returns stdout. diff --git a/git_test.go b/git_test.go index e0bf030..92ccd36 100644 --- a/git_test.go +++ b/git_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" core "dappco.re/go/core" @@ -471,6 +472,27 @@ func TestStatus_Good_MultipleRepos(t *testing.T) { assert.True(t, results[1].IsDirty()) } +func TestStatusIter_Good_MultipleRepos(t *testing.T) { + dir1, _ := filepath.Abs(initTestRepo(t)) + dir2, _ := filepath.Abs(initTestRepo(t)) + + require.NoError(t, os.WriteFile(core.JoinPath(dir2, "extra.txt"), []byte("extra"), 0644)) + + statuses := slices.Collect(StatusIter(context.Background(), StatusOptions{ + Paths: []string{dir1, dir2}, + Names: map[string]string{ + dir1: "clean-repo", + dir2: "dirty-repo", + }, + })) + + require.Len(t, statuses, 2) + assert.Equal(t, "clean-repo", statuses[0].Name) + assert.Equal(t, "dirty-repo", statuses[1].Name) + assert.False(t, statuses[0].IsDirty()) + assert.True(t, statuses[1].IsDirty()) +} + func TestStatus_Good_EmptyPaths(t *testing.T) { results := Status(context.Background(), StatusOptions{ Paths: []string{}, @@ -537,6 +559,17 @@ func TestPushMultiple_Good_NameFallback(t *testing.T) { assert.Equal(t, dir, results[0].Name, "name should fall back to path") } +func TestPushMultipleIter_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results := slices.Collect(PushMultipleIter(context.Background(), []string{dir}, map[string]string{})) + + require.Len(t, results, 1) + assert.Equal(t, dir, results[0].Name, "name should fall back to path") + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestPushMultiple_Bad_RelativePath(t *testing.T) { validDir, _ := filepath.Abs(initTestRepo(t)) relativePath := "relative/repo" From c221f513f6b58ec6b6d35ab439c99da28b5e08d2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:03:12 +0000 Subject: [PATCH 15/26] refactor(git): keep task dispatch off query bus Co-Authored-By: Virgil --- service.go | 2 -- service_extra_test.go | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/service.go b/service.go index 3938778..2de61dc 100644 --- a/service.go +++ b/service.go @@ -162,8 +162,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { return core.Result{Value: s.AheadRepos(), OK: true} case QueryBehindRepos: return core.Result{Value: s.BehindRepos(), OK: true} - case TaskPush, TaskPull, TaskPushMultiple: - return s.handleTask(c, m) } return core.Result{} } diff --git a/service_extra_test.go b/service_extra_test.go index caa6704..1440cdf 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -239,7 +239,7 @@ func TestService_HandleQuery_Good_BehindRepos(t *testing.T) { assert.Equal(t, "behind", behind[0].Name) } -func TestService_HandleQuery_Good_TaskPush(t *testing.T) { +func TestService_HandleTaskMessage_Good_TaskPush(t *testing.T) { bareDir, _ := filepath.Abs(t.TempDir()) cloneDir, _ := filepath.Abs(t.TempDir()) @@ -285,9 +285,8 @@ func TestService_HandleQuery_Good_TaskPush(t *testing.T) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), } - svc.OnStartup(context.Background()) - result := c.Query(TaskPush{Path: cloneDir}) + result := svc.handleTaskMessage(c, TaskPush{Path: cloneDir}) assert.True(t, result.OK) } From 7a2356e03c5a526e4c0e267488ca2a2092c5de61 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:11:06 +0000 Subject: [PATCH 16/26] feat(git): add batch pull support Co-Authored-By: Virgil --- docs/architecture.md | 14 ++++++++++ docs/index.md | 4 +-- git.go | 53 +++++++++++++++++++++++++++++++++++ git_test.go | 52 ++++++++++++++++++++++++++++++++++ service.go | 38 +++++++++++++++++++++++++ service_extra_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++ service_test.go | 5 ++++ 7 files changed, 229 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 75ffc5b..822d02f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,6 +62,19 @@ type PushResult struct { } ``` +### PullResult + +Returned by `PullMultiple`, one per repository: + +```go +type PullResult struct { + Name string + Path string + Success bool + Error error +} +``` + ## Data flow ### Parallel status checking @@ -161,6 +174,7 @@ statuses := Status(ctx, StatusOptions(queryStatus)) | `TaskPush` | `nil` | Pushes a single repository (interactive). | | `TaskPull` | `nil` | Pulls a single repository with `--rebase` (interactive). | | `TaskPushMultiple` | `[]PushResult` | Pushes multiple repositories sequentially. | +| `TaskPullMultiple` | `[]PullResult` | Pulls multiple repositories sequentially with `--rebase`. | ### Path validation diff --git a/docs/index.md b/docs/index.md index 8f5acc3..618569d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,8 +98,8 @@ func main() { | File | Purpose | |------|---------| -| `git.go` | Standalone Git operations -- `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, error types. Zero framework dependencies. | -| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`). | +| `git.go` | Standalone Git operations -- `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, `PullMultiple`, `PullMultipleIter`, error types. Zero framework dependencies. | +| `service.go` | Core framework integration -- `Service`, query types (`QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`), task types (`TaskPush`, `TaskPull`, `TaskPushMultiple`, `TaskPullMultiple`). | | `git_test.go` | Tests for standalone operations using real temporary Git repositories. | | `service_test.go` | Tests for `Service` filtering helpers (`DirtyRepos`, `AheadRepos`, `BehindRepos`, iterators). | | `service_extra_test.go` | Integration tests for `Service` query/task handlers against the Core framework. | diff --git a/git.go b/git.go index a6c11e1..8982d44 100644 --- a/git.go +++ b/git.go @@ -295,6 +295,14 @@ type PushResult struct { Error error } +// PullResult represents the result of a pull operation. +type PullResult struct { + Name string + Path string + Success bool + Error error +} + // PushMultiple pushes multiple repositories sequentially. // Sequential because SSH passphrase prompts need user interaction. func PushMultiple(ctx context.Context, paths []string, names map[string]string) ([]PushResult, error) { @@ -340,6 +348,51 @@ 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(ctx, paths, names)) + var lastErr error + + for _, result := range results { + if result.Error != nil { + lastErr = result.Error + } + } + + return results, lastErr +} + +// PullMultipleIter pulls changes for multiple repositories sequentially and yields +// each per-repository result in input order. +func PullMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PullResult] { + return func(yield func(PullResult) bool) { + for _, path := range paths { + name := names[path] + if name == "" { + name = path + } + + result := PullResult{ + Name: name, + Path: path, + } + + if err := requireAbsolutePath("git.pullMultiple", path); err != nil { + result.Error = err + } else if err := Pull(ctx, path); err != nil { + result.Error = err + } else { + result.Success = true + } + + if !yield(result) { + return + } + } + } +} + // gitCommand runs a git command and returns stdout. func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { if err := requireAbsolutePath("git.command", dir); err != nil { diff --git a/git_test.go b/git_test.go index 92ccd36..50d339c 100644 --- a/git_test.go +++ b/git_test.go @@ -549,6 +549,21 @@ func TestPushMultiple_Good_NoRemote(t *testing.T) { assert.Error(t, results[0].Error) } +func TestPullMultiple_Good_NoRemote(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{ + dir: "test-repo", + }) + assert.Error(t, err) + + require.Len(t, results, 1) + assert.Equal(t, "test-repo", results[0].Name) + assert.Equal(t, dir, results[0].Path) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestPushMultiple_Good_NameFallback(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) @@ -559,6 +574,16 @@ func TestPushMultiple_Good_NameFallback(t *testing.T) { assert.Equal(t, dir, results[0].Name, "name should fall back to path") } +func TestPullMultiple_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results, err := PullMultiple(context.Background(), []string{dir}, map[string]string{}) + assert.Error(t, err) + + require.Len(t, results, 1) + assert.Equal(t, dir, results[0].Name, "name should fall back to path") +} + func TestPushMultipleIter_Good_NameFallback(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) @@ -570,6 +595,17 @@ func TestPushMultipleIter_Good_NameFallback(t *testing.T) { assert.Error(t, results[0].Error) } +func TestPullMultipleIter_Good_NameFallback(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + results := slices.Collect(PullMultipleIter(context.Background(), []string{dir}, map[string]string{})) + + require.Len(t, results, 1) + assert.Equal(t, dir, results[0].Name, "name should fall back to path") + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestPushMultiple_Bad_RelativePath(t *testing.T) { validDir, _ := filepath.Abs(initTestRepo(t)) relativePath := "relative/repo" @@ -586,6 +622,22 @@ func TestPushMultiple_Bad_RelativePath(t *testing.T) { assert.Equal(t, validDir, results[1].Path) } +func TestPullMultiple_Bad_RelativePath(t *testing.T) { + validDir, _ := filepath.Abs(initTestRepo(t)) + relativePath := "relative/repo" + + results, err := PullMultiple(context.Background(), []string{relativePath, validDir}, map[string]string{ + validDir: "valid-repo", + }) + + assert.Error(t, err) + require.Len(t, results, 2) + assert.Equal(t, relativePath, results[0].Path) + assert.Error(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), "path must be absolute") + assert.Equal(t, validDir, results[1].Path) +} + // --- Pull tests --- func TestPull_Bad_NoRemote(t *testing.T) { diff --git a/service.go b/service.go index 2de61dc..40369e5 100644 --- a/service.go +++ b/service.go @@ -48,6 +48,12 @@ type TaskPushMultiple struct { Names map[string]string } +// TaskPullMultiple requests git pull for multiple paths. +type TaskPullMultiple struct { + Paths []string + Names map[string]string +} + // ServiceOptions for configuring the git service. type ServiceOptions struct { WorkDir string @@ -118,6 +124,23 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { return core.Result{Value: results, OK: true} }) + s.Core().Action("git.pull-multiple", func(ctx context.Context, opts core.Options) core.Result { + r := opts.Get("paths") + paths, _ := r.Value.([]string) + r = opts.Get("names") + names, _ := r.Value.(map[string]string) + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.pull-multiple", "path validation failed") + } + } + results, err := PullMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.pull-multiple", "pull multiple had failures") + } + return core.Result{Value: results, OK: true} + }) + return core.Result{OK: true} } @@ -130,6 +153,8 @@ func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result return s.handleTask(c, m) case TaskPushMultiple: return s.handleTask(c, m) + case TaskPullMultiple: + return s.handleTask(c, m) default: return core.Result{OK: true} } @@ -200,6 +225,19 @@ func (s *Service) handleTask(c *core.Core, t any) core.Result { _ = c.LogError(err, "git.handleTask", "push multiple had failures") } return core.Result{Value: results, OK: true} + + case TaskPullMultiple: + for _, path := range m.Paths { + if err := s.validatePath(path); err != nil { + return c.LogError(err, "git.handleTask", "path validation failed") + } + } + results, err := PullMultiple(ctx, m.Paths, m.Names) + if err != nil { + // Log for observability; partial results are still returned. + _ = c.LogError(err, "git.handleTask", "pull multiple had failures") + } + return core.Result{Value: results, OK: true} } return core.Result{} diff --git a/service_extra_test.go b/service_extra_test.go index 1440cdf..6e54398 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -123,6 +123,23 @@ func TestService_Action_Bad_PushMultipleInvalidPath(t *testing.T) { assert.False(t, result.OK) } +func TestService_Action_Bad_PullMultipleInvalidPath(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{WorkDir: "/home/repos"}), + opts: ServiceOptions{WorkDir: "/home/repos"}, + } + svc.OnStartup(context.Background()) + + opts := core.NewOptions() + opts.Set("paths", []string{"/home/repos/ok", "/etc/bad"}) + opts.Set("names", map[string]string{}) + result := c.Action("git.pull-multiple").Run(context.Background(), opts) + _ = svc + assert.False(t, result.OK) +} + func TestNewService_Good(t *testing.T) { opts := ServiceOptions{WorkDir: t.TempDir()} factory := NewService(opts) @@ -416,6 +433,31 @@ func TestService_Action_Good_PushMultiple(t *testing.T) { assert.False(t, results[0].Success) // No remote } +func TestService_Action_Good_PullMultiple(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + svc.OnStartup(context.Background()) + + opts := core.NewOptions() + opts.Set("paths", []string{dir}) + opts.Set("names", map[string]string{dir: "test"}) + result := c.Action("git.pull-multiple").Run(context.Background(), opts) + _ = svc + + assert.True(t, result.OK) + results, ok := result.Value.([]PullResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + func TestService_HandleTask_Good_PushMultiple(t *testing.T) { dir, _ := filepath.Abs(initTestRepo(t)) @@ -439,6 +481,29 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) { assert.Error(t, results[0].Error) } +func TestService_HandleTask_Good_PullMultiple(t *testing.T) { + dir, _ := filepath.Abs(initTestRepo(t)) + + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTask(c, TaskPullMultiple{ + Paths: []string{dir}, + Names: map[string]string{dir: "test"}, + }) + + assert.True(t, result.OK) + results, ok := result.Value.([]PullResult) + require.True(t, ok) + assert.Len(t, results, 1) + assert.Equal(t, "test", results[0].Name) + assert.False(t, results[0].Success) + assert.Error(t, results[0].Error) +} + // --- Additional git operation tests --- func TestGetStatus_Good_AheadBehindNoUpstream(t *testing.T) { diff --git a/service_test.go b/service_test.go index 0428d8a..1fb86d5 100644 --- a/service_test.go +++ b/service_test.go @@ -202,6 +202,11 @@ func TestQueryBehindRepos_TypeExists(t *testing.T) { assert.IsType(t, QueryBehindRepos{}, q) } +func TestTaskPullMultiple_TypeExists(t *testing.T) { + var tpm TaskPullMultiple + assert.IsType(t, TaskPullMultiple{}, tpm) +} + func TestServiceOptions_WorkDir(t *testing.T) { opts := ServiceOptions{WorkDir: "/home/claude/repos"} assert.Equal(t, "/home/claude/repos", opts.WorkDir) From 3cfd6842b8fbaba2f2cbe4ecc2435caad4220413 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:18:10 +0000 Subject: [PATCH 17/26] fix(git): report batch task failures Co-Authored-By: Virgil --- service.go | 10 +++++----- service_extra_test.go | 23 ++++++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/service.go b/service.go index 40369e5..425e50c 100644 --- a/service.go +++ b/service.go @@ -121,7 +121,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { if err != nil { _ = s.Core().LogError(err, "git.push-multiple", "push multiple had failures") } - return core.Result{Value: results, OK: true} + return core.Result{Value: results, OK: err == nil} }) s.Core().Action("git.pull-multiple", func(ctx context.Context, opts core.Options) core.Result { @@ -138,7 +138,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { if err != nil { _ = s.Core().LogError(err, "git.pull-multiple", "pull multiple had failures") } - return core.Result{Value: results, OK: true} + return core.Result{Value: results, OK: err == nil} }) return core.Result{OK: true} @@ -156,7 +156,7 @@ func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result case TaskPullMultiple: return s.handleTask(c, m) default: - return core.Result{OK: true} + return core.Result{} } } @@ -224,7 +224,7 @@ func (s *Service) handleTask(c *core.Core, t any) core.Result { // Log for observability; partial results are still returned. _ = c.LogError(err, "git.handleTask", "push multiple had failures") } - return core.Result{Value: results, OK: true} + return core.Result{Value: results, OK: err == nil} case TaskPullMultiple: for _, path := range m.Paths { @@ -237,7 +237,7 @@ func (s *Service) handleTask(c *core.Core, t any) core.Result { // Log for observability; partial results are still returned. _ = c.LogError(err, "git.handleTask", "pull multiple had failures") } - return core.Result{Value: results, OK: true} + return core.Result{Value: results, OK: err == nil} } return core.Result{} diff --git a/service_extra_test.go b/service_extra_test.go index 6e54398..a109d9e 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -307,6 +307,18 @@ func TestService_HandleTaskMessage_Good_TaskPush(t *testing.T) { assert.True(t, result.OK) } +func TestService_HandleTaskMessage_Bad_UnknownTask(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTaskMessage(c, struct{}{}) + assert.False(t, result.OK) + assert.Nil(t, result.Value) +} + func TestService_Action_Good_TaskPush(t *testing.T) { bareDir, _ := filepath.Abs(t.TempDir()) cloneDir, _ := filepath.Abs(t.TempDir()) @@ -424,8 +436,9 @@ func TestService_Action_Good_PushMultiple(t *testing.T) { result := c.Action("git.push-multiple").Run(context.Background(), opts) _ = svc - // PushMultiple returns results even when individual pushes fail. - assert.True(t, result.OK) + // PushMultiple returns results even when individual pushes fail, but the + // overall action should still report failure. + assert.False(t, result.OK) results, ok := result.Value.([]PushResult) require.True(t, ok) @@ -449,7 +462,7 @@ func TestService_Action_Good_PullMultiple(t *testing.T) { result := c.Action("git.pull-multiple").Run(context.Background(), opts) _ = svc - assert.True(t, result.OK) + assert.False(t, result.OK) results, ok := result.Value.([]PullResult) require.True(t, ok) assert.Len(t, results, 1) @@ -472,7 +485,7 @@ func TestService_HandleTask_Good_PushMultiple(t *testing.T) { Names: map[string]string{dir: "test"}, }) - assert.True(t, result.OK) + assert.False(t, result.OK) results, ok := result.Value.([]PushResult) require.True(t, ok) assert.Len(t, results, 1) @@ -495,7 +508,7 @@ func TestService_HandleTask_Good_PullMultiple(t *testing.T) { Names: map[string]string{dir: "test"}, }) - assert.True(t, result.OK) + assert.False(t, result.OK) results, ok := result.Value.([]PullResult) require.True(t, ok) assert.Len(t, results, 1) From 3a823996ceed2bf98edcfa60f9aa17045b957549 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:26:57 +0000 Subject: [PATCH 18/26] refactor(git): centralise service task dispatch Co-Authored-By: Virgil --- service.go | 128 ++++++++++++++++++------------------------ service_extra_test.go | 19 ++++++- 2 files changed, 73 insertions(+), 74 deletions(-) diff --git a/service.go b/service.go index 425e50c..aca6d20 100644 --- a/service.go +++ b/service.go @@ -87,24 +87,12 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().Action("git.push", func(ctx context.Context, opts core.Options) core.Result { path := opts.String("path") - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.push", "path validation failed") - } - if err := Push(ctx, path); err != nil { - return s.Core().LogError(err, "git.push", "push failed") - } - return core.Result{OK: true} + return s.runPush(ctx, path) }) s.Core().Action("git.pull", func(ctx context.Context, opts core.Options) core.Result { path := opts.String("path") - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.pull", "path validation failed") - } - if err := Pull(ctx, path); err != nil { - return s.Core().LogError(err, "git.pull", "pull failed") - } - return core.Result{OK: true} + return s.runPull(ctx, path) }) s.Core().Action("git.push-multiple", func(ctx context.Context, opts core.Options) core.Result { @@ -112,16 +100,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { paths, _ := r.Value.([]string) r = opts.Get("names") names, _ := r.Value.(map[string]string) - for _, path := range paths { - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.push-multiple", "path validation failed") - } - } - results, err := PushMultiple(ctx, paths, names) - if err != nil { - _ = s.Core().LogError(err, "git.push-multiple", "push multiple had failures") - } - return core.Result{Value: results, OK: err == nil} + return s.runPushMultiple(ctx, paths, names) }) s.Core().Action("git.pull-multiple", func(ctx context.Context, opts core.Options) core.Result { @@ -129,16 +108,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { paths, _ := r.Value.([]string) r = opts.Get("names") names, _ := r.Value.(map[string]string) - for _, path := range paths { - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.pull-multiple", "path validation failed") - } - } - results, err := PullMultiple(ctx, paths, names) - if err != nil { - _ = s.Core().LogError(err, "git.pull-multiple", "pull multiple had failures") - } - return core.Result{Value: results, OK: err == nil} + return s.runPullMultiple(ctx, paths, names) }) return core.Result{OK: true} @@ -156,7 +126,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 c.LogError(coreerr.E("git.handleTaskMessage", "unsupported task message type", nil), "git.handleTaskMessage", "unsupported task message type") } } @@ -188,7 +158,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { case QueryBehindRepos: return core.Result{Value: s.BehindRepos(), OK: true} } - return core.Result{} + return c.LogError(coreerr.E("git.handleQuery", "unsupported query type", nil), "git.handleQuery", "unsupported query type") } func (s *Service) handleTask(c *core.Core, t any) core.Result { @@ -196,51 +166,65 @@ func (s *Service) handleTask(c *core.Core, t any) core.Result { switch m := t.(type) { case TaskPush: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Push(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "push failed") - } - return core.Result{OK: true} + return s.runPush(ctx, m.Path) case TaskPull: - if err := s.validatePath(m.Path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - if err := Pull(ctx, m.Path); err != nil { - return c.LogError(err, "git.handleTask", "pull failed") - } - return core.Result{OK: true} + return s.runPull(ctx, m.Path) case TaskPushMultiple: - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - } - results, err := PushMultiple(ctx, m.Paths, m.Names) - if err != nil { - // Log for observability; partial results are still returned. - _ = c.LogError(err, "git.handleTask", "push multiple had failures") - } - return core.Result{Value: results, OK: err == nil} + return s.runPushMultiple(ctx, m.Paths, m.Names) case TaskPullMultiple: - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleTask", "path validation failed") - } - } - results, err := PullMultiple(ctx, m.Paths, m.Names) - if err != nil { - // Log for observability; partial results are still returned. - _ = c.LogError(err, "git.handleTask", "pull multiple had failures") + return s.runPullMultiple(ctx, m.Paths, m.Names) + } + + return c.LogError(coreerr.E("git.handleTask", "unsupported task type", nil), "git.handleTask", "unsupported task type") +} + +func (s *Service) runPush(ctx context.Context, path string) core.Result { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.push", "path validation failed") + } + if err := Push(ctx, path); err != nil { + return s.Core().LogError(err, "git.push", "push failed") + } + return core.Result{OK: true} +} + +func (s *Service) runPull(ctx context.Context, path string) core.Result { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.pull", "path validation failed") + } + if err := Pull(ctx, path); err != nil { + return s.Core().LogError(err, "git.pull", "pull failed") + } + return core.Result{OK: true} +} + +func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.push-multiple", "path validation failed") } - return core.Result{Value: results, OK: err == nil} } + results, err := PushMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.push-multiple", "push multiple had failures") + } + return core.Result{Value: results, OK: err == nil} +} - return core.Result{} +func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return s.Core().LogError(err, "git.pull-multiple", "path validation failed") + } + } + results, err := PullMultiple(ctx, paths, names) + if err != nil { + _ = s.Core().LogError(err, "git.pull-multiple", "pull multiple had failures") + } + return core.Result{Value: results, OK: err == nil} } func (s *Service) validatePath(path string) error { diff --git a/service_extra_test.go b/service_extra_test.go index a109d9e..7853f86 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -316,7 +316,21 @@ func TestService_HandleTaskMessage_Bad_UnknownTask(t *testing.T) { result := svc.handleTaskMessage(c, struct{}{}) assert.False(t, result.OK) - assert.Nil(t, result.Value) + assert.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "unsupported task message type") +} + +func TestService_HandleTask_Bad_UnknownTask(t *testing.T) { + c := core.New() + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + + result := svc.handleTask(c, struct{}{}) + assert.False(t, result.OK) + assert.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "unsupported task type") } func TestService_Action_Good_TaskPush(t *testing.T) { @@ -385,7 +399,8 @@ func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { result := svc.handleQuery(c, "unknown query type") assert.False(t, result.OK) - assert.Nil(t, result.Value) + assert.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "unsupported query type") } func TestService_Action_Bad_PushNoRemote(t *testing.T) { From 34dcf83382a4b2d3d895744b816cd112b06c9a19 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:16:19 +0000 Subject: [PATCH 19/26] feat(git): decouple standalone API from core helpers --- git.go | 52 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/git.go b/git.go index 8982d44..6a412e0 100644 --- a/git.go +++ b/git.go @@ -5,15 +5,15 @@ import ( "bytes" "context" goio "io" + "fmt" "iter" "os" "os/exec" + "path/filepath" "slices" "strconv" + "strings" "sync" - - core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" ) // RepoStatus represents the git status of a single repository. @@ -107,7 +107,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { status.Error = err return status } - status.Branch = core.Trim(branch) + status.Branch = trim(branch) // Get porcelain status porcelain, err := gitCommand(ctx, path, "status", "--porcelain") @@ -117,7 +117,7 @@ func getStatus(ctx context.Context, path, name string) RepoStatus { } // Parse status output - for _, line := range core.Split(porcelain, "\n") { + for _, line := range strings.Split(porcelain, "\n") { if len(line) < 2 { continue } @@ -176,15 +176,19 @@ func isNoUpstreamError(err error) bool { if err == nil { return false } - msg := core.Lower(core.Trim(err.Error())) - return core.Contains(msg, "no upstream") + msg := strings.ToLower(trim(err.Error())) + return strings.Contains(msg, "no upstream") } func requireAbsolutePath(op string, path string) error { - if core.PathIsAbs(path) { + if filepath.IsAbs(path) { return nil } - return coreerr.E(op, "path must be absolute: "+path, nil) + return &GitError{ + Args: []string{op}, + Err: fmt.Errorf("path must be absolute: %s", path), + Stderr: "", + } } // getAheadBehind returns the number of commits ahead and behind upstream. @@ -195,9 +199,9 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er aheadStr, err := gitCommand(ctx, path, "rev-list", "--count", "@{u}..HEAD") if err == nil { - ahead, err = strconv.Atoi(core.Trim(aheadStr)) + ahead, err = strconv.Atoi(trim(aheadStr)) if err != nil { - return 0, 0, coreerr.E("git.getAheadBehind", "failed to parse ahead count", err) + return 0, 0, fmt.Errorf("failed to parse ahead count: %w", err) } } else if isNoUpstreamError(err) { err = nil @@ -209,9 +213,9 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er behindStr, err := gitCommand(ctx, path, "rev-list", "--count", "HEAD..@{u}") if err == nil { - behind, err = strconv.Atoi(core.Trim(behindStr)) + behind, err = strconv.Atoi(trim(behindStr)) if err != nil { - return 0, 0, coreerr.E("git.getAheadBehind", "failed to parse behind count", err) + return 0, 0, fmt.Errorf("failed to parse behind count: %w", err) } } else if isNoUpstreamError(err) { err = nil @@ -253,10 +257,10 @@ func IsNonFastForward(err error) bool { if err == nil { return false } - 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") + 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") } // gitInteractive runs a git command with terminal attached for user interaction. @@ -429,19 +433,23 @@ type GitError struct { // Error returns a descriptive error message. func (e *GitError) Error() string { - cmd := "git " + core.Join(" ", e.Args...) - stderr := core.Trim(e.Stderr) + cmd := "git " + strings.Join(e.Args, " ") + stderr := trim(e.Stderr) if stderr != "" { - return core.Sprintf("git command %q failed: %s", cmd, stderr) + return fmt.Sprintf("git command %q failed: %s", cmd, stderr) } if e.Err != nil { - return core.Sprintf("git command %q failed: %v", cmd, e.Err) + return fmt.Sprintf("git command %q failed: %v", cmd, e.Err) } - return core.Sprintf("git command %q failed", cmd) + 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 } + +func trim(s string) string { + return strings.TrimSpace(s) +} From b8148ca6d2865303bfc1a24b71c296bfda86adce Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:57:00 +0000 Subject: [PATCH 20/26] refactor(git): centralize service path validation Co-Authored-By: Virgil --- service.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/service.go b/service.go index aca6d20..8c92fcf 100644 --- a/service.go +++ b/service.go @@ -135,11 +135,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { switch m := q.(type) { case QueryStatus: - // Validate all paths before execution - for _, path := range m.Paths { - if err := s.validatePath(path); err != nil { - return c.LogError(err, "git.handleQuery", "path validation failed") - } + if err := s.validatePaths(m.Paths); err != nil { + return c.LogError(err, "git.handleQuery", "path validation failed") } statuses := Status(ctx, StatusOptions(m)) @@ -202,10 +199,8 @@ func (s *Service) runPull(ctx context.Context, path string) core.Result { } func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { - for _, path := range paths { - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.push-multiple", "path validation failed") - } + if err := s.validatePaths(paths); err != nil { + return s.Core().LogError(err, "git.push-multiple", "path validation failed") } results, err := PushMultiple(ctx, paths, names) if err != nil { @@ -215,10 +210,8 @@ func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map } func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { - for _, path := range paths { - if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.pull-multiple", "path validation failed") - } + if err := s.validatePaths(paths); err != nil { + return s.Core().LogError(err, "git.pull-multiple", "path validation failed") } results, err := PullMultiple(ctx, paths, names) if err != nil { @@ -248,6 +241,15 @@ func (s *Service) validatePath(path string) error { return nil } +func (s *Service) validatePaths(paths []string) error { + for _, path := range paths { + if err := s.validatePath(path); err != nil { + return err + } + } + return nil +} + // Status returns last status result. func (s *Service) Status() []RepoStatus { s.mu.RLock() From a170f2f75bad198676195fd2ae43dbf2d1bba23c Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 07:35:54 +0000 Subject: [PATCH 21/26] refactor(git): centralise repository name resolution and action constants --- git.go | 27 +++++++++++++++------------ service.go | 15 +++++++++++---- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/git.go b/git.go index 6a412e0..b00ba69 100644 --- a/git.go +++ b/git.go @@ -61,6 +61,18 @@ func Status(ctx context.Context, opts StatusOptions) []RepoStatus { return slices.Collect(StatusIter(ctx, opts)) } +func repoName(path string, names map[string]string) string { + if names == nil { + return path + } + + name := names[path] + if name == "" { + return path + } + return name +} + // StatusIter checks git status for multiple repositories in parallel and yields // the results in input order. func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] { @@ -72,10 +84,7 @@ func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] { wg.Add(1) go func(idx int, repoPath string) { defer wg.Done() - name := opts.Names[repoPath] - if name == "" { - name = repoPath - } + name := repoName(repoPath, opts.Names) results[idx] = getStatus(ctx, repoPath, name) }(i, path) } @@ -327,10 +336,7 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string) func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] { return func(yield func(PushResult) bool) { for _, path := range paths { - name := names[path] - if name == "" { - name = path - } + name := repoName(path, names) result := PushResult{ Name: name, @@ -372,10 +378,7 @@ func PullMultiple(ctx context.Context, paths []string, names map[string]string) func PullMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PullResult] { return func(yield func(PullResult) bool) { for _, path := range paths { - name := names[path] - if name == "" { - name = path - } + name := repoName(path, names) result := PullResult{ Name: name, diff --git a/service.go b/service.go index 8c92fcf..a62a763 100644 --- a/service.go +++ b/service.go @@ -70,6 +70,13 @@ type Service struct { lastStatus []RepoStatus } +const ( + actionGitPush = "git.push" + actionGitPull = "git.pull" + actionGitPushMultiple = "git.push-multiple" + actionGitPullMultiple = "git.pull-multiple" +) + // NewService creates a git service factory. func NewService(opts ServiceOptions) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { @@ -85,17 +92,17 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterAction(s.handleTaskMessage) - s.Core().Action("git.push", func(ctx context.Context, opts core.Options) core.Result { + s.Core().Action(actionGitPush, func(ctx context.Context, opts core.Options) core.Result { path := opts.String("path") return s.runPush(ctx, path) }) - s.Core().Action("git.pull", func(ctx context.Context, opts core.Options) core.Result { + s.Core().Action(actionGitPull, func(ctx context.Context, opts core.Options) core.Result { path := opts.String("path") return s.runPull(ctx, path) }) - s.Core().Action("git.push-multiple", func(ctx context.Context, opts core.Options) core.Result { + s.Core().Action(actionGitPushMultiple, func(ctx context.Context, opts core.Options) core.Result { r := opts.Get("paths") paths, _ := r.Value.([]string) r = opts.Get("names") @@ -103,7 +110,7 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { return s.runPushMultiple(ctx, paths, names) }) - s.Core().Action("git.pull-multiple", func(ctx context.Context, opts core.Options) core.Result { + s.Core().Action(actionGitPullMultiple, func(ctx context.Context, opts core.Options) core.Result { r := opts.Get("paths") paths, _ := r.Value.([]string) r = opts.Get("names") From 70146422e257d747038d8642d18fc4b37f1d14be Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 08:37:53 +0000 Subject: [PATCH 22/26] docs: add agent index for go-git Co-Authored-By: Virgil --- README.md | 2 ++ llms.txt | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 llms.txt diff --git a/README.md b/README.md index 758aa84..591f1e1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Go module: `forge.lthn.ai/core/go-git` +Agent index: [`llms.txt`](llms.txt) + ## License [EUPL-1.2](LICENSE.md) diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..9b90135 --- /dev/null +++ b/llms.txt @@ -0,0 +1,38 @@ +# go-git + +Module: `forge.lthn.ai/core/go-git` +Purpose: Multi-repository Git operations library with standalone helpers and Core service integration. + +## Entry Points + +- `git.go`: standalone Git helpers. Exports `Status`, `StatusIter`, `Push`, `Pull`, `PushMultiple`, `PushMultipleIter`, `PullMultiple`, `PullMultipleIter`, `RepoStatus`, `GitError`, and `IsNonFastForward`. +- `service.go`: Core integration. Exports `NewService`, `Service`, `ServiceOptions`, `QueryStatus`, `QueryDirtyRepos`, `QueryAheadRepos`, `QueryBehindRepos`, `TaskPush`, `TaskPull`, `TaskPushMultiple`, and `TaskPullMultiple`. +- `docs/architecture.md`: design and data-flow notes. +- `docs/development.md`: test and contribution workflow. + +## Operational Rules + +- Paths must be absolute. +- `ServiceOptions.WorkDir` is optional; when set, all paths must stay within that directory. +- `Status` checks repositories in parallel. +- `Push`, `Pull`, `PushMultiple`, and `PullMultiple` run interactively so SSH prompts can reach the terminal. +- `PushMultiple` and `PullMultiple` are sequential by design. + +## Common Patterns + +- Use `Status(ctx, StatusOptions{Paths: ..., Names: ...})` for batch inspection. +- Use `Service` queries to reuse cached status data with `DirtyRepos`, `AheadRepos`, and `BehindRepos`. +- Use `GitError` to inspect failed command stderr and args. +- Use `IsNonFastForward(err)` to detect pull-before-push rejections. + +## Testing + +```bash +GOMODCACHE=/tmp/gomodcache GOPATH=/tmp/gopath go test ./... +``` + +## Conventions + +- Tests use real temporary git repositories. +- Comments and docs use UK English. +- Commits use conventional prefixes like `feat:`, `fix:`, `refactor:`, and `docs:`. From fb140ce0d7e5891d65c259739570795428389b3a Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 4 Apr 2026 16:21:12 +0100 Subject: [PATCH 23/26] fix: migrate module paths from forge.lthn.ai to dappco.re Co-Authored-By: Virgil --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b1cd093..3d0663f 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module forge.lthn.ai/core/go-git +module dappco.re/go/core/git go 1.26.0 From 0ddde93407edc9740ab482887622b70df0ec8522 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 4 Apr 2026 16:25:12 +0100 Subject: [PATCH 24/26] fix: tidy deps after dappco.re migration Co-Authored-By: Virgil --- go.sum | 1 - 1 file changed, 1 deletion(-) diff --git a/go.sum b/go.sum index 25c4a3b..872f5dc 100644 --- a/go.sum +++ b/go.sum @@ -13,7 +13,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 1f5c6b8ccf99a8e2a362be4f2584e53afab8400a Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 16:04:36 +0100 Subject: [PATCH 25/26] =?UTF-8?q?feat(git):=20small=20RFC=20alignments=20?= =?UTF-8?q?=E2=80=94=205.4-mini=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Virgil --- git.go | 24 ++++++++++++++++++++---- service.go | 4 ++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/git.go b/git.go index b00ba69..8a6acdb 100644 --- a/git.go +++ b/git.go @@ -4,8 +4,8 @@ package git import ( "bytes" "context" - goio "io" "fmt" + goio "io" "iter" "os" "os/exec" @@ -16,6 +16,13 @@ import ( "sync" ) +func withBackground(ctx context.Context) context.Context { + if ctx != nil { + return ctx + } + return context.Background() +} + // RepoStatus represents the git status of a single repository. type RepoStatus struct { Name string @@ -58,7 +65,7 @@ type StatusOptions struct { // // statuses := Status(ctx, StatusOptions{Paths: []string{"/home/user/Code/core/agent"}}) func Status(ctx context.Context, opts StatusOptions) []RepoStatus { - return slices.Collect(StatusIter(ctx, opts)) + return slices.Collect(StatusIter(withBackground(ctx), opts)) } func repoName(path string, names map[string]string) string { @@ -76,6 +83,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] { + ctx = withBackground(ctx) return func(yield func(RepoStatus) bool) { var wg sync.WaitGroup results := make([]RepoStatus, len(opts.Paths)) @@ -100,6 +108,7 @@ 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 { + ctx = withBackground(ctx) status := RepoStatus{ Name: name, Path: path, @@ -202,6 +211,7 @@ func requireAbsolutePath(op string, path string) error { // getAheadBehind returns the number of commits ahead and behind upstream. func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err error) { + ctx = withBackground(ctx) if err := requireAbsolutePath("git.getAheadBehind", path); err != nil { return 0, 0, err } @@ -241,6 +251,7 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int, err er // // Uses interactive mode to support SSH passphrase prompts. func Push(ctx context.Context, path string) error { + ctx = withBackground(ctx) if err := requireAbsolutePath("git.push", path); err != nil { return err } @@ -255,6 +266,7 @@ func Push(ctx context.Context, path string) error { // // Uses interactive mode to support SSH passphrase prompts. func Pull(ctx context.Context, path string) error { + ctx = withBackground(ctx) if err := requireAbsolutePath("git.pull", path); err != nil { return err } @@ -274,6 +286,7 @@ func IsNonFastForward(err error) bool { // gitInteractive runs a git command with terminal attached for user interaction. func gitInteractive(ctx context.Context, dir string, args ...string) error { + ctx = withBackground(ctx) if err := requireAbsolutePath("git.interactive", dir); err != nil { return err } @@ -319,7 +332,7 @@ 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(ctx, paths, names)) + results := slices.Collect(PushMultipleIter(withBackground(ctx), paths, names)) var lastErr error for _, result := range results { @@ -334,6 +347,7 @@ func PushMultiple(ctx context.Context, paths []string, names map[string]string) // PushMultipleIter pushes multiple repositories sequentially and yields each // per-repository result in input order. func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] { + ctx = withBackground(ctx) return func(yield func(PushResult) bool) { for _, path := range paths { name := repoName(path, names) @@ -361,7 +375,7 @@ 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(ctx, paths, names)) + results := slices.Collect(PullMultipleIter(withBackground(ctx), paths, names)) var lastErr error for _, result := range results { @@ -376,6 +390,7 @@ func PullMultiple(ctx context.Context, paths []string, names map[string]string) // PullMultipleIter pulls changes for multiple repositories sequentially and yields // each per-repository result in input order. func PullMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PullResult] { + ctx = withBackground(ctx) return func(yield func(PullResult) bool) { for _, path := range paths { name := repoName(path, names) @@ -402,6 +417,7 @@ 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) { + ctx = withBackground(ctx) if err := requireAbsolutePath("git.command", dir); err != nil { return "", err } diff --git a/service.go b/service.go index a62a763..bb427c2 100644 --- a/service.go +++ b/service.go @@ -228,7 +228,7 @@ func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map } func (s *Service) validatePath(path string) error { - if !core.PathIsAbs(path) { + if !filepath.IsAbs(path) { return coreerr.E("git.validatePath", "path must be absolute: "+path, nil) } @@ -238,7 +238,7 @@ func (s *Service) validatePath(path string) error { } workDir = filepath.Clean(workDir) - if !core.PathIsAbs(workDir) { + if !filepath.IsAbs(workDir) { return coreerr.E("git.validatePath", "WorkDir must be absolute: "+s.opts.WorkDir, nil) } rel, err := filepath.Rel(workDir, filepath.Clean(path)) From 31c5d4b458fcfda166c9ed78fff827748e10d9e0 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 18:16:35 +0100 Subject: [PATCH 26/26] feat(git): RFC module path alignment + graceful unsupported message handling - go.mod: rename module to dappco.re/go/git per RFC canonical path - docs + CLAUDE.md + llms.txt + README.md: match new module path - service.go: unsupported broadcast/query messages ignored silently instead of logging spurious errors - service_extra_test.go: updated to reflect new no-op behavior Verified: GOWORK=off go test ./... passes No current consumers of old path detected in /Users/snider/Code/core/. Co-Authored-By: Virgil --- CLAUDE.md | 2 +- README.md | 4 ++-- docs/architecture.md | 8 ++++---- docs/development.md | 2 +- docs/index.md | 10 +++++----- go.mod | 2 +- llms.txt | 2 +- service.go | 4 ++-- service_extra_test.go | 10 ++++------ 9 files changed, 21 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0914df4..7d1ebf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Multi-repository git operations library. Parallel status checks, sequential push/pull (for SSH passphrase prompts), error handling with stderr capture. -**Module:** `forge.lthn.ai/core/go-git` +**Module:** `dappco.re/go/git` **Go:** 1.26+ ## Build & Test diff --git a/README.md b/README.md index 591f1e1..4236553 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-git.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-git) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/git.svg)](https://pkg.go.dev/dappco.re/go/git) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) [![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) # go-git -Go module: `forge.lthn.ai/core/go-git` +Go module: `dappco.re/go/git` Agent index: [`llms.txt`](llms.txt) diff --git a/docs/architecture.md b/docs/architecture.md index 822d02f..85edeab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,13 +142,13 @@ The factory constructs a `Service` embedding `core.ServiceRuntime[ServiceOptions ### Lifecycle -`Service` implements the `Startable` interface. On startup, it registers a query handler and a task handler with the Core message bus: +`Service` implements the `Startable` interface. On startup, it registers a query handler and a broadcast action handler with the Core message bus: ```go -func (s *Service) OnStartup(ctx context.Context) error { +func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil + s.Core().RegisterAction(s.handleTaskMessage) + return core.Result{OK: true} } ``` diff --git a/docs/development.md b/docs/development.md index 9fd01b6..f6276f8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,7 @@ description: How to build, test, lint, and contribute to go-git. - **Git** (system binary -- the library shells out to `git`) - **golangci-lint** (for linting) -go-git is part of the Go workspace at `~/Code/go.work`. If you are working within that workspace, module resolution is handled automatically. Otherwise, ensure `GOPRIVATE=forge.lthn.ai/*` is set so Go can fetch private modules. +go-git is part of the Go workspace at `~/Code/go.work`. If you are working within that workspace, module resolution is handled automatically. Otherwise, ensure `GOPRIVATE=dappco.re/*` is set so Go can fetch private modules. ## Running tests diff --git a/docs/index.md b/docs/index.md index 618569d..97f2fd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Multi-repository Git operations library for Go with parallel status # go-git -**Module:** `forge.lthn.ai/core/go-git` +**Module:** `dappco.re/go/git` **Go version:** 1.26+ @@ -13,7 +13,7 @@ description: Multi-repository Git operations library for Go with parallel status ## What it does -go-git is a Go library for orchestrating Git operations across multiple repositories. It was extracted from `forge.lthn.ai/core/go-scm/git/` into a standalone module. +go-git is a Go library for orchestrating Git operations across multiple repositories. It was extracted from `dappco.re/go/core/scm/git/` into a standalone module. The library provides two layers: @@ -33,7 +33,7 @@ import ( "context" "fmt" - git "forge.lthn.ai/core/go-git" + git "dappco.re/go/git" ) func main() { @@ -64,8 +64,8 @@ package main import ( "fmt" - "forge.lthn.ai/core/go/pkg/core" - git "forge.lthn.ai/core/go-git" + "dappco.re/go/core" + git "dappco.re/go/git" ) func main() { diff --git a/go.mod b/go.mod index 3d0663f..b85d152 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module dappco.re/go/core/git +module dappco.re/go/git go 1.26.0 diff --git a/llms.txt b/llms.txt index 9b90135..0c4a95b 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # go-git -Module: `forge.lthn.ai/core/go-git` +Module: `dappco.re/go/git` Purpose: Multi-repository Git operations library with standalone helpers and Core service integration. ## Entry Points diff --git a/service.go b/service.go index bb427c2..e004cfb 100644 --- a/service.go +++ b/service.go @@ -133,7 +133,7 @@ func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result case TaskPullMultiple: return s.handleTask(c, m) default: - return c.LogError(coreerr.E("git.handleTaskMessage", "unsupported task message type", nil), "git.handleTaskMessage", "unsupported task message type") + return core.Result{} } } @@ -162,7 +162,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { case QueryBehindRepos: return core.Result{Value: s.BehindRepos(), OK: true} } - return c.LogError(coreerr.E("git.handleQuery", "unsupported query type", nil), "git.handleQuery", "unsupported query type") + return core.Result{} } func (s *Service) handleTask(c *core.Core, t any) core.Result { diff --git a/service_extra_test.go b/service_extra_test.go index 7853f86..762d5af 100644 --- a/service_extra_test.go +++ b/service_extra_test.go @@ -307,7 +307,7 @@ func TestService_HandleTaskMessage_Good_TaskPush(t *testing.T) { assert.True(t, result.OK) } -func TestService_HandleTaskMessage_Bad_UnknownTask(t *testing.T) { +func TestService_HandleTaskMessage_Ignores_UnknownTask(t *testing.T) { c := core.New() svc := &Service{ @@ -316,8 +316,7 @@ func TestService_HandleTaskMessage_Bad_UnknownTask(t *testing.T) { result := svc.handleTaskMessage(c, struct{}{}) assert.False(t, result.OK) - assert.Error(t, result.Value.(error)) - assert.Contains(t, result.Value.(error).Error(), "unsupported task message type") + assert.Nil(t, result.Value) } func TestService_HandleTask_Bad_UnknownTask(t *testing.T) { @@ -390,7 +389,7 @@ func TestService_Action_Good_TaskPush(t *testing.T) { assert.Equal(t, 0, behind) } -func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { +func TestService_HandleQuery_Ignores_UnknownQuery(t *testing.T) { c := core.New() svc := &Service{ @@ -399,8 +398,7 @@ func TestService_HandleQuery_Good_UnknownQuery(t *testing.T) { result := svc.handleQuery(c, "unknown query type") assert.False(t, result.OK) - assert.Error(t, result.Value.(error)) - assert.Contains(t, result.Value.(error).Error(), "unsupported query type") + assert.Nil(t, result.Value) } func TestService_Action_Bad_PushNoRemote(t *testing.T) {