1 Commits

Author SHA1 Message Date
18cb0e8f65 feat(cmd): implement push, diff, and commit commands
Implement three previously-stubbed commands following the existing
parallel execution pattern used by clone/pull/fetch:

- push: pushes all refs to origin for all configured repos
- diff: shows file-level uncommitted changes across all repos
- commit: commits staged changes across all repos with a shared message (-m)

All commands support parallel execution via -j flag.
Update AGENTS.md to reflect current implementation status.
2026-04-07 08:36:16 +00:00
4 changed files with 319 additions and 21 deletions

View File

@@ -44,11 +44,11 @@ mg/
│ │ ├── register.go # Register repo (implemented) │ │ ├── register.go # Register repo (implemented)
│ │ ├── unregister.go# Unregister repo (implemented) │ │ ├── unregister.go# Unregister repo (implemented)
│ │ ├── import.go # Merge configs (implemented) │ │ ├── import.go # Merge configs (implemented)
│ │ ├── push.go # Stub │ │ ├── push.go # Push all repos (implemented)
│ │ ├── fetch.go # Stub │ │ ├── fetch.go # Fetch all repos (implemented)
│ │ ├── status.go # Stub │ │ ├── status.go # Status all repos (implemented)
│ │ ├── diff.go # Stub │ │ ├── diff.go # Diff all repos (implemented)
│ │ ├── commit.go # Stub │ │ ├── commit.go # Commit staged changes (implemented)
│ │ └── config.go # Stub │ │ └── config.go # Stub
│ └── paths/ │ └── paths/
│ └── mrpaths.go # Utility to list repo paths from mrconfig │ └── mrpaths.go # Utility to list repo paths from mrconfig
@@ -67,11 +67,11 @@ mg/
| `register` | Implemented | Detects git root, stores `$HOME` | | `register` | Implemented | Detects git root, stores `$HOME` |
| `unregister` | Implemented | By path or current dir | | `unregister` | Implemented | By path or current dir |
| `import` | Implemented | Merge configs, supports stdin `-` | | `import` | Implemented | Merge configs, supports stdin `-` |
| `push` | Stub | Prints "push called" | | `push` | Implemented | Parallel via `-j`, pushes all refs |
| `fetch` | Stub | Prints "fetch called" | | `fetch` | Implemented | Parallel via `-j` |
| `status` | Stub | Prints "status called" | | `status` | Implemented | Parallel via `-j`, sorted output |
| `diff` | Stub | Prints "diff called" | | `diff` | Implemented | Parallel via `-j`, file-level diff |
| `commit` | Stub | Prints "commit called" | | `commit` | Implemented | Parallel via `-j`, `-m` message |
| `config` | Stub | Prints "config called" | | `config` | Stub | Prints "config called" |
## Configuration ## Configuration
@@ -221,10 +221,9 @@ go test ./...
## Known Issues / TODOs ## Known Issues / TODOs
1. Several commands are stubs (push, fetch, status, diff, commit, config) 1. `config` command is still a stub
2. `parse/mgconf.go:61` has a hint about inefficient string concatenation in a loop 2. Test coverage is minimal for cmd package (parse package has good coverage)
3. Test coverage is minimal 3. No integration tests for commands that interact with git repos
4. `unregister` command short description incorrectly says "add current path" (copy-paste error)
## Error Handling Pattern ## Error Handling Pattern

View File

