45 Commits

Author SHA1 Message Date
6bd40cb1d2 Merge pull request #4 from taigrr/cd/dep-updates-go126
feat(cmd): implement fetch and status commands, update deps
2026-03-07 02:43:48 -05:00
348b87702d feat(cmd): implement fetch and status commands, update deps to go1.26.1
- Implement fetch command: fetches all registered repos without merging,
  supports parallel execution via -j/--jobs flag
- Implement status command: shows uncommitted changes across all repos
  with per-file-type counts, supports parallel execution
- Update Go to 1.26.1
- Update go-git v5.16.5 -> v5.17.0, go-crypto v1.3.0 -> v1.4.0,
  go-billy v5.7.0 -> v5.8.0, x/net v0.50.0 -> v0.51.0
- Fix unregister command description (said 'add' instead of 'remove')
- Fix goimports formatting in mgconf_test.go
2026-03-07 07:03:49 +00:00
9d4ac7bc47 Merge pull request #2 from taigrr/burrow/refactor-alias-loading
refactor: dynamically load all [DEFAULT] section aliases
2026-02-23 00:04:21 -05:00
a4044ba47a Merge pull request #3 from taigrr/burrow/dep-update-feb-2026
chore(deps): update Go dependencies
2026-02-22 20:59:39 -05:00
e579599876 chore(deps): update Go dependencies
Updated:
- go-git/go-git v5.16.4 → v5.16.5
- cloudflare/circl v1.6.2 → v1.6.3
- kevinburke/ssh_config v1.4.0 → v1.6.0
- golang.org/x/crypto v0.47.0 → v0.48.0
- golang.org/x/net v0.49.0 → v0.50.0
- golang.org/x/sys v0.40.0 → v0.41.0
- Removed unused golang.org/x/mod and golang.org/x/tools
2026-02-19 08:01:55 +00:00
9ad9412a8c refactor: dynamically load all [DEFAULT] section aliases
Removed hardcoded alias handling (unregister, git_gc) in favor of
dynamically loading all key-value pairs from the [DEFAULT] section
into the Aliases map. This allows mrconfig files to define custom
aliases without code changes.

- Simplified switch statement in LoadMRConfig
- Updated function documentation
- All tests passing
2026-02-12 13:33:30 +00:00
50f5bc897d update go deps 2026-01-18 02:30:38 -05:00
0f925f852e update env var expansion logic 2026-01-18 02:29:26 -05:00
cacdbf673f update to use /Users/tai 2025-08-10 15:15:50 -07:00
Tai Groot
991985ab4e update modeperm for directory creation on clone 2024-09-16 12:12:04 -07:00
417cf943fa fixup help text 2024-09-15 13:08:02 -07:00
1030a8f3a9 add cloning 2024-09-15 13:01:55 -07:00
f0c6f3906a fix print statements 2024-02-02 16:28:41 -08:00
01c736b54e fixup clone error 2024-02-02 16:24:06 -08:00
e93a0489d3 register clone cmd 2024-02-02 13:32:06 -08:00
567ab899e0 fix conflict 2024-02-02 13:29:27 -08:00
a889092b0d update dependencies 2024-02-02 12:29:47 -08:00
1a593ad588 update go mod 2024-01-08 09:22:41 -08:00
c5310d13b0 update deps 2023-10-14 00:00:03 -07:00
256e82cca2 update deps 2023-09-08 23:06:16 -07:00
b99b1eabec add stringer for stats 2023-08-28 18:22:55 -07:00
f503d4b0eb fix import registration 2023-08-28 18:11:17 -07:00
f368381458 update crypto deps 2023-08-28 18:09:21 -07:00
a5817c554b add merge functionality to mg 2023-08-28 18:08:35 -07:00
15122346d1 upgrade go git deps 2023-08-23 16:10:14 -07:00
c2d67df8f0 mod tidy 2023-07-24 23:53:27 -07:00
84d7a73202 upgrade to fix github.com/go-git/go-git/issues/328 2023-07-24 23:52:34 -07:00
662d80fbf5 ignore built binary 2023-07-03 16:36:01 -07:00
e4475de48c don't fail because already registered 2023-05-28 13:08:36 -07:00
82be16a6ba update readme 2023-05-27 21:09:48 -07:00
a4a3f1f332 placeholder config cmd added 2023-05-26 19:24:42 -07:00
15cbf5c0a0 fix pack from: https://github.com/go-git/go-git/issues/328 2023-05-26 18:50:50 -07:00
2085e2b8b7 add pull command 2023-05-26 14:45:13 -07:00
2818cfb61d add unregister command 2023-05-25 16:28:43 -07:00
809d4a0711 update messaging 2023-05-25 16:15:11 -07:00
cdb17168b3 add register command 2023-05-25 16:12:33 -07:00
399faba8ef add in cobra stubs for the subcommands 2023-05-25 15:30:38 -07:00
9a4f8023d8 add mgconfig conversion to lib 2023-05-25 15:07:06 -07:00
93a21b8c61 add test stub and docstrings 2023-05-10 22:51:16 -07:00
28f0329d4a bump go version and fix invalid nil assignment 2023-05-06 16:00:24 -07:00
97c2e898fc update sponsors 2023-03-09 16:50:18 -08:00
5929432d08 add mgconf 2023-03-09 16:28:07 -08:00
7455da4355 start work on more mr config, add readme 2023-03-08 17:21:02 -08:00
144d4f6b87 Update README.md 2023-01-30 14:41:44 -08:00
f28b5df9ef Create README.md 2023-01-30 14:03:54 -08:00
26 changed files with 2006 additions and 18 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: taigrr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
cmd/mg/mg
.crush

234
AGENTS.md Normal file
View File

