aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/mugi/main.go45
-rw-r--r--internal/cli/cli.go61
-rw-r--r--internal/manage/manage.go270
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)
+}