mirror of
https://github.com/taigrr/mg.git
synced 2026-04-02 03:28:42 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f925f852e | |||
| cacdbf673f | |||
|
|
991985ab4e | ||
| 417cf943fa | |||
| 1030a8f3a9 | |||
| f0c6f3906a | |||
| 01c736b54e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
cmd/mg/mg
|
||||
.crush
|
||||
|
||||
234
AGENTS.md
Normal file
234
AGENTS.md
Normal 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
|
||||
1
cmd/mg/.gitignore
vendored
Normal file
1
cmd/mg/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
main
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
git "github.com/go-git/go-git/v5"
|
||||
@@ -37,46 +38,55 @@ var (
|
||||
mutex := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(conf.Repos))
|
||||
for i := 0; i < jobs; i++ {
|
||||
go func() {
|
||||
for repo := range repoChan {
|
||||
_, err := git.PlainOpenWithOptions(repo.Remote, &(git.PlainOpenOptions{DetectDotGit: true}))
|
||||
if err == nil {
|
||||
log.Printf("already cloned: %s\n", repo.Path)
|
||||
mutex.Lock()
|
||||
alreadyCloned++
|
||||
mutex.Unlock()
|
||||
} else if err == git.ErrRepositoryNotExists {
|
||||
log.Printf("attempting clone: %s\n", repo)
|
||||
_, 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)
|
||||
wg.Done()
|
||||
continue
|
||||
} else {
|
||||
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, err)
|
||||
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)
|
||||
|
||||
@@ -27,5 +27,6 @@ func GetConfig() parse.MGConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
conf.ExpandPaths()
|
||||
return conf
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ var (
|
||||
jobs int
|
||||
pullCmd = &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "add current path to list of repos",
|
||||
Short: "update all git repos specified in config",
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
type RepoError struct {
|
||||
Error error
|
||||
|
||||
@@ -52,6 +52,7 @@ var registerCmd = &cobra.Command{
|
||||
os.Exit(1)
|
||||
}
|
||||
path = newPath.Filesystem.Root()
|
||||
|
||||
for _, v := range conf.Repos {
|
||||
if v.Path == path {
|
||||
fmt.Printf("repo %s already registered\n", path)
|
||||
|
||||
@@ -2,14 +2,44 @@ 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",
|
||||
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.
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/taigrr/mg
|
||||
|
||||
go 1.21
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/go-git/go-git/v5 v5.11.0
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var errAlreadyRegistered = os.ErrExist
|
||||
@@ -117,6 +118,28 @@ func ParseMGConfig(b []byte) (MGConfig, error) {
|
||||
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 == "" {
|
||||
@@ -133,7 +156,15 @@ func (m MGConfig) Save() error {
|
||||
}
|
||||
mgConf = filepath.Join(confDir, "mgconfig")
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
// 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
|
||||
}
|
||||
|
||||
530
parse/mgconf_test.go
Normal file
530
parse/mgconf_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,9 @@ func (m MRConfig) ToMGConfig() MGConfig {
|
||||
mgconf := MGConfig(m)
|
||||
for i, repo := range mgconf.Repos {
|
||||
checkout := repo.Remote
|
||||
if strings.HasPrefix(checkout, "git clone '") {
|
||||
if after, ok := strings.CutPrefix(checkout, "git clone '"); ok {
|
||||
// git clone 'git@bitbucket.org:taigrr/mg.git' 'mg'
|
||||
remote := strings.TrimPrefix(checkout, "git clone '")
|
||||
remote := after
|
||||
sp := strings.Split(remote, "' '")
|
||||
remote = sp[0]
|
||||
mgconf.Repos[i].Remote = remote
|
||||
|
||||
Reference in New Issue
Block a user