@@ -0,0 +1,234 @@
# AGENTS.md
Agent guide for the `mg` codebase - a Go replacement for [myrepos](https://myrepos.branchable.com/) that only supports git repos.
## Project Overview
`mg` is a CLI tool for managing multiple git repositories simultaneously. It uses `go-git/go-git` for pure Go git operations (no external git dependency required) and `spf13/cobra` for CLI structure.
### Key Features
- Parallel operations via `-j` flag
- Compatible with existing `~/.mrconfig` files (auto-migrates to `mgconfig`)
- Pure Go implementation - no external git binary needed
- Embeddable as a library
## Commands
```bash
# Build
go build ./...
# Run tests
go test ./...
# Install the binary
go install ./cmd/mg
# Run directly
go run ./cmd/mg <command>
```
## Project Structure
```
mg/
├── cmd/
│ ├── mg/
│ │ ├── main.go # Entry point
│ │ └── cmd/ # Cobra commands
│ │ ├── root.go # Root command setup
│ │ ├── common.go # Shared utilities (GetConfig)
│ │ ├── clone.go # Clone all repos (implemented)
│ │ ├── pull.go # Pull all repos (implemented)
│ │ ├── 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
│ │ └── config.go # Stub
│ └── paths/
│ └── mrpaths.go # Utility to list repo paths from mrconfig
└── parse/
├── mgconf.go # MGConfig: JSON-based config format
├── myrepos.go # MRConfig: Parse ~/.mrconfig (INI-style)
└── myrepos_test.go # Tests (skeleton)
```
## Implementation Status
| Command | Status | Notes |
|--------------|-------------|------------------------------------|
| `clone` | Implemented | Parallel via `-j`, creates dirs |
| `pull` | Implemented | Parallel via `-j` |
| `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" |
| `config` | Stub | Prints "config called" |
## Configuration
### Config File Location
1. `$MGCONFIG` environment variable (if set)
2. `$XDG_CONFIG_HOME/mgconfig`
3. `~/.config/mgconfig`
### Config Format (JSON)
```json
{
"Repos": [
{
"Path": "$HOME/code/project",
"Remote": "git@github.com:user/project.git"
}
],
"Aliases": {}
}
```
### Migration from myrepos
If no `mgconfig` exists but `~/.mrconfig` does, `mg` auto-migrates on first run. The `MRConfig.ToMGConfig()` method handles conversion.
## Code Patterns
### Adding a New Command
1. Create `cmd/mg/cmd/<name>.go`
2. Define a `cobra.Command` variable
3. Register in `init()` via `rootCmd.AddCommand()`
4. For parallel operations, follow the pattern in `clone.go` or `pull.go`:
```go
var myCmd = &cobra.Command{
Use: "mycommand",
Short: "description",
Run: func(_ *cobra.Command, args []string) {
conf := GetConfig() // Load config with fallback to mrconfig
// Implementation...
},
}
func init() {
rootCmd.AddCommand(myCmd)
myCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of parallel jobs")
}
```
### Parallel Execution Pattern
Used in `clone.go` and `pull.go`:
```go
repoChan := make(chan RepoType, len(repos))
wg := sync.WaitGroup{}
mutex := sync.Mutex{}
errs := []Error{}
wg.Add(len(repos))
for i := 0; i < jobs; i++ {
go func() {
for repo := range repoChan {
// Do work
// Use mutex for shared state (errs, counters)
wg.Done()
}
}()
}
for _, repo := range repos {
repoChan <- repo
}
close(repoChan)
wg.Wait()
```
### Git Operations
Use `go-git/go-git/v5`:
```go
import git "github.com/go-git/go-git/v5"
// Open repo (detects .git in parent dirs)
r, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{DetectDotGit: true})
// Clone
_, err = git.PlainClone(path, false, &git.CloneOptions{URL: remote})
// Pull
w, _ := r.Worktree()
err = w.Pull(&git.PullOptions{})
// Check: err == git.NoErrAlreadyUpToDate
```
### Path Handling
- Paths in config use `$HOME` prefix for portability
- `GetConfig()` in `common.go` expands `$HOME` to actual home directory at runtime
- `register` command stores paths with `$HOME` prefix
## Key Types
### `parse.MGConfig`
```go
type MGConfig struct {
Repos []Repo
Aliases map[string]string
}
// Methods
func LoadMGConfig() (MGConfig, error)
func (m *MGConfig) AddRepo(path, remote string) error
func (m *MGConfig) DelRepo(path string) error
func (m *MGConfig) Merge(m2 MGConfig) (Stats, error)
func (m MGConfig) Save() error
```
### `parse.Repo`
```go
type Repo struct {
Path string
Remote string
Aliases map[string]string `json:"aliases,omitempty"`
}
```
## Dependencies
- `github.com/go-git/go-git/v5` - Pure Go git implementation
- `github.com/spf13/cobra` - CLI framework
## Testing
Tests are minimal. Only `parse/myrepos_test.go` exists with a skeleton structure:
```bash
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)
## Error Handling Pattern
Commands typically:
1. Log errors via `log.Println(err)`
2. Exit with `os.Exit(1)` on fatal errors
3. Collect errors during parallel operations and report summary at end

32
README.md Normal file
View File

