diff --git a/Git-Operations.-.md b/Git-Operations.-.md new file mode 100644 index 0000000..051a1b6 --- /dev/null +++ b/Git-Operations.-.md @@ -0,0 +1,196 @@ +# Git Operations + +**Package:** `forge.lthn.ai/core/go-scm/git` + +Multi-repository git utilities providing parallel status checks, push/pull operations, and integration with the Core framework as a registered service. + +## Overview + +The `git` package wraps `git` CLI commands to operate across multiple repositories simultaneously. It is used by the `core dev` CLI commands (`work`, `status`, `push`, `pull`) to manage the federated monorepo. + +## RepoStatus + +The `RepoStatus` struct captures a snapshot of a single repository's state: + +```go +type RepoStatus struct { + Name string // Display name + Path string // Filesystem path + Modified int // Files modified in working tree + Untracked int // Untracked files + Staged int // Files staged in index + Ahead int // Commits ahead of upstream + Behind int // Commits behind upstream + Branch string // Current branch name + Error error // Any error during status check +} +``` + +### Helper Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `IsDirty()` | `bool` | Has uncommitted changes (Modified > 0 or Untracked > 0 or Staged > 0) | +| `HasUnpushed()` | `bool` | Has commits to push (Ahead > 0) | +| `HasUnpulled()` | `bool` | Has commits to pull (Behind > 0) | + +## Parallel Status Checks + +The `Status` function checks multiple repositories concurrently using goroutines and a WaitGroup: + +```go +statuses := git.Status(ctx, git.StatusOptions{ + Paths: []string{ + "/Users/snider/Code/host-uk/core-php", + "/Users/snider/Code/host-uk/core-tenant", + "/Users/snider/Code/host-uk/core-admin", + }, + Names: map[string]string{ + "/Users/snider/Code/host-uk/core-php": "core-php", + "/Users/snider/Code/host-uk/core-tenant": "core-tenant", + "/Users/snider/Code/host-uk/core-admin": "core-admin", + }, +}) + +for _, s := range statuses { + if s.Error != nil { + fmt.Printf("%s: error: %v\n", s.Name, s.Error) + continue + } + if s.IsDirty() { + fmt.Printf("%s [%s]: %d modified, %d untracked, %d staged\n", + s.Name, s.Branch, s.Modified, s.Untracked, s.Staged) + } + if s.HasUnpushed() { + fmt.Printf("%s: %d commits ahead\n", s.Name, s.Ahead) + } +} +``` + +### StatusOptions + +```go +type StatusOptions struct { + Paths []string // Repository paths to check + Names map[string]string // Maps paths to display names +} +``` + +## Push and Pull + +Push and pull operations use interactive mode to support SSH passphrase prompts: + +```go +// Push a single repository +err := git.Push(ctx, "/path/to/repo") + +// Pull a single repository (with --rebase) +err := git.Pull(ctx, "/path/to/repo") + +// Check if error is a non-fast-forward rejection +if git.IsNonFastForward(err) { + fmt.Println("Need to pull first") +} +``` + +### PushMultiple + +Push multiple repositories sequentially (sequential because SSH passphrase prompts need user interaction): + +```go +results := git.PushMultiple(ctx, paths, names) + +for _, r := range results { + if r.Success { + fmt.Printf("%s: pushed successfully\n", r.Name) + } else { + fmt.Printf("%s: push failed: %v\n", r.Name, r.Error) + } +} +``` + +#### PushResult + +```go +type PushResult struct { + Name string + Path string + Success bool + Error error +} +``` + +## GitError + +Git command errors include captured stderr output for better diagnostics: + +```go +type GitError struct { + Err error // Underlying exec error + Stderr string // Captured stderr output +} + +func (e *GitError) Error() string // Returns stderr if available, else Err.Error() +func (e *GitError) Unwrap() error // Returns Err for error chain inspection +``` + +## Core Service Integration + +The `git` package integrates with the Core framework as a registered service, exposing git operations via the query/task message-passing system: + +```go +import ( + "forge.lthn.ai/core/go/pkg/framework" + "forge.lthn.ai/core/go-scm/git" +) + +core, err := framework.New( + framework.WithService(git.NewService(git.ServiceOptions{ + WorkDir: "/Users/snider/Code/host-uk", + })), +) +``` + +### Queries + +| Query Type | Returns | Description | +|-----------|---------|-------------| +| `QueryStatus{Paths, Names}` | `[]RepoStatus` | Run parallel status check | +| `QueryDirtyRepos{}` | `[]RepoStatus` | Get repos with uncommitted changes (from last status) | +| `QueryAheadRepos{}` | `[]RepoStatus` | Get repos with unpushed commits (from last status) | + +### Tasks + +| Task Type | Description | +|----------|-------------| +| `TaskPush{Path, Name}` | Push a single repository | +| `TaskPull{Path, Name}` | Pull a single repository | +| `TaskPushMultiple{Paths, Names}` | Push multiple repositories sequentially | + +### Service Methods + +The service also exposes direct method access: + +```go +svc := framework.ServiceFor[*git.Service](core) + +// Get last status results +statuses := svc.Status() + +// Get filtered views +dirty := svc.DirtyRepos() +ahead := svc.AheadRepos() +``` + +## Implementation Details + +- **Parallel execution**: `Status` uses one goroutine per repository with `sync.WaitGroup`, maintaining result order via indexed slice +- **Branch detection**: Uses `git rev-parse --abbrev-ref HEAD` +- **Status parsing**: Uses `git status --porcelain` and parses the two-character status codes (X = index, Y = working tree) +- **Ahead/behind**: Uses `git rev-list --count @{u}..HEAD` and `HEAD..@{u}` respectively; silently returns 0 if no upstream is configured +- **Interactive push/pull**: Connects stdin/stdout/stderr to the terminal for SSH passphrase prompts, while also capturing stderr via `io.MultiWriter` for error reporting + +## See Also + +- [[Home]] -- Package overview and quick start +- [[Job-Runner]] -- Automated pipeline that may trigger push operations