🍰 Personal Multi-Git Remote Manager
go git

feat: Add repository management commands

fuwn.net a391fdec 0dbdee0e

verified
+365 -11
+44 -1
cmd/mugi/main.go
··· 6 6 7 7 "github.com/ebisu/mugi/internal/cli" 8 8 "github.com/ebisu/mugi/internal/config" 9 + "github.com/ebisu/mugi/internal/manage" 9 10 "github.com/ebisu/mugi/internal/ui" 10 11 ) 11 12 ··· 36 37 return nil 37 38 } 38 39 39 - cfg, err := config.Load(cmd.ConfigPath) 40 + configPath := cmd.ConfigPath 41 + if configPath == "" { 42 + configPath, _ = config.Path() 43 + } 44 + 45 + switch cmd.Type { 46 + case cli.CommandAdd: 47 + cfg, err := config.Load(configPath) 48 + if err != nil { 49 + return fmt.Errorf("config: %w", err) 50 + } 51 + 52 + if err := manage.Add(cmd.Path, configPath, cfg.Remotes); err != nil { 53 + return err 54 + } 55 + 56 + fmt.Printf("Added repository: %s\n", cmd.Path) 57 + 58 + return nil 59 + 60 + case cli.CommandRemove: 61 + if err := manage.Remove(cmd.Repo, configPath); err != nil { 62 + return err 63 + } 64 + 65 + fmt.Printf("Removed repository: %s\n", cmd.Repo) 66 + 67 + return nil 68 + 69 + case cli.CommandList: 70 + repos, err := manage.List(configPath) 71 + if err != nil { 72 + return err 73 + } 74 + 75 + for _, repo := range repos { 76 + fmt.Printf("%s (%s)\n", repo.Name, repo.Path) 77 + } 78 + 79 + return nil 80 + } 81 + 82 + cfg, err := config.Load(configPath) 40 83 if err != nil { 41 84 return fmt.Errorf("config: %w", err) 42 85 }
+51 -10
internal/cli/cli.go
··· 9 9 "github.com/ebisu/mugi/internal/remote" 10 10 ) 11 11 12 + type CommandType int 13 + 14 + const ( 15 + CommandOperation CommandType = iota 16 + CommandAdd 17 + CommandRemove 18 + CommandList 19 + ) 20 + 12 21 type Command struct { 22 + Type CommandType 13 23 Operation remote.Operation 14 24 Repo string 15 25 Remotes []string 26 + Path string 16 27 ConfigPath string 17 28 Verbose bool 18 29 Force bool ··· 61 72 62 73 switch args[0] { 63 74 case "pull": 75 + cmd.Type = CommandOperation 64 76 cmd.Operation = remote.Pull 65 77 case "push": 78 + cmd.Type = CommandOperation 66 79 cmd.Operation = remote.Push 67 80 case "fetch": 81 + cmd.Type = CommandOperation 68 82 cmd.Operation = remote.Fetch 83 + case "add": 84 + cmd.Type = CommandAdd 85 + 86 + if len(args) < 2 { 87 + cmd.Path = "." 88 + } else { 89 + cmd.Path = args[1] 90 + } 91 + 92 + return cmd, nil 93 + case "rm", "remove": 94 + cmd.Type = CommandRemove 95 + 96 + if len(args) < 2 { 97 + return cmd, fmt.Errorf("rm requires a repository name") 98 + } 99 + 100 + cmd.Repo = args[1] 101 + 102 + return cmd, nil 103 + case "list", "ls": 104 + cmd.Type = CommandList 105 + 106 + return cmd, nil 69 107 default: 70 108 return cmd, fmt.Errorf("%w: %s", ErrUnknownCommand, args[0]) 71 109 } ··· 95 133 mugi [flags] <command> [repo] [remotes...] 96 134 97 135 Commands: 98 - pull Pull from remote(s) 99 - push Push to remote(s) 100 - fetch Fetch from remote(s) 101 - help Show this help 102 - version Show version 136 + pull Pull from remote(s) 137 + push Push to remote(s) 138 + fetch Fetch from remote(s) 139 + add <path> Add repository to config 140 + rm <name> Remove repository from config 141 + list List tracked repositories 142 + help Show this help 143 + version Show version 103 144 104 145 Flags: 105 146 -c, --config <path> Override config file path ··· 109 150 110 151 Examples: 111 152 mugi pull Pull all repositories from all remotes 112 - mugi pull windmark Pull Windmark from all remotes 113 - mugi pull windmark github Pull Windmark from GitHub only 114 - mugi push windmark gh cb Push Windmark to GitHub and Codeberg 115 - mugi fetch gemrest/september Fetch specific repository 116 - mugi -c ./test.yaml pull Use custom config 153 + mugi push windmark gh cb Push to GitHub and Codeberg 154 + mugi add . Add current directory to config 155 + mugi add ~/Developer/mugi Add repository at path 156 + mugi rm mugi Remove repository from config 157 + mugi list List all tracked repositories 117 158 118 159 Config: ` + configPath() 119 160 }
+270
internal/manage/manage.go
··· 1 + package manage 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "strings" 9 + 10 + "github.com/ebisu/mugi/internal/config" 11 + "gopkg.in/yaml.v3" 12 + ) 13 + 14 + type RepoInfo struct { 15 + Name string 16 + Path string 17 + Remotes map[string]string 18 + } 19 + 20 + func Add(path, configPath string, remoteDefs map[string]config.RemoteDefinition) error { 21 + absPath, err := filepath.Abs(path) 22 + if err != nil { 23 + return fmt.Errorf("invalid path: %w", err) 24 + } 25 + 26 + if !isGitRepo(absPath) { 27 + return fmt.Errorf("not a git repository: %s", absPath) 28 + } 29 + 30 + info, err := extractRepoInfo(absPath, remoteDefs) 31 + if err != nil { 32 + return err 33 + } 34 + 35 + return appendToConfig(configPath, info) 36 + } 37 + 38 + func Remove(name, configPath string) error { 39 + cfg, err := config.Load(configPath) 40 + if err != nil { 41 + return err 42 + } 43 + 44 + fullName, _, found := cfg.FindRepo(name) 45 + if !found { 46 + return fmt.Errorf("repository not found: %s", name) 47 + } 48 + 49 + return removeFromConfig(configPath, fullName) 50 + } 51 + 52 + func List(configPath string) ([]RepoInfo, error) { 53 + cfg, err := config.Load(configPath) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + var repos []RepoInfo 59 + 60 + for name, repo := range cfg.Repos { 61 + repos = append(repos, RepoInfo{ 62 + Name: name, 63 + Path: repo.ExpandPath(), 64 + Remotes: repo.Remotes, 65 + }) 66 + } 67 + 68 + return repos, nil 69 + } 70 + 71 + func isGitRepo(path string) bool { 72 + cmd := exec.Command("git", "rev-parse", "--git-dir") 73 + cmd.Dir = path 74 + 75 + return cmd.Run() == nil 76 + } 77 + 78 + func extractRepoInfo(path string, remoteDefs map[string]config.RemoteDefinition) (RepoInfo, error) { 79 + info := RepoInfo{ 80 + Path: path, 81 + Remotes: make(map[string]string), 82 + } 83 + 84 + cmd := exec.Command("git", "remote", "-v") 85 + cmd.Dir = path 86 + 87 + out, err := cmd.Output() 88 + if err != nil { 89 + return info, fmt.Errorf("failed to get remotes: %w", err) 90 + } 91 + 92 + remoteURLs := parseRemotes(string(out)) 93 + 94 + for remoteName, url := range remoteURLs { 95 + knownRemote := matchRemoteURL(url, remoteDefs) 96 + 97 + if knownRemote != "" { 98 + info.Remotes[knownRemote] = url 99 + } else { 100 + info.Remotes[remoteName] = url 101 + } 102 + } 103 + 104 + info.Name = inferRepoName(path, remoteURLs) 105 + 106 + return info, nil 107 + } 108 + 109 + func parseRemotes(output string) map[string]string { 110 + remotes := make(map[string]string) 111 + 112 + for line := range strings.SplitSeq(output, "\n") { 113 + if !strings.Contains(line, "(fetch)") { 114 + continue 115 + } 116 + 117 + parts := strings.Fields(line) 118 + 119 + if len(parts) >= 2 { 120 + remotes[parts[0]] = parts[1] 121 + } 122 + } 123 + 124 + return remotes 125 + } 126 + 127 + func matchRemoteURL(url string, remoteDefs map[string]config.RemoteDefinition) string { 128 + for name, def := range remoteDefs { 129 + template := def.URL 130 + 131 + if template == "" { 132 + continue 133 + } 134 + 135 + pattern := strings.ReplaceAll(template, "${user}", "") 136 + pattern = strings.ReplaceAll(pattern, "${repo}", "") 137 + 138 + base := strings.Split(pattern, ":")[0] 139 + 140 + if strings.Contains(url, base) || strings.Contains(url, name) { 141 + return name 142 + } 143 + } 144 + 145 + return "" 146 + } 147 + 148 + func inferRepoName(path string, remotes map[string]string) string { 149 + for _, url := range remotes { 150 + name := extractRepoNameFromURL(url) 151 + 152 + if name != "" { 153 + return name 154 + } 155 + } 156 + 157 + return filepath.Base(path) 158 + } 159 + 160 + func extractRepoNameFromURL(url string) string { 161 + url = strings.TrimSuffix(url, ".git") 162 + 163 + if strings.Contains(url, ":") { 164 + parts := strings.Split(url, ":") 165 + 166 + if len(parts) == 2 { 167 + return strings.TrimPrefix(parts[1], "~") 168 + } 169 + } 170 + 171 + if strings.Contains(url, "/") { 172 + parts := strings.Split(url, "/") 173 + 174 + if len(parts) >= 2 { 175 + return parts[len(parts)-2] + "/" + parts[len(parts)-1] 176 + } 177 + } 178 + 179 + return "" 180 + } 181 + 182 + func appendToConfig(configPath string, info RepoInfo) error { 183 + data, err := os.ReadFile(configPath) 184 + if err != nil { 185 + return err 186 + } 187 + 188 + var raw map[string]yaml.Node 189 + 190 + if err := yaml.Unmarshal(data, &raw); err != nil { 191 + return err 192 + } 193 + 194 + reposNode, ok := raw["repos"] 195 + if !ok { 196 + return fmt.Errorf("repos section not found in config") 197 + } 198 + 199 + repoEntry := map[string]any{ 200 + "path": info.Path, 201 + "remotes": info.Remotes, 202 + } 203 + 204 + entryBytes, err := yaml.Marshal(map[string]any{info.Name: repoEntry}) 205 + if err != nil { 206 + return err 207 + } 208 + 209 + var entryNode yaml.Node 210 + 211 + if err := yaml.Unmarshal(entryBytes, &entryNode); err != nil { 212 + return err 213 + } 214 + 215 + if reposNode.Kind == yaml.MappingNode && len(entryNode.Content) > 0 && len(entryNode.Content[0].Content) >= 2 { 216 + reposNode.Content = append(reposNode.Content, entryNode.Content[0].Content...) 217 + raw["repos"] = reposNode 218 + } 219 + 220 + output, err := yaml.Marshal(raw) 221 + if err != nil { 222 + return err 223 + } 224 + 225 + return os.WriteFile(configPath, output, 0o644) 226 + } 227 + 228 + func removeFromConfig(configPath, name string) error { 229 + data, err := os.ReadFile(configPath) 230 + if err != nil { 231 + return err 232 + } 233 + 234 + var raw map[string]yaml.Node 235 + 236 + if err := yaml.Unmarshal(data, &raw); err != nil { 237 + return err 238 + } 239 + 240 + reposNode, ok := raw["repos"] 241 + if !ok { 242 + return fmt.Errorf("repos section not found in config") 243 + } 244 + 245 + if reposNode.Kind != yaml.MappingNode { 246 + return fmt.Errorf("repos section is not a mapping") 247 + } 248 + 249 + var newContent []*yaml.Node 250 + 251 + for i := 0; i < len(reposNode.Content); i += 2 { 252 + if i+1 >= len(reposNode.Content) { 253 + break 254 + } 255 + 256 + if reposNode.Content[i].Value != name { 257 + newContent = append(newContent, reposNode.Content[i], reposNode.Content[i+1]) 258 + } 259 + } 260 + 261 + reposNode.Content = newContent 262 + raw["repos"] = reposNode 263 + 264 + output, err := yaml.Marshal(raw) 265 + if err != nil { 266 + return err 267 + } 268 + 269 + return os.WriteFile(configPath, output, 0o644) 270 + }