@@ -2,19 +2,122 @@ package cmd
import ( import (
"fmt" "fmt"
"log"
"os"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var commitMessage string
// commitCmd represents the commit command // commitCmd represents the commit command
var commitCmd = &cobra.Command{ var commitCmd = &cobra.Command{
Use: "commit", Use: "commit",
Short: "commit all current repos with the same message", Short: "commit staged changes across all repos with the same message",
Run: func(_ *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
fmt.Println("commit called") type RepoError struct {
Error error
Repo string
}
if jobs < 1 {
log.Println("jobs must be greater than 0")
os.Exit(1)
}
if commitMessage == "" {
log.Println("commit message is required (-m)")
os.Exit(1)
}
conf := GetConfig()
if len(args) > 0 {
log.Println("too many arguments")
os.Exit(1)
}
repoChan := make(chan string, len(conf.Repos))
var (
errs []RepoError
skipped int
mutex sync.Mutex
wg sync.WaitGroup
)
wg.Add(len(conf.Repos))
for i := 0; i < jobs; i++ {
go func() {
for repo := range repoChan {
r, err := git.PlainOpenWithOptions(repo, &git.PlainOpenOptions{DetectDotGit: true})
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
w, err := r.Worktree()
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
st, err := w.Status()
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
// Check if there are any staged changes
hasStagedChanges := false
for _, s := range st {
if s.Staging != git.Unmodified && s.Staging != git.Untracked {
hasStagedChanges = true
break
}
}
if !hasStagedChanges {
mutex.Lock()
skipped++
mutex.Unlock()
fmt.Printf("repo %s: nothing staged to commit\n", repo)
wg.Done()
continue
}
_, err = w.Commit(commitMessage, &git.CommitOptions{})
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
log.Printf("commit failed for %s: %v\n", repo, err)
wg.Done()
continue
}
fmt.Printf("successfully committed in %s\n", repo)
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
for _, err := range errs {
log.Printf("error committing %s: %s\n", err.Repo, err.Error)
}
lenErrs := len(errs)
committed := len(conf.Repos) - lenErrs - skipped
fmt.Println()
fmt.Printf("successfully committed %d/%d repos\n", committed, len(conf.Repos))
fmt.Printf("%d repos had nothing staged\n", skipped)
fmt.Printf("failed to commit %d/%d repos\n", lenErrs, len(conf.Repos))
}, },
} }
func init() { func init() {
rootCmd.AddCommand(commitCmd) rootCmd.AddCommand(commitCmd)
commitCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
commitCmd.Flags().StringVarP(&commitMessage, "message", "m", "", "commit message")
} }

View File

@@ -2,19 +2,140 @@ package cmd
import ( import (
"fmt" "fmt"
"log"
"os"
"sort"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type repoDiff struct {
Path string
Changes []string
}
// diffCmd represents the diff command // diffCmd represents the diff command
var diffCmd = &cobra.Command{ var diffCmd = &cobra.Command{
Use: "diff", Use: "diff",
Short: "compute a collective diff", Short: "show uncommitted changes across all repos",
Run: func(cmd *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
fmt.Println("diff called") type RepoError struct {
Error error
Repo string
}
if jobs < 1 {
log.Println("jobs must be greater than 0")
os.Exit(1)
}
conf := GetConfig()
if len(args) > 0 {
log.Println("too many arguments")
os.Exit(1)
}
repoChan := make(chan string, len(conf.Repos))
var (
errs []RepoError
diffs []repoDiff
mutex sync.Mutex
wg sync.WaitGroup
)
wg.Add(len(conf.Repos))
for i := 0; i < jobs; i++ {
go func() {
for repo := range repoChan {
r, err := git.PlainOpenWithOptions(repo, &git.PlainOpenOptions{DetectDotGit: true})
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
w, err := r.Worktree()
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
st, err := w.Status()
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
wg.Done()
continue
}
if st.IsClean() {
wg.Done()
continue
}
rd := repoDiff{Path: repo}
for file, status := range st {
code := status.Worktree
if code == git.Unmodified {
code = status.Staging
}
var prefix string
switch code {
case git.Modified:
prefix = "M"
case git.Added:
prefix = "A"
case git.Deleted:
prefix = "D"
case git.Renamed:
prefix = "R"
case git.Copied:
prefix = "C"
case git.Untracked:
prefix = "?"
default:
continue
}
rd.Changes = append(rd.Changes, fmt.Sprintf(" %s %s", prefix, file))
}
sort.Strings(rd.Changes)
mutex.Lock()
diffs = append(diffs, rd)
mutex.Unlock()
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
sort.Slice(diffs, func(i, j int) bool {
return diffs[i].Path < diffs[j].Path
})
for _, rd := range diffs {
fmt.Printf("%s:\n", rd.Path)
for _, change := range rd.Changes {
fmt.Println(change)
}
fmt.Println()
}
for _, err := range errs {
log.Printf("error reading %s: %s\n", err.Repo, err.Error)
}
fmt.Printf("%d/%d repos have changes\n", len(diffs), len(conf.Repos))
if len(errs) > 0 {
fmt.Printf("failed to read %d/%d repos\n", len(errs), len(conf.Repos))
}
}, },
} }
func init() { func init() {
rootCmd.AddCommand(diffCmd) rootCmd.AddCommand(diffCmd)
diffCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
} }

View File

@@ -2,7 +2,12 @@ package cmd
import ( import (
"fmt" "fmt"
"log"
"os"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -10,11 +15,81 @@ import (
var pushCmd = &cobra.Command{ var pushCmd = &cobra.Command{
Use: "push", Use: "push",
Short: "push all git repos", Short: "push all git repos",
Run: func(cmd *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
fmt.Println("push called") type RepoError struct {
Error error
Repo string
}
if jobs < 1 {
log.Println("jobs must be greater than 0")
os.Exit(1)
}
conf := GetConfig()
if len(args) > 0 {
log.Println("too many arguments")
os.Exit(1)
}
repoChan := make(chan string, len(conf.Repos))
var (
errs []RepoError
alreadyUpToDate int
mutex sync.Mutex
wg sync.WaitGroup
)
wg.Add(len(conf.Repos))
for i := 0; i < jobs; i++ {
go func() {
for repo := range repoChan {
log.Printf("attempting push: %s\n", repo)
r, err := git.PlainOpenWithOptions(repo, &git.PlainOpenOptions{DetectDotGit: true})
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
log.Printf("push failed for %s: %v\n", repo, err)
wg.Done()
continue
}
err = r.Push(&git.PushOptions{
RefSpecs: []config.RefSpec{"refs/heads/*:refs/heads/*"},
})
if err == git.NoErrAlreadyUpToDate {
mutex.Lock()
alreadyUpToDate++
mutex.Unlock()
fmt.Printf("repo %s: already up to date\n", repo)
wg.Done()
continue
} else if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
log.Printf("push failed for %s: %v\n", repo, err)
wg.Done()
continue
}
fmt.Printf("successfully pushed %s\n", repo)
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
for _, err := range errs {
log.Printf("error pushing %s: %s\n", err.Repo, err.Error)
}
lenErrs := len(errs)
fmt.Println()
fmt.Printf("successfully pushed %d/%d repos\n", len(conf.Repos)-lenErrs, len(conf.Repos))
fmt.Printf("%d repos already up to date\n", alreadyUpToDate)
fmt.Printf("failed to push %d/%d repos\n", lenErrs, len(conf.Repos))
}, },
} }
func init() { func init() {
rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pushCmd)
pushCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
} }