@@ -0,0 +1,32 @@
# mg
Golang replacement for [myrepos](https://myrepos.branchable.com/) which only supports git repos.
This app will support the following subcommands:
- mg commit
- mg push
- mg status
- mg diff
- mg pull
- mg fetch
- mg register
- mg unregister
Passing the `-jX` argument will spin up X jobs simultaneously
mg supports loading an existing ~/.mrconfig and migrating it to ~/.config/mg.conf, provided no mg.conf file exists.
## Improvements over mr:
1. No external dependencies (even git!*)
1. More output options (summary of failures)
1. More deterministic behavior (global vs local run, register from git project subdir)
1. Exports public functions and can be embedded into other Go programs idiomatically
## Why to stick with mr:
1. If you need support for non-git VCS tooling
1. If you want to use the [mr plugin ecosystem](https://myrepos.branchable.com/#:~:text=repos%20to%20myrepos-,related%20software,-garden%3A%20manage%20git)
*: custom-registered commands may rely on external applications.

1
cmd/mg/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
main

106
cmd/mg/cmd/clone.go Normal file
View File

@@ -0,0 +1,106 @@
package cmd
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
"github.com/taigrr/mg/parse"
)
// cloneCmd represents the clone command
var (
cloneCmd = &cobra.Command{
Use: "clone",
Short: "ensure all repos defined in the config are cloned",
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 parse.Repo, len(conf.Repos))
errs := []RepoError{}
alreadyCloned := 0
mutex := sync.Mutex{}
wg := sync.WaitGroup{}
wg.Add(len(conf.Repos))
cloneFunc := func() {
for repo := range repoChan {
_, err := git.PlainOpenWithOptions(repo.Path, &(git.PlainOpenOptions{DetectDotGit: true}))
if err == nil {
log.Printf("already cloned: %s\n", repo.Path)
mutex.Lock()
alreadyCloned++
mutex.Unlock()
wg.Done()
continue
} else if err == git.ErrRepositoryNotExists {
log.Printf("attempting clone: %s\n", repo.Path)
parentPath := filepath.Dir(repo.Path)
if _, err := os.Stat(parentPath); err != nil {
os.MkdirAll(parentPath, os.ModeDir|os.ModePerm)
}
_, err = git.PlainClone(repo.Path, false, &git.CloneOptions{
URL: repo.Remote,
})
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo.Path})
mutex.Unlock()
log.Printf("clone failed for %s: %v\n", repo.Path, err)
wg.Done()
continue
}
fmt.Printf("successfully cloned %s\n", repo.Path)
wg.Done()
continue
} else {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo.Path})
mutex.Unlock()
log.Printf("clone failed for %s: %v\n", repo.Path, err)
wg.Done()
continue
}
}
}
for i := 0; i < jobs; i++ {
go cloneFunc()
}
fmt.Println(len(conf.Repos))
for _, repo := range conf.Repos {
repoChan <- repo
}
close(repoChan)
fmt.Println("waiting...")
wg.Wait()
for _, err := range errs {
log.Printf("error pulling %s: %s\n", err.Repo, err.Error)
}
lenErrs := len(errs)
fmt.Println()
fmt.Printf("successfully cloned %d/%d repos\n", len(conf.Repos)-lenErrs, len(conf.Repos))
fmt.Printf("%d repos already cloned\n", alreadyCloned)
fmt.Printf("failed to clone %d/%d repos\n", lenErrs, len(conf.Repos))
},
}
)
func init() {
rootCmd.AddCommand(cloneCmd)
cloneCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
}

20
cmd/mg/cmd/commit.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// commitCmd represents the commit command
var commitCmd = &cobra.Command{
Use: "commit",
Short: "commit all current repos with the same message",
Run: func(_ *cobra.Command, args []string) {
fmt.Println("commit called")
},
}
func init() {
rootCmd.AddCommand(commitCmd)
}

32
cmd/mg/cmd/common.go Normal file
View File

@@ -0,0 +1,32 @@
package cmd
import (
"log"
"os"
"github.com/taigrr/mg/parse"
)
func GetConfig() parse.MGConfig {
conf, err := parse.LoadMGConfig()
if err != nil {
if os.IsNotExist(err) {
// Try to load mr config instead
mrconf, err := parse.LoadMRConfig()
if err != nil {
log.Println(err)
os.Exit(1)
}
conf = mrconf.ToMGConfig()
log.Println("migrated mrconfig to mgconfig")
err = conf.Save()
if err != nil {
log.Println(err)
os.Exit(1)
}
}
}
conf.ExpandPaths()
return conf
}

21
cmd/mg/cmd/config.go Normal file
View File

@@ -0,0 +1,21 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// configCmd represents the config command
var configCmd = &cobra.Command{
Use: "config",
Short: "",
Long: ``,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("config called")
},
}
func init() {
rootCmd.AddCommand(configCmd)
}

20
cmd/mg/cmd/diff.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// 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")
},
}
func init() {
rootCmd.AddCommand(diffCmd)
}

91
cmd/mg/cmd/fetch.go Normal file
View File

@@ -0,0 +1,91 @@
package cmd
import (
"fmt"
"log"
"os"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
var fetchCmd = &cobra.Command{
Use: "fetch",
Short: "fetch all git repos without merging",
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
alreadyFetched 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 fetch: %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("fetch failed for %s: %v\n", repo, err)
wg.Done()
continue
}
err = r.Fetch(&git.FetchOptions{})
if err == git.NoErrAlreadyUpToDate {
mutex.Lock()
alreadyFetched++
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("fetch failed for %s: %v\n", repo, err)
wg.Done()
continue
}
fmt.Printf("successfully fetched %s\n", repo)
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
for _, err := range errs {
log.Printf("error fetching %s: %s\n", err.Repo, err.Error)
}
lenErrs := len(errs)
fmt.Println()
fmt.Printf("successfully fetched %d/%d repos\n", len(conf.Repos)-lenErrs, len(conf.Repos))
fmt.Printf("%d repos already up to date\n", alreadyFetched)
fmt.Printf("failed to fetch %d/%d repos\n", lenErrs, len(conf.Repos))
},
}
func init() {
rootCmd.AddCommand(fetchCmd)
fetchCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
}

64
cmd/mg/cmd/import.go Normal file
View File

@@ -0,0 +1,64 @@
package cmd
import (
"fmt"
"io"
"log"
"os"
"github.com/spf13/cobra"
"github.com/taigrr/mg/parse"
)
var importCmd = &cobra.Command{
Use: "import <file>",
Short: "merge a new mgconfig into the current one",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
conf := GetConfig()
if args[0] == "-" {
f, err := io.ReadAll(os.Stdin)
if err != nil {
log.Println(err)
os.Exit(1)
}
parsed, err := parse.ParseMGConfig(f)
if err != nil {
log.Println(err)
os.Exit(1)
}
stats, err := conf.Merge(parsed)
if err != nil {
log.Println(err)
os.Exit(1)
}
fmt.Println(stats)
} else {
f, err := os.ReadFile(args[0])
if err != nil {
log.Println(err)
os.Exit(1)
}
parsed, err := parse.ParseMGConfig(f)
if err != nil {
log.Println(err)
os.Exit(1)
}
stats, err := conf.Merge(parsed)
if err != nil {
log.Println(err)
os.Exit(1)
}
fmt.Println(stats)
}
err := conf.Save()
if err != nil {
log.Println(err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(importCmd)
}

103
cmd/mg/cmd/pull.go Normal file
View File

@@ -0,0 +1,103 @@
package cmd
import (
"fmt"
"log"
"os"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
// pullCmd represents the pull command
var (
jobs int
pullCmd = &cobra.Command{
Use: "pull",
Short: "update all git repos specified in config",
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))
errs := []RepoError{}
alreadyUpToDate := 0
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 pull: %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("pull failed for %s: %v\n", repo, err)
wg.Done()
continue
}
w, err := r.Worktree()
if err != nil {
mutex.Lock()
errs = append(errs, RepoError{Error: err, Repo: repo})
mutex.Unlock()
log.Printf("pull failed for %s: %v\n", repo, err)
wg.Done()
continue
}
err = w.Pull(&git.PullOptions{})
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("pull failed for %s: %v\n", repo, err)
wg.Done()
continue
} else {
fmt.Printf("successfully pulled %s\n", w.Filesystem.Root())
}
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
for _, err := range errs {
log.Printf("error pulling %s: %s\n", err.Repo, err.Error)
}
lenErrs := len(errs)
fmt.Println()
fmt.Printf("successfully pulled %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 pull %d/%d repos\n", lenErrs, len(conf.Repos))
},
}
)
func init() {
rootCmd.AddCommand(pullCmd)
pullCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
}

20
cmd/mg/cmd/push.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// pushCmd represents the push command
var pushCmd = &cobra.Command{
Use: "push",
Short: "push all git repos",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("push called")
},
}
func init() {
rootCmd.AddCommand(pushCmd)
}

73
cmd/mg/cmd/register.go Normal file
View File

@@ -0,0 +1,73 @@
package cmd
import (
"fmt"
"log"
"os"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
var registerCmd = &cobra.Command{
Use: "register",
Short: "add current path to list of repos",
Run: func(_ *cobra.Command, args []string) {
conf := GetConfig()
path, err := os.Getwd()
if err != nil {
log.Println(err)
os.Exit(1)
}
if len(args) == 1 {
path = args[0]
} else if len(args) > 1 {
log.Println("too many arguments")
os.Exit(1)
}
r, err := git.PlainOpenWithOptions(path, &(git.PlainOpenOptions{DetectDotGit: true}))
if err != nil {
log.Println(err)
os.Exit(1)
}
remotes, err := r.Remotes()
if err != nil {
log.Println(err)
os.Exit(1)
}
if len(remotes) == 0 {
log.Println("no remotes found")
os.Exit(1)
}
remote := remotes[0]
urls := remote.Config().URLs
if len(urls) == 0 {
log.Println("no urls found for remote")
os.Exit(1)
}
url := urls[0]
newPath, err := r.Worktree()
if err != nil {
log.Println(err)
os.Exit(1)
}
path = newPath.Filesystem.Root()
for _, v := range conf.Repos {
if v.Path == path {
fmt.Printf("repo %s already registered\n", path)
os.Exit(0)
}
}
conf.AddRepo(path, url)
err = conf.Save()
if err != nil {
log.Println(err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(registerCmd)
}

52
cmd/mg/cmd/root.go Normal file
View File

@@ -0,0 +1,52 @@
package cmd
import (
"os"
"runtime/debug"
"github.com/spf13/cobra"
)
func getVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
var revision, dirty string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
revision = s.Value
case "vcs.modified":
if s.Value == "true" {
dirty = "-dirty"
}
}
}
if revision != "" {
if len(revision) > 7 {
revision = revision[:7]
}
return revision + dirty
}
return "dev"
}
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mg",
Short: "go replacement for myrepos which only supports git repos",
Version: getVersion(),
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

157
cmd/mg/cmd/status.go Normal file
View File

@@ -0,0 +1,157 @@
package cmd
import (
"fmt"
"log"
"os"
"sort"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
type repoStatus struct {
Path string
Modified int
Added int
Deleted int
Renamed int
Copied int
Untrack int
Clean bool
}
var statusCmd = &cobra.Command{
Use: "status",
Short: "get the combined git status for all git 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
statuses []repoStatus
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
}
rs := repoStatus{Path: repo, Clean: st.IsClean()}
for _, s := range st {
code := s.Worktree
if code == git.Unmodified {
code = s.Staging
}
switch code {
case git.Modified:
rs.Modified++
case git.Added:
rs.Added++
case git.Deleted:
rs.Deleted++
case git.Renamed:
rs.Renamed++
case git.Copied:
rs.Copied++
case git.Untracked:
rs.Untrack++
}
}
mutex.Lock()
statuses = append(statuses, rs)
mutex.Unlock()
wg.Done()
}
}()
}
for _, repo := range conf.Repos {
repoChan <- repo.Path
}
close(repoChan)
wg.Wait()
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Path < statuses[j].Path
})
dirtyCount := 0
for _, rs := range statuses {
if rs.Clean {
continue
}
dirtyCount++
fmt.Printf("%s:\n", rs.Path)
if rs.Modified > 0 {
fmt.Printf(" modified: %d\n", rs.Modified)
}
if rs.Added > 0 {
fmt.Printf(" added: %d\n", rs.Added)
}
if rs.Deleted > 0 {
fmt.Printf(" deleted: %d\n", rs.Deleted)
}
if rs.Renamed > 0 {
fmt.Printf(" renamed: %d\n", rs.Renamed)
}
if rs.Copied > 0 {
fmt.Printf(" copied: %d\n", rs.Copied)
}
if rs.Untrack > 0 {
fmt.Printf(" untracked: %d\n", rs.Untrack)
}
}
for _, err := range errs {
log.Printf("error reading %s: %s\n", err.Repo, err.Error)
}
fmt.Println()
fmt.Printf("%d/%d repos have uncommitted changes\n", dirtyCount, 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(statusCmd)
statusCmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "number of jobs to run in parallel")
}

60
cmd/mg/cmd/unregister.go Normal file
View File

@@ -0,0 +1,60 @@
package cmd
import (
"log"
"os"
git "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
)
// unregisterCmd represents the unregister command
var unregisterCmd = &cobra.Command{
Use: "unregister",
Short: "remove current path from list of repos",
Run: func(_ *cobra.Command, args []string) {
conf := GetConfig()
path, err := os.Getwd()
if err != nil {
log.Println(err)
os.Exit(1)
}
if len(args) == 1 {
path = args[0]
err = conf.DelRepo(path)
if err != nil {
log.Println(err)
os.Exit(1)
}
return
} else if len(args) > 1 {
log.Println("too many arguments")
os.Exit(1)
}
r, err := git.PlainOpenWithOptions(path, &(git.PlainOpenOptions{DetectDotGit: true}))
if err != nil {
log.Println(err)
os.Exit(1)
}
newPath, err := r.Worktree()
if err != nil {
log.Println(err)
os.Exit(1)
}
path = newPath.Filesystem.Root()
err = conf.DelRepo(path)
if err != nil {
log.Println(err)
os.Exit(1)
}
err = conf.Save()
if err != nil {
log.Println(err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(unregisterCmd)
}

9
cmd/mg/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"github.com/taigrr/mg/cmd/mg/cmd"
)
func main() {
cmd.Execute()
}

32
go.mod
View File

@@ -1,3 +1,33 @@
module github.com/taigrr/mg module github.com/taigrr/mg
go 1.19 go 1.26.1
require (
github.com/go-git/go-git/v5 v5.17.0
github.com/spf13/cobra v1.10.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

114
go.sum Normal file
View File

@@ -0,0 +1,114 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

172
parse/mgconf.go Normal file
View File

@@ -0,0 +1,172 @@
package parse
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
var errAlreadyRegistered = os.ErrExist
// MGConfig is the struct that represents the mgconfig file
// It contains a slice of Repo structs and a map of aliases
// The aliases map is a map of strings to strings, where the key is the alias
// and the value is a command to be run
type MGConfig struct {
Repos []Repo
Aliases map[string]string
}
// GetRepoPaths returns a slice of strings containing the paths of the repos
// in the mgconfig file
func (m MGConfig) GetRepoPaths() []string {
paths := []string{}
for _, r := range m.Repos {
paths = append(paths, r.Path)
}
return paths
}
func (m *MGConfig) DelRepo(path string) error {
for i, v := range m.Repos {
if v.Path == path {
m.Repos = append(m.Repos[:i], m.Repos[i+1:]...)
return nil
}
}
return os.ErrNotExist
}
func (m *MGConfig) AddRepo(path, remote string) error {
for _, v := range m.Repos {
if v.Path == path {
return errAlreadyRegistered
}
}
m.Repos = append(m.Repos, Repo{Path: path, Remote: remote})
return nil
}
type Stats struct {
Duplicates int
NewPaths []string
}
func (s Stats) String() string {
str := ""
for _, v := range s.NewPaths {
str += "Added repo " + v + "\n"
}
str += "\nAdded " + fmt.Sprintf("%d", len(s.NewPaths)) + " new repos\n"
str += "Skipped " + fmt.Sprintf("%d", s.Duplicates) + " duplicate repos"
return str
}
func (m *MGConfig) Merge(m2 MGConfig) (Stats, error) {
stats := Stats{}
for _, v := range m2.Repos {
err := m.AddRepo(v.Path, v.Remote)
switch err {
case errAlreadyRegistered:
stats.Duplicates++
continue
case nil:
stats.NewPaths = append(stats.NewPaths, v.Path)
continue
default:
return stats, err
}
}
return stats, nil
}
// LoadMGConfig loads the mgconfig file from the XDG_CONFIG_HOME directory
// or from the default location of $HOME/.config/mgconfig
// If the file is not found, an error is returned
func LoadMGConfig() (MGConfig, error) {
mgConf := os.Getenv("MGCONFIG")
if mgConf == "" {
confDir := os.Getenv("XDG_CONFIG_HOME")
if confDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return MGConfig{}, err
}
confDir = filepath.Join(home, ".config")
if _, err := os.Stat(confDir); err != nil {
return MGConfig{}, err
}
}
mgConf = filepath.Join(confDir, "mgconfig")
}
file, err := os.ReadFile(mgConf)
if err != nil {
return MGConfig{}, err
}
return ParseMGConfig(file)
}
// ParseMGConfig parses the mgconfig file from a byte slice
func ParseMGConfig(b []byte) (MGConfig, error) {
var config MGConfig
err := json.Unmarshal(b, &config)
return config, err
}
// ExpandPaths expands shell variables in all repo paths using os.ExpandEnv.
// This allows paths like $HOME/code or $GOPATH/src to work cross-platform.
func (m *MGConfig) ExpandPaths() {
for i := range m.Repos {
m.Repos[i].Path = os.ExpandEnv(m.Repos[i].Path)
}
}
// CollapsePaths replaces the user's home directory with $HOME in all repo paths.
// This allows config files to be shared across machines with different home paths.
func (m *MGConfig) CollapsePaths() {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return
}
for i := range m.Repos {
if strings.HasPrefix(m.Repos[i].Path, home) {
m.Repos[i].Path = "$HOME" + m.Repos[i].Path[len(home):]
}
}
}
func (m MGConfig) Save() error {
mgConf := os.Getenv("MGCONFIG")
if mgConf == "" {
confDir := os.Getenv("XDG_CONFIG_HOME")
if confDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
confDir = filepath.Join(home, ".config")
if _, err := os.Stat(confDir); err != nil {
return err
}
}
mgConf = filepath.Join(confDir, "mgconfig")
}
// Collapse paths before saving so config is portable
toSave := MGConfig{
Repos: make([]Repo, len(m.Repos)),
Aliases: m.Aliases,
}
copy(toSave.Repos, m.Repos)
toSave.CollapsePaths()
b, err := json.MarshalIndent(toSave, "", " ")
if err != nil {
return err
}
return os.WriteFile(mgConf, b, 0o644)
}

530
parse/mgconf_test.go Normal file
View File

@@ -0,0 +1,530 @@
package parse
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestExpandPaths(t *testing.T) {
// Set up test environment variables
t.Setenv("HOME", "/home/testuser")
t.Setenv("GOPATH", "/home/testuser/go")
t.Setenv("CUSTOM_VAR", "/custom/path")
tests := []struct {
name string
input []Repo
expected []string
}{
{
name: "expand $HOME",
input: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/home/testuser/code/project"},
},
{
name: "expand multiple variables",
input: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
{Path: "$GOPATH/src/github.com/user/repo", Remote: "git@github.com:user/repo.git"},
},
expected: []string{
"/home/testuser/code/project",
"/home/testuser/go/src/github.com/user/repo",
},
},
{
name: "expand custom variable",
input: []Repo{
{Path: "$CUSTOM_VAR/subdir", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/custom/path/subdir"},
},
{
name: "no expansion needed",
input: []Repo{
{Path: "/absolute/path/to/repo", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/absolute/path/to/repo"},
},
{
name: "empty repos",
input: []Repo{},
expected: []string{},
},
{
name: "undefined variable stays as-is",
input: []Repo{
{Path: "$UNDEFINED_VAR/code", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/code"}, // undefined vars expand to empty string
},
{
name: "braced variable syntax",
input: []Repo{
{Path: "${HOME}/code/project", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/home/testuser/code/project"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := MGConfig{Repos: tt.input}
conf.ExpandPaths()
if len(conf.Repos) != len(tt.expected) {
t.Fatalf("expected %d repos, got %d", len(tt.expected), len(conf.Repos))
}
for i, repo := range conf.Repos {
if repo.Path != tt.expected[i] {
t.Errorf("repo %d: expected path %q, got %q", i, tt.expected[i], repo.Path)
}
}
})
}
}
func TestCollapsePaths(t *testing.T) {
// Get the actual home directory for this test
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
tests := []struct {
name string
input []Repo
expected []string
}{
{
name: "collapse home directory",
input: []Repo{
{Path: filepath.Join(home, "code/project"), Remote: "git@github.com:user/project.git"},
},
expected: []string{"$HOME/code/project"},
},
{
name: "collapse multiple paths",
input: []Repo{
{Path: filepath.Join(home, "code/project1"), Remote: "git@github.com:user/project1.git"},
{Path: filepath.Join(home, "code/project2"), Remote: "git@github.com:user/project2.git"},
},
expected: []string{
"$HOME/code/project1",
"$HOME/code/project2",
},
},
{
name: "path not under home",
input: []Repo{
{Path: "/opt/repos/project", Remote: "git@github.com:user/project.git"},
},
expected: []string{"/opt/repos/project"},
},
{
name: "mixed paths",
input: []Repo{
{Path: filepath.Join(home, "code/project"), Remote: "git@github.com:user/project.git"},
{Path: "/opt/repos/other", Remote: "git@github.com:user/other.git"},
},
expected: []string{
"$HOME/code/project",
"/opt/repos/other",
},
},
{
name: "empty repos",
input: []Repo{},
expected: []string{},
},
{
name: "home directory itself",
input: []Repo{
{Path: home, Remote: "git@github.com:user/home.git"},
},
expected: []string{"$HOME"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := MGConfig{Repos: tt.input}
conf.CollapsePaths()
if len(conf.Repos) != len(tt.expected) {
t.Fatalf("expected %d repos, got %d", len(tt.expected), len(conf.Repos))
}
for i, repo := range conf.Repos {
if repo.Path != tt.expected[i] {
t.Errorf("repo %d: expected path %q, got %q", i, tt.expected[i], repo.Path)
}
}
})
}
}
func TestExpandAndCollapse_Roundtrip(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
// Start with $HOME-based paths (as stored in config)
original := MGConfig{
Repos: []Repo{
{Path: "$HOME/code/project1", Remote: "git@github.com:user/project1.git"},
{Path: "$HOME/go/src/github.com/user/repo", Remote: "git@github.com:user/repo.git"},
{Path: "/opt/external/repo", Remote: "git@github.com:user/external.git"},
},
}
// Expand paths (as done when loading)
conf := MGConfig{
Repos: make([]Repo, len(original.Repos)),
}
copy(conf.Repos, original.Repos)
conf.ExpandPaths()
// Verify expansion worked
expectedExpanded := []string{
filepath.Join(home, "code/project1"),
filepath.Join(home, "go/src/github.com/user/repo"),
"/opt/external/repo",
}
for i, repo := range conf.Repos {
if repo.Path != expectedExpanded[i] {
t.Errorf("after expand, repo %d: expected %q, got %q", i, expectedExpanded[i], repo.Path)
}
}
// Collapse paths (as done when saving)
conf.CollapsePaths()
// Verify we're back to the original
for i, repo := range conf.Repos {
if repo.Path != original.Repos[i].Path {
t.Errorf("after roundtrip, repo %d: expected %q, got %q", i, original.Repos[i].Path, repo.Path)
}
}
}
func TestSave_CollapsesPaths(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to get home directory: %v", err)
}
// Create a temp file for the config
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "mgconfig")
t.Setenv("MGCONFIG", configPath)
// Create config with expanded (absolute) paths
conf := MGConfig{
Repos: []Repo{
{Path: filepath.Join(home, "code/project"), Remote: "git@github.com:user/project.git"},
{Path: "/opt/external/repo", Remote: "git@github.com:user/external.git"},
},
Aliases: map[string]string{"test": "echo test"},
}
// Save should collapse paths
err = conf.Save()
if err != nil {
t.Fatalf("Save() failed: %v", err)
}
// Read back and verify paths are collapsed
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read saved config: %v", err)
}
var saved MGConfig
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("failed to parse saved config: %v", err)
}
expectedPaths := []string{
"$HOME/code/project",
"/opt/external/repo",
}
for i, repo := range saved.Repos {
if repo.Path != expectedPaths[i] {
t.Errorf("saved repo %d: expected path %q, got %q", i, expectedPaths[i], repo.Path)
}
}
// Verify original config wasn't modified
if conf.Repos[0].Path != filepath.Join(home, "code/project") {
t.Errorf("original config was modified: expected %q, got %q",
filepath.Join(home, "code/project"), conf.Repos[0].Path)
}
}
func TestParseMGConfig(t *testing.T) {
tests := []struct {
name string
input string
wantRepos int
wantErr bool
}{
{
name: "valid config",
input: `{
"Repos": [
{"Path": "$HOME/code/project", "Remote": "git@github.com:user/project.git"}
],
"Aliases": {"gc": "git gc"}
}`,
wantRepos: 1,
wantErr: false,
},
{
name: "empty config",
input: `{"Repos": [], "Aliases": {}}`,
wantRepos: 0,
wantErr: false,
},
{
name: "invalid json",
input: `{invalid}`,
wantRepos: 0,
wantErr: true,
},
{
name: "multiple repos",
input: `{
"Repos": [
{"Path": "$HOME/code/project1", "Remote": "git@github.com:user/project1.git"},
{"Path": "$HOME/code/project2", "Remote": "git@github.com:user/project2.git"}
]
}`,
wantRepos: 2,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf, err := ParseMGConfig([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Errorf("ParseMGConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(conf.Repos) != tt.wantRepos {
t.Errorf("ParseMGConfig() got %d repos, want %d", len(conf.Repos), tt.wantRepos)
}
})
}
}
func TestAddRepo(t *testing.T) {
tests := []struct {
name string
initial []Repo
addPath string
addRemote string
wantErr bool
wantCount int
}{
{
name: "add to empty",
initial: []Repo{},
addPath: "$HOME/code/new",
addRemote: "git@github.com:user/new.git",
wantErr: false,
wantCount: 1,
},
{
name: "add to existing",
initial: []Repo{
{Path: "$HOME/code/existing", Remote: "git@github.com:user/existing.git"},
},
addPath: "$HOME/code/new",
addRemote: "git@github.com:user/new.git",
wantErr: false,
wantCount: 2,
},
{
name: "add duplicate",
initial: []Repo{
{Path: "$HOME/code/existing", Remote: "git@github.com:user/existing.git"},
},
addPath: "$HOME/code/existing",
addRemote: "git@github.com:user/existing.git",
wantErr: true,
wantCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := MGConfig{Repos: tt.initial}
err := conf.AddRepo(tt.addPath, tt.addRemote)
if (err != nil) != tt.wantErr {
t.Errorf("AddRepo() error = %v, wantErr %v", err, tt.wantErr)
}
if len(conf.Repos) != tt.wantCount {
t.Errorf("AddRepo() repo count = %d, want %d", len(conf.Repos), tt.wantCount)
}
})
}
}
func TestDelRepo(t *testing.T) {
tests := []struct {
name string
initial []Repo
delPath string
wantErr bool
wantCount int
}{
{
name: "delete existing",
initial: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
},
delPath: "$HOME/code/project",
wantErr: false,
wantCount: 0,
},
{
name: "delete from multiple",
initial: []Repo{
{Path: "$HOME/code/project1", Remote: "git@github.com:user/project1.git"},
{Path: "$HOME/code/project2", Remote: "git@github.com:user/project2.git"},
},
delPath: "$HOME/code/project1",
wantErr: false,
wantCount: 1,
},
{
name: "delete non-existent",
initial: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
},
delPath: "$HOME/code/other",
wantErr: true,
wantCount: 1,
},
{
name: "delete from empty",
initial: []Repo{},
delPath: "$HOME/code/project",
wantErr: true,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := MGConfig{Repos: tt.initial}
err := conf.DelRepo(tt.delPath)
if (err != nil) != tt.wantErr {
t.Errorf("DelRepo() error = %v, wantErr %v", err, tt.wantErr)
}
if len(conf.Repos) != tt.wantCount {
t.Errorf("DelRepo() repo count = %d, want %d", len(conf.Repos), tt.wantCount)
}
})
}
}
func TestMerge(t *testing.T) {
tests := []struct {
name string
initial []Repo
merge []Repo
wantCount int
wantDuplicates int
wantNewPaths int
}{
{
name: "merge into empty",
initial: []Repo{},
merge: []Repo{
{Path: "$HOME/code/new", Remote: "git@github.com:user/new.git"},
},
wantCount: 1,
wantDuplicates: 0,
wantNewPaths: 1,
},
{
name: "merge with duplicates",
initial: []Repo{
{Path: "$HOME/code/existing", Remote: "git@github.com:user/existing.git"},
},
merge: []Repo{
{Path: "$HOME/code/existing", Remote: "git@github.com:user/existing.git"},
{Path: "$HOME/code/new", Remote: "git@github.com:user/new.git"},
},
wantCount: 2,
wantDuplicates: 1,
wantNewPaths: 1,
},
{
name: "merge all duplicates",
initial: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
},
merge: []Repo{
{Path: "$HOME/code/project", Remote: "git@github.com:user/project.git"},
},
wantCount: 1,
wantDuplicates: 1,
wantNewPaths: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := MGConfig{Repos: tt.initial}
mergeConf := MGConfig{Repos: tt.merge}
stats, err := conf.Merge(mergeConf)
if err != nil {
t.Fatalf("Merge() unexpected error: %v", err)
}
if len(conf.Repos) != tt.wantCount {
t.Errorf("Merge() repo count = %d, want %d", len(conf.Repos), tt.wantCount)
}
if stats.Duplicates != tt.wantDuplicates {
t.Errorf("Merge() duplicates = %d, want %d", stats.Duplicates, tt.wantDuplicates)
}
if len(stats.NewPaths) != tt.wantNewPaths {
t.Errorf("Merge() new paths = %d, want %d", len(stats.NewPaths), tt.wantNewPaths)
}
})
}
}
func TestGetRepoPaths(t *testing.T) {
conf := MGConfig{
Repos: []Repo{
{Path: "$HOME/code/project1", Remote: "git@github.com:user/project1.git"},
{Path: "$HOME/code/project2", Remote: "git@github.com:user/project2.git"},
},
}
paths := conf.GetRepoPaths()
if len(paths) != 2 {
t.Fatalf("GetRepoPaths() returned %d paths, want 2", len(paths))
}
expected := []string{"$HOME/code/project1", "$HOME/code/project2"}
for i, path := range paths {
if path != expected[i] {
t.Errorf("GetRepoPaths()[%d] = %q, want %q", i, path, expected[i])
}
}
}

View File

@@ -3,22 +3,23 @@ package parse
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
type MRConfig struct { type MRConfig struct {
Unregister string Repos []Repo
GC string Aliases map[string]string
Repos []Repo
} }
type Repo struct { type Repo struct {
Path string Path string
Checkout string Remote string
Aliases map[string]string `json:"aliases,omitempty"`
} }
// GetRepoPaths returns a slice of strings containing the paths of all repos
// in the MRConfig struct
func (m MRConfig) GetRepoPaths() []string { func (m MRConfig) GetRepoPaths() []string {
paths := []string{} paths := []string{}
for _, r := range m.Repos { for _, r := range m.Repos {
@@ -27,6 +28,23 @@ func (m MRConfig) GetRepoPaths() []string {
return paths return paths
} }
func (m MRConfig) ToMGConfig() MGConfig {
mgconf := MGConfig(m)
for i, repo := range mgconf.Repos {
checkout := repo.Remote
if after, ok := strings.CutPrefix(checkout, "git clone '"); ok {
// git clone 'git@bitbucket.org:taigrr/mg.git' 'mg'
remote := after
sp := strings.Split(remote, "' '")
remote = sp[0]
mgconf.Repos[i].Remote = remote
}
}
return mgconf
}
// LoadMRConfig loads the mrconfig file from the user's home directory
// and returns a MRConfig struct with all repos and aliases from the [DEFAULT] section
func LoadMRConfig() (MRConfig, error) { func LoadMRConfig() (MRConfig, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -40,13 +58,16 @@ func LoadMRConfig() (MRConfig, error) {
if s.IsDir() { if s.IsDir() {
return MRConfig{}, errors.New("expected mrconfig file but got a directory") return MRConfig{}, errors.New("expected mrconfig file but got a directory")
} }
f, err := ioutil.ReadFile(mrconfPath) f, err := os.ReadFile(mrconfPath)
if err != nil { if err != nil {
return MRConfig{}, err return MRConfig{}, err
} }
text := string(f) text := string(f)
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
config := MRConfig{} config := MRConfig{
Aliases: make(map[string]string),
Repos: []Repo{},
}
length := -1 length := -1
mode := "default" mode := "default"
@@ -81,16 +102,12 @@ func LoadMRConfig() (MRConfig, error) {
if split[0] != "checkout" { if split[0] != "checkout" {
return MRConfig{}, fmt.Errorf("unexpected argument on line %d: %s", n, line) return MRConfig{}, fmt.Errorf("unexpected argument on line %d: %s", n, line)
} }
config.Repos[length].Checkout = split[1]
config.Repos[length].Remote = split[1]
case "default": case "default":
switch split[0] { // Load all DEFAULT section aliases into the map
case "unregister": config.Aliases[split[0]] = split[1]
config.Unregister = split[1]
case "git_gc":
config.GC = split[1]
default:
return MRConfig{}, fmt.Errorf("unexpected argument on line %d: %s", n, line)
}
} }
} }
return config, nil return config, nil

16
parse/myrepos_test.go Normal file
View File

@@ -0,0 +1,16 @@
package parse
import "testing"
func TestLoadMRConfig(t *testing.T) {
// test cases for LoadMRConfig
tests := []struct {
id string
}{}
for _, test := range tests {
t.Run(test.id, func(t *testing.T) {
// TODO
})
}
}