From 0f925f852e1e4bcec14f44e7cf1c82125c4da635 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 18 Jan 2026 02:29:26 -0500 Subject: [PATCH] update env var expansion logic --- .gitignore | 1 + AGENTS.md | 234 ++++++++++++++++++ cmd/mg/cmd/common.go | 8 +- cmd/mg/cmd/register.go | 10 - cmd/mg/cmd/root.go | 34 ++- parse/mgconf.go | 33 ++- parse/mgconf_test.go | 530 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 830 insertions(+), 20 deletions(-) create mode 100644 AGENTS.md create mode 100644 parse/mgconf_test.go diff --git a/.gitignore b/.gitignore index a44cc2b..8713ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ cmd/mg/mg +.crush diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..601c256 --- /dev/null +++ b/AGENTS.md @@ -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 +``` + +## 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/.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 diff --git a/cmd/mg/cmd/common.go b/cmd/mg/cmd/common.go index cb331c3..e572189 100644 --- a/cmd/mg/cmd/common.go +++ b/cmd/mg/cmd/common.go @@ -3,7 +3,6 @@ package cmd import ( "log" "os" - "strings" "github.com/taigrr/mg/parse" ) @@ -28,11 +27,6 @@ func GetConfig() parse.MGConfig { } } } - homeDir, _ := os.UserHomeDir() - for i, repo := range conf.Repos { - if strings.HasPrefix(repo.Path, "$HOME") { - conf.Repos[i].Path = strings.Replace(repo.Path, "$HOME", homeDir, 1) - } - } + conf.ExpandPaths() return conf } diff --git a/cmd/mg/cmd/register.go b/cmd/mg/cmd/register.go index 86215d2..a2f8284 100644 --- a/cmd/mg/cmd/register.go +++ b/cmd/mg/cmd/register.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "os" - "strings" git "github.com/go-git/go-git/v5" "github.com/spf13/cobra" @@ -54,15 +53,6 @@ var registerCmd = &cobra.Command{ } path = newPath.Filesystem.Root() - homeDir, err := os.UserHomeDir() - if err != nil { - log.Println("Unable to get home directory") - os.Exit(1) - } - if strings.HasPrefix(path, homeDir) { - path = "$HOME" + path[len(homeDir):] - } - for _, v := range conf.Repos { if v.Path == path { fmt.Printf("repo %s already registered\n", path) diff --git a/cmd/mg/cmd/root.go b/cmd/mg/cmd/root.go index c4f94fd..63a7d91 100644 --- a/cmd/mg/cmd/root.go +++ b/cmd/mg/cmd/root.go @@ -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. diff --git a/parse/mgconf.go b/parse/mgconf.go index efba968..f2baf28 100644 --- a/parse/mgconf.go +++ b/parse/mgconf.go @@ -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 } diff --git a/parse/mgconf_test.go b/parse/mgconf_test.go new file mode 100644 index 0000000..e81b846 --- /dev/null +++ b/parse/mgconf_test.go @@ -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]) + } + } +}