From 18cb0e8f657c5cb7e203cb5e08b04dde72f7ec9f Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 7 Apr 2026 08:36:16 +0000 Subject: [PATCH] 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. --- AGENTS.md | 27 +++++---- cmd/mg/cmd/commit.go | 107 +++++++++++++++++++++++++++++++++++- cmd/mg/cmd/diff.go | 127 ++++++++++++++++++++++++++++++++++++++++++- cmd/mg/cmd/push.go | 79 ++++++++++++++++++++++++++- 4 files changed, 319 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 601c256..b042c2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,11 +44,11 @@ mg/ │ │ ├── register.go # Register repo (implemented) │ │ ├── unregister.go# Unregister repo (implemented) │ │ ├── import.go # Merge configs (implemented) -│ │ ├── push.go # Stub -│ │ ├── fetch.go # Stub -│ │ ├── status.go # Stub -│ │ ├── diff.go # Stub -│ │ ├── commit.go # Stub +│ │ ├── push.go # Push all repos (implemented) +│ │ ├── fetch.go # Fetch all repos (implemented) +│ │ ├── status.go # Status all repos (implemented) +│ │ ├── diff.go # Diff all repos (implemented) +│ │ ├── commit.go # Commit staged changes (implemented) │ │ └── config.go # Stub │ └── paths/ │ └── mrpaths.go # Utility to list repo paths from mrconfig @@ -67,11 +67,11 @@ mg/ | `register` | Implemented | Detects git root, stores `$HOME` | | `unregister` | Implemented | By path or current dir | | `import` | Implemented | Merge configs, supports stdin `-` | -| `push` | Stub | Prints "push called" | -| `fetch` | Stub | Prints "fetch called" | -| `status` | Stub | Prints "status called" | -| `diff` | Stub | Prints "diff called" | -| `commit` | Stub | Prints "commit called" | +| `push` | Implemented | Parallel via `-j`, pushes all refs | +| `fetch` | Implemented | Parallel via `-j` | +| `status` | Implemented | Parallel via `-j`, sorted output | +| `diff` | Implemented | Parallel via `-j`, file-level diff | +| `commit` | Implemented | Parallel via `-j`, `-m` message | | `config` | Stub | Prints "config called" | ## Configuration @@ -221,10 +221,9 @@ go test ./... ## Known Issues / TODOs -1. Several commands are stubs (push, fetch, status, diff, commit, config) -2. `parse/mgconf.go:61` has a hint about inefficient string concatenation in a loop -3. Test coverage is minimal -4. `unregister` command short description incorrectly says "add current path" (copy-paste error) +1. `config` command is still a stub +2. Test coverage is minimal for cmd package (parse package has good coverage) +3. No integration tests for commands that interact with git repos ## Error Handling Pattern diff --git a/cmd/mg/cmd/commit.go b/cmd/mg/cmd/commit.go index 3da2ad7..db3fac9 100644 --- a/cmd/mg/cmd/commit.go +++ b/cmd/mg/cmd/commit.go @@ -2,19 +2,122 @@ package cmd import ( "fmt" + "log" + "os" + "sync" + git "github.com/go-git/go-git/v5" "github.com/spf13/cobra" ) +var commitMessage string + // commitCmd represents the commit command var commitCmd = &cobra.Command{ 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) { - 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() { 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") } diff --git a/cmd/mg/cmd/diff.go b/cmd/mg/cmd/diff.go index b1f4f96..7677941 100644 --- a/cmd/mg/cmd/diff.go +++ b/cmd/mg/cmd/diff.go @@ -2,19 +2,140 @@ package cmd import ( "fmt" + "log" + "os" + "sort" + "sync" + git "github.com/go-git/go-git/v5" "github.com/spf13/cobra" ) +type repoDiff struct { + Path string + Changes []string +} + // diffCmd represents the diff command var diffCmd = &cobra.Command{ Use: "diff", - Short: "compute a collective diff", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("diff called") + Short: "show uncommitted changes across all repos", + Run: func(_ *cobra.Command, args []string) { + 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() { rootCmd.AddCommand(diffCmd) + diffCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel") } diff --git a/cmd/mg/cmd/push.go b/cmd/mg/cmd/push.go index 8a2f511..06e00f8 100644 --- a/cmd/mg/cmd/push.go +++ b/cmd/mg/cmd/push.go @@ -2,7 +2,12 @@ package cmd import ( "fmt" + "log" + "os" + "sync" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" "github.com/spf13/cobra" ) @@ -10,11 +15,81 @@ import ( var pushCmd = &cobra.Command{ Use: "push", Short: "push all git repos", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("push called") + Run: func(_ *cobra.Command, args []string) { + 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() { rootCmd.AddCommand(pushCmd) + pushCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel") }