diff options
| author | Fuwn <[email protected]> | 2026-01-27 03:55:42 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-27 03:58:28 +0000 |
| commit | a391fdec036a7e2ce165516d4c38962bb12cf65e (patch) | |
| tree | a307a30a899953d975f927ed621869f2eebd927e | |
| parent | feat(config): Add '.' shorthand for current directory repository (diff) | |
| download | mugi-a391fdec036a7e2ce165516d4c38962bb12cf65e.tar.xz mugi-a391fdec036a7e2ce165516d4c38962bb12cf65e.zip | |
feat: Add repository management commands
| -rw-r--r-- | cmd/mugi/main.go | 45 | ||||
| -rw-r--r-- | internal/cli/cli.go | 61 | ||||
| -rw-r--r-- | internal/manage/manage.go | 270 |
3 files changed, 365 insertions, 11 deletions
diff --git a/cmd/mugi/main.go b/cmd/mugi/main.go index 3c32db6..7416760 100644 --- a/cmd/mugi/main.go +++ b/cmd/mugi/main.go @@ -6,6 +6,7 @@ import ( "github.com/ebisu/mugi/internal/cli" "github.com/ebisu/mugi/internal/config" + "github.com/ebisu/mugi/internal/manage" "github.com/ebisu/mugi/internal/ui" ) @@ -36,7 +37,49 @@ func run() error { return nil } - cfg, err := config.Load(cmd.ConfigPath) + configPath := cmd.ConfigPath + if configPath == "" { + configPath, _ = config.Path() + } + + switch cmd.Type { + case cli.CommandAdd: + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("config: %w", err) + } + + if err := manage.Add(cmd.Path, configPath, cfg.Remotes); err != nil { + return err + } + + fmt.Printf("Added repository: %s\n", cmd.Path) + + return nil + + case cli.CommandRemove: + if err := manage.Remove(cmd.Repo, configPath); err != nil { + return err + } + + fmt.Printf("Removed repository: %s\n", cmd.Repo) + + return nil + + case cli.CommandList: + repos, err := manage.List(configPath) + if err != nil { + return err + } + + for _, repo := range repos { + fmt.Printf("%s (%s)\n", repo.Name, repo.Path) + } + + return nil + } + + cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("config: %w", err) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 306e242..63590af 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -9,10 +9,21 @@ import ( "github.com/ebisu/mugi/internal/remote" ) +type CommandType int + +const ( + CommandOperation CommandType = iota + CommandAdd + CommandRemove + CommandList +) + type Command struct { + Type CommandType Operation remote.Operation Repo string Remotes []string + Path string ConfigPath string Verbose bool Force bool @@ -61,11 +72,38 @@ func Parse(args []string) (Command, error) { switch args[0] { case "pull": + cmd.Type = CommandOperation cmd.Operation = remote.Pull case "push": + cmd.Type = CommandOperation cmd.Operation = remote.Push case "fetch": + cmd.Type = CommandOperation cmd.Operation = remote.Fetch + case "add": + cmd.Type = CommandAdd + + if len(args) < 2 { + cmd.Path = "." + } else { + cmd.Path = args[1] + } + + return cmd, nil + case "rm", "remove": + cmd.Type = CommandRemove + + if len(args) < 2 { + return cmd, fmt.Errorf("rm requires a repository name") + } + + cmd.Repo = args[1] + + return cmd, nil + case "list", "ls": + cmd.Type = CommandList + + return cmd, nil default: return cmd, fmt.Errorf("%w: %s", ErrUnknownCommand, args[0]) } @@ -95,11 +133,14 @@ Usage: mugi [flags] <command> [repo] [remotes...] Commands: - pull Pull from remote(s) - push Push to remote(s) - fetch Fetch from remote(s) - help Show this help - version Show version + pull Pull from remote(s) + push Push to remote(s) + fetch Fetch from remote(s) + add <path> Add repository to config + rm <name> Remove repository from config + list List tracked repositories + help Show this help + version Show version Flags: -c, --config <path> Override config file path @@ -109,11 +150,11 @@ Flags: Examples: mugi pull Pull all repositories from all remotes - mugi pull windmark Pull Windmark from all remotes - mugi pull windmark github Pull Windmark from GitHub only - mugi push windmark gh cb Push Windmark to GitHub and Codeberg - mugi fetch gemrest/september Fetch specific repository - mugi -c ./test.yaml pull Use custom config + mugi push windmark gh cb Push to GitHub and Codeberg + mugi add . Add current directory to config + mugi add ~/Developer/mugi Add repository at path + mugi rm mugi Remove repository from config + mugi list List all tracked repositories Config: ` + configPath() } diff --git a/internal/manage/manage.go b/internal/manage/manage.go new file mode 100644 index 0000000..c33e995 --- /dev/null +++ b/internal/manage/manage.go @@ -0,0 +1,270 @@ +package manage + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ebisu/mugi/internal/config" + "gopkg.in/yaml.v3" +) + +type RepoInfo struct { + Name string + Path string + Remotes map[string]string +} + +func Add(path, configPath string, remoteDefs map[string]config.RemoteDefinition) error { + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + if !isGitRepo(absPath) { + return fmt.Errorf("not a git repository: %s", absPath) + } + + info, err := extractRepoInfo(absPath, remoteDefs) + if err != nil { + return err + } + + return appendToConfig(configPath, info) +} + +func Remove(name, configPath string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + fullName, _, found := cfg.FindRepo(name) + if !found { + return fmt.Errorf("repository not found: %s", name) + } + + return removeFromConfig(configPath, fullName) +} + +func List(configPath string) ([]RepoInfo, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + + var repos []RepoInfo + + for name, repo := range cfg.Repos { + repos = append(repos, RepoInfo{ + Name: name, + Path: repo.ExpandPath(), + Remotes: repo.Remotes, + }) + } + + return repos, nil +} + +func isGitRepo(path string) bool { + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + + return cmd.Run() == nil +} + +func extractRepoInfo(path string, remoteDefs map[string]config.RemoteDefinition) (RepoInfo, error) { + info := RepoInfo{ + Path: path, + Remotes: make(map[string]string), + } + + cmd := exec.Command("git", "remote", "-v") + cmd.Dir = path + + out, err := cmd.Output() + if err != nil { + return info, fmt.Errorf("failed to get remotes: %w", err) + } + + remoteURLs := parseRemotes(string(out)) + + for remoteName, url := range remoteURLs { + knownRemote := matchRemoteURL(url, remoteDefs) + + if knownRemote != "" { + info.Remotes[knownRemote] = url + } else { + info.Remotes[remoteName] = url + } + } + + info.Name = inferRepoName(path, remoteURLs) + + return info, nil +} + +func parseRemotes(output string) map[string]string { + remotes := make(map[string]string) + + for line := range strings.SplitSeq(output, "\n") { + if !strings.Contains(line, "(fetch)") { + continue + } + + parts := strings.Fields(line) + + if len(parts) >= 2 { + remotes[parts[0]] = parts[1] + } + } + + return remotes +} + +func matchRemoteURL(url string, remoteDefs map[string]config.RemoteDefinition) string { + for name, def := range remoteDefs { + template := def.URL + + if template == "" { + continue + } + + pattern := strings.ReplaceAll(template, "${user}", "") + pattern = strings.ReplaceAll(pattern, "${repo}", "") + + base := strings.Split(pattern, ":")[0] + + if strings.Contains(url, base) || strings.Contains(url, name) { + return name + } + } + + return "" +} + +func inferRepoName(path string, remotes map[string]string) string { + for _, url := range remotes { + name := extractRepoNameFromURL(url) + + if name != "" { + return name + } + } + + return filepath.Base(path) +} + +func extractRepoNameFromURL(url string) string { + url = strings.TrimSuffix(url, ".git") + + if strings.Contains(url, ":") { + parts := strings.Split(url, ":") + + if len(parts) == 2 { + return strings.TrimPrefix(parts[1], "~") + } + } + + if strings.Contains(url, "/") { + parts := strings.Split(url, "/") + + if len(parts) >= 2 { + return parts[len(parts)-2] + "/" + parts[len(parts)-1] + } + } + + return "" +} + +func appendToConfig(configPath string, info RepoInfo) error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + var raw map[string]yaml.Node + + if err := yaml.Unmarshal(data, &raw); err != nil { + return err + } + + reposNode, ok := raw["repos"] + if !ok { + return fmt.Errorf("repos section not found in config") + } + + repoEntry := map[string]any{ + "path": info.Path, + "remotes": info.Remotes, + } + + entryBytes, err := yaml.Marshal(map[string]any{info.Name: repoEntry}) + if err != nil { + return err + } + + var entryNode yaml.Node + + if err := yaml.Unmarshal(entryBytes, &entryNode); err != nil { + return err + } + + if reposNode.Kind == yaml.MappingNode && len(entryNode.Content) > 0 && len(entryNode.Content[0].Content) >= 2 { + reposNode.Content = append(reposNode.Content, entryNode.Content[0].Content...) + raw["repos"] = reposNode + } + + output, err := yaml.Marshal(raw) + if err != nil { + return err + } + + return os.WriteFile(configPath, output, 0o644) +} + +func removeFromConfig(configPath, name string) error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + var raw map[string]yaml.Node + + if err := yaml.Unmarshal(data, &raw); err != nil { + return err + } + + reposNode, ok := raw["repos"] + if !ok { + return fmt.Errorf("repos section not found in config") + } + + if reposNode.Kind != yaml.MappingNode { + return fmt.Errorf("repos section is not a mapping") + } + + var newContent []*yaml.Node + + for i := 0; i < len(reposNode.Content); i += 2 { + if i+1 >= len(reposNode.Content) { + break + } + + if reposNode.Content[i].Value != name { + newContent = append(newContent, reposNode.Content[i], reposNode.Content[i+1]) + } + } + + reposNode.Content = newContent + raw["repos"] = reposNode + + output, err := yaml.Marshal(raw) + if err != nil { + return err + } + + return os.WriteFile(configPath, output, 0o644) +} |