diff options
| author | Fuwn <[email protected]> | 2026-01-26 05:13:25 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-26 05:13:25 +0000 |
| commit | 567e5b51d32450999935bcf3af143248888e12e9 (patch) | |
| tree | 41bb8b2a8fcae168bcb7044adb84f08dc89aaec0 /internal | |
| download | mugi-567e5b51d32450999935bcf3af143248888e12e9.tar.xz mugi-567e5b51d32450999935bcf3af143248888e12e9.zip | |
feat: Initial commit
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 176 | ||||
| -rw-r--r-- | internal/config/config.go | 250 | ||||
| -rw-r--r-- | internal/git/git.go | 214 | ||||
| -rw-r--r-- | internal/remote/remote.go | 50 | ||||
| -rw-r--r-- | internal/ui/ui.go | 626 |
5 files changed, 1316 insertions, 0 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..1ab54b2 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,176 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/ebisu/mugi/internal/remote" +) + +type Command struct { + Operation remote.Operation + Repo string + Remotes []string + ConfigPath string + Verbose bool + Help bool + Version bool +} + +var ErrUnknownCommand = errors.New("unknown command") + +func Parse(args []string) (Command, error) { + cmd := Command{ + Remotes: []string{remote.All}, + } + + if len(args) == 0 { + cmd.Help = true + + return cmd, nil + } + + args, cmd.ConfigPath = extractConfigFlag(args) + args, cmd.Verbose = extractVerboseFlag(args) + + for _, arg := range args { + if arg == "-h" || arg == "--help" || arg == "help" { + cmd.Help = true + + return cmd, nil + } + + if arg == "-v" || arg == "--version" || arg == "version" { + cmd.Version = true + + return cmd, nil + } + } + + if len(args) == 0 { + cmd.Help = true + + return cmd, nil + } + + switch args[0] { + case "pull": + cmd.Operation = remote.Pull + case "push": + cmd.Operation = remote.Push + case "fetch": + cmd.Operation = remote.Fetch + default: + return cmd, fmt.Errorf("%w: %s", ErrUnknownCommand, args[0]) + } + + remaining := args[1:] + + if len(remaining) == 0 { + cmd.Repo = remote.All + + return cmd, nil + } + + cmd.Repo = remaining[0] + remaining = remaining[1:] + + if len(remaining) > 0 { + cmd.Remotes = remaining + } + + return cmd, nil +} + +func Usage() string { + return `Mugi - Personal Multi-Git Remote Manager + +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 + +Flags: + -c, --config <path> Override config file path + -V, --verbose Show detailed output + +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 + +Config: ` + configPath() +} + +func configPath() string { + xdg := os.Getenv("XDG_CONFIG_HOME") + + if xdg == "" { + home, _ := os.UserHomeDir() + + return home + "/.config/mugi/config.yaml" + } + + return xdg + "/mugi/config.yaml" +} + +func extractConfigFlag(args []string) ([]string, string) { + var remaining []string + var configPath string + + for i := 0; i < len(args); i++ { + arg := args[i] + + if arg == "-c" || arg == "--config" { + if i+1 < len(args) { + configPath = args[i+1] + i++ + } + + continue + } + + if v, ok := strings.CutPrefix(arg, "--config="); ok { + configPath = v + + continue + } + + if v, ok := strings.CutPrefix(arg, "-c"); ok && v != "" { + configPath = v + + continue + } + + remaining = append(remaining, arg) + } + + return remaining, configPath +} + +func extractVerboseFlag(args []string) ([]string, bool) { + var remaining []string + var verbose bool + + for _, arg := range args { + if arg == "-V" || arg == "--verbose" { + verbose = true + + continue + } + + remaining = append(remaining, arg) + } + + return remaining, verbose +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9564b4e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,250 @@ +package config + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +type RemoteDefinition struct { + Aliases []string `yaml:"aliases"` + URL string `yaml:"url"` +} + +type Defaults struct { + Remotes []string `yaml:"remotes"` + PathPrefix string `yaml:"path_prefix"` +} + +type RepoRemotes map[string]string + +type Repo struct { + Path string + Remotes RepoRemotes +} + +type Config struct { + Remotes map[string]RemoteDefinition + Defaults Defaults + Repos map[string]Repo +} + +type rawConfig struct { + Remotes map[string]RemoteDefinition `yaml:"remotes"` + Defaults Defaults `yaml:"defaults"` + Repos map[string]yaml.Node `yaml:"repos"` +} + +type remoteOverride struct { + User string `yaml:"user"` + Repo string `yaml:"repo"` +} + +func Load(override string) (Config, error) { + path := override + + if path == "" { + var err error + + path, err = Path() + if err != nil { + return Config{}, err + } + } + + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + + var raw rawConfig + + if err := yaml.Unmarshal(data, &raw); err != nil { + return Config{}, err + } + + return expand(raw) +} + +func expand(raw rawConfig) (Config, error) { + cfg := Config{ + Remotes: raw.Remotes, + Defaults: raw.Defaults, + Repos: make(map[string]Repo), + } + + for name, node := range raw.Repos { + repo, err := expandRepo(name, node, raw) + if err != nil { + return Config{}, err + } + + cfg.Repos[name] = repo + } + + return cfg, nil +} + +func expandRepo(name string, node yaml.Node, raw rawConfig) (Repo, error) { + user, repoName := splitRepoName(name) + repo := Repo{ + Remotes: make(RepoRemotes), + } + + var parsed map[string]yaml.Node + + if err := node.Decode(&parsed); err != nil { + parsed = make(map[string]yaml.Node) + } + + if pathNode, ok := parsed["path"]; ok { + var path string + + pathNode.Decode(&path) + + repo.Path = path + } else if raw.Defaults.PathPrefix != "" { + repo.Path = filepath.Join(raw.Defaults.PathPrefix, repoName) + } + + remoteList := raw.Defaults.Remotes + + if remotesNode, ok := parsed["remotes"]; ok { + var list []string + + if err := remotesNode.Decode(&list); err == nil { + remoteList = list + } else { + var oldStyle map[string]string + + if err := remotesNode.Decode(&oldStyle); err == nil { + repo.Remotes = oldStyle + + return repo, nil + } + } + } + + for _, remoteName := range remoteList { + remoteUser, remoteRepo := user, repoName + + if overrideNode, ok := parsed[remoteName]; ok { + var override remoteOverride + + if err := overrideNode.Decode(&override); err == nil { + if override.User != "" { + remoteUser = override.User + } + + if override.Repo != "" { + remoteRepo = override.Repo + } + } else { + var urlOverride string + + if err := overrideNode.Decode(&urlOverride); err == nil { + repo.Remotes[remoteName] = urlOverride + + continue + } + } + } + + if def, ok := raw.Remotes[remoteName]; ok && def.URL != "" { + url := expandURL(def.URL, remoteUser, remoteRepo) + + repo.Remotes[remoteName] = url + } + } + + return repo, nil +} + +func expandURL(template, user, repo string) string { + url := strings.ReplaceAll(template, "${user}", user) + url = strings.ReplaceAll(url, "${repo}", repo) + + return url +} + +func splitRepoName(name string) (user, repo string) { + parts := strings.SplitN(name, "/", 2) + + if len(parts) == 2 { + return parts[0], parts[1] + } + + return "", parts[0] +} + +func Path() (string, error) { + configDir := os.Getenv("XDG_CONFIG_HOME") + + if configDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + configDir = filepath.Join(home, ".config") + } + + return filepath.Join(configDir, "mugi", "config.yaml"), nil +} + +func (c Config) FindRepo(name string) (string, Repo, bool) { + if repo, ok := c.Repos[name]; ok { + return name, repo, true + } + + var matches []string + + for fullName := range c.Repos { + repoName := filepath.Base(fullName) + + if repoName == name { + matches = append(matches, fullName) + } + } + + if len(matches) == 1 { + return matches[0], c.Repos[matches[0]], true + } + + return "", Repo{}, false +} + +func (c Config) AllRepos() []string { + repos := make([]string, 0, len(c.Repos)) + + for name := range c.Repos { + repos = append(repos, name) + } + + return repos +} + +func (c Config) ResolveAlias(alias string) string { + for name, def := range c.Remotes { + if name == alias || slices.Contains(def.Aliases, alias) { + return name + } + } + + return alias +} + +func (r Repo) ExpandPath() string { + path := r.Path + + if len(path) > 0 && path[0] == '~' { + if home, err := os.UserHomeDir(); err == nil { + path = filepath.Join(home, path[1:]) + } + } + + return path +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..13e9cb2 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,214 @@ +package git + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" + + "github.com/ebisu/mugi/internal/remote" +) + +const sshEnv = "GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=accept-new" + +func gitEnv() []string { + return append(os.Environ(), sshEnv) +} + +type Result struct { + Repo string + Remote string + Output string + Error error + ExitCode int +} + +func (r *Result) setError(err error) { + r.Error = err + + var exitErr *exec.ExitError + + if errors.As(err, &exitErr) { + r.ExitCode = exitErr.ExitCode() + } else { + r.ExitCode = 1 + } + + if r.Output == "" { + r.Output = err.Error() + } +} + +func Execute(ctx context.Context, op remote.Operation, repoPath, remoteName string) Result { + result := Result{ + Repo: repoPath, + Remote: remoteName, + } + + args := buildArgs(op, remoteName, repoPath) + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repoPath + cmd.Env = gitEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Output = strings.TrimSpace(stdout.String() + stderr.String()) + + if err != nil { + result.setError(err) + } + + return result +} + +func buildArgs(op remote.Operation, remoteName, repoPath string) []string { + switch op { + case remote.Pull: + branch := currentBranch(repoPath) + if branch == "" { + branch = "HEAD" + } + return []string{"pull", remoteName, branch} + case remote.Push: + return []string{"push", remoteName} + case remote.Fetch: + return []string{"fetch", remoteName} + default: + return []string{} + } +} + +func currentBranch(repoPath string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = repoPath + + out, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +} + +func IsRepo(path string) bool { + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + + return cmd.Run() == nil +} + +func Clone(ctx context.Context, url, path string) Result { + result := Result{ + Repo: path, + Remote: "origin", + } + + cmd := exec.CommandContext(ctx, "git", "clone", url, path) + cmd.Env = gitEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Output = strings.TrimSpace(stdout.String() + stderr.String()) + + if err != nil { + result.setError(err) + } + + return result +} + +func AddRemote(ctx context.Context, repoPath, name, url string) Result { + result := Result{ + Repo: repoPath, + Remote: name, + } + + cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url) + cmd.Dir = repoPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Output = strings.TrimSpace(stdout.String() + stderr.String()) + + if err != nil { + result.setError(err) + } + + return result +} + +func RenameRemote(ctx context.Context, repoPath, oldName, newName string) Result { + result := Result{ + Repo: repoPath, + Remote: newName, + } + + cmd := exec.CommandContext(ctx, "git", "remote", "rename", oldName, newName) + cmd.Dir = repoPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Output = strings.TrimSpace(stdout.String() + stderr.String()) + + if err != nil { + result.setError(err) + } + + return result +} + +func HasRemote(repoPath, name string) bool { + cmd := exec.Command("git", "remote", "get-url", name) + cmd.Dir = repoPath + + return cmd.Run() == nil +} + +func SetRemoteURL(ctx context.Context, repoPath, name, url string) Result { + result := Result{ + Repo: repoPath, + Remote: name, + } + + cmd := exec.CommandContext(ctx, "git", "remote", "set-url", name, url) + cmd.Dir = repoPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Output = strings.TrimSpace(stdout.String() + stderr.String()) + + if err != nil { + result.setError(err) + } + + return result +} + +func GetRemoteURL(repoPath, name string) string { + cmd := exec.Command("git", "remote", "get-url", name) + cmd.Dir = repoPath + + out, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +} diff --git a/internal/remote/remote.go b/internal/remote/remote.go new file mode 100644 index 0000000..7280c66 --- /dev/null +++ b/internal/remote/remote.go @@ -0,0 +1,50 @@ +package remote + +type Operation int + +const ( + Pull Operation = iota + Push + Fetch +) + +func (o Operation) String() string { + switch o { + case Pull: + return "pull" + case Push: + return "push" + case Fetch: + return "fetch" + default: + return "unknown" + } +} + +func (o Operation) Verb() string { + switch o { + case Pull: + return "Pulling" + case Push: + return "Pushing" + case Fetch: + return "Fetching" + default: + return "Operating" + } +} + +func (o Operation) PastTense() string { + switch o { + case Pull: + return "Pulled" + case Push: + return "Pushed" + case Fetch: + return "Fetched" + default: + return "Completed" + } +} + +const All = "all" diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..cee3442 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,626 @@ +package ui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ebisu/mugi/internal/config" + "github.com/ebisu/mugi/internal/git" + "github.com/ebisu/mugi/internal/remote" +) + +type Task struct { + RepoName string + RemoteName string + RemoteURL string + RepoPath string + Op remote.Operation +} + +type taskState int + +const ( + taskPending taskState = iota + taskRunning + taskSuccess + taskFailed +) + +type taskResult struct { + task Task + result git.Result +} + +type Model struct { + tasks []Task + states map[string]taskState + results map[string]git.Result + spinner spinner.Model + operation remote.Operation + verbose bool + done bool +} + +func NewModel(op remote.Operation, tasks []Task, verbose bool) Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + states := make(map[string]taskState) + + for _, t := range tasks { + states[taskKey(t)] = taskPending + } + + return Model{ + tasks: tasks, + states: states, + results: make(map[string]git.Result), + spinner: s, + operation: op, + verbose: verbose, + } +} + +func taskKey(t Task) string { + return t.RepoName + ":" + t.RemoteName +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{m.spinner.Tick} + + for _, task := range m.tasks { + cmds = append(cmds, m.runTask(task)) + } + + return tea.Batch(cmds...) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + + return m, cmd + + case taskResult: + key := taskKey(msg.task) + if msg.result.Error != nil { + m.states[key] = taskFailed + } else { + m.states[key] = taskSuccess + } + m.results[key] = msg.result + + if m.allDone() { + m.done = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m Model) View() string { + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + Render(fmt.Sprintf("%s repositories", m.operation.Verb())) + + b.WriteString(title + "\n\n") + + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + + for _, task := range m.tasks { + key := taskKey(task) + state := m.states[key] + + var status string + switch state { + case taskPending: + status = dimStyle.Render("○") + case taskRunning: + status = m.spinner.View() + case taskSuccess: + status = successStyle.Render("✓") + case taskFailed: + status = failStyle.Render("✗") + } + + repoName := filepath.Base(task.RepoName) + line := fmt.Sprintf("%s %s → %s", status, repoName, task.RemoteName) + + if result, ok := m.results[key]; ok && result.Output != "" { + if m.verbose { + line += "\n" + indentOutput(result.Output, dimStyle) + } else if state == taskFailed { + line += dimStyle.Render(" " + firstLine(result.Output)) + } + } + + b.WriteString(line + "\n") + } + + if m.done { + b.WriteString("\n") + success, failed := m.summary() + if failed > 0 { + b.WriteString(failStyle.Render(fmt.Sprintf("%d failed", failed))) + b.WriteString(", ") + } + b.WriteString(successStyle.Render(fmt.Sprintf("%d succeeded", success))) + b.WriteString("\n") + } + + return b.String() +} + +func (m *Model) runTask(task Task) tea.Cmd { + return func() tea.Msg { + op := task.Op + + if op == 0 { + op = m.operation + } + + result := git.Execute(context.Background(), op, task.RepoPath, task.RemoteName) + + return taskResult{task: task, result: result} + } +} + +func (m Model) allDone() bool { + for _, state := range m.states { + if state == taskPending || state == taskRunning { + return false + } + } + + return true +} + +func (m Model) summary() (success, failed int) { + for _, state := range m.states { + switch state { + case taskSuccess: + success++ + case taskFailed: + failed++ + } + } + return +} + +func firstLine(s string) string { + if idx := strings.Index(s, "\n"); idx != -1 { + return s[:idx] + } + + return s +} + +func indentOutput(s string, style lipgloss.Style) string { + lines := strings.Split(s, "\n") + + for i, line := range lines { + lines[i] = " " + style.Render(line) + } + + return strings.Join(lines, "\n") +} + +func Run(op remote.Operation, tasks []Task, verbose bool) error { + if op == remote.Pull { + inits := NeedsInit(tasks) + if len(inits) > 0 { + if err := runInit(inits, verbose); err != nil { + return err + } + } + } + + syncRemotes(tasks) + + if op == remote.Pull { + tasks = adjustPullTasks(tasks) + } + + model := NewModel(op, tasks, verbose) + p := tea.NewProgram(model) + + _, err := p.Run() + + return err +} + +func syncRemotes(tasks []Task) { + ctx := context.Background() + seen := make(map[string]bool) + + for _, task := range tasks { + key := task.RepoPath + ":" + task.RemoteName + + if seen[key] { + continue + } + + seen[key] = true + + if !git.IsRepo(task.RepoPath) { + continue + } + + currentURL := git.GetRemoteURL(task.RepoPath, task.RemoteName) + + if currentURL == "" { + git.AddRemote(ctx, task.RepoPath, task.RemoteName, task.RemoteURL) + } else if currentURL != task.RemoteURL { + git.SetRemoteURL(ctx, task.RepoPath, task.RemoteName, task.RemoteURL) + } + } +} + +func adjustPullTasks(tasks []Task) []Task { + firstPerRepo := make(map[string]bool) + result := make([]Task, len(tasks)) + + for i, task := range tasks { + result[i] = task + + if firstPerRepo[task.RepoPath] { + result[i].Op = remote.Fetch + } else { + result[i].Op = remote.Pull + firstPerRepo[task.RepoPath] = true + } + } + + return result +} + +func runInit(inits []RepoInit, verbose bool) error { + model := NewInitModel(inits, verbose) + p := tea.NewProgram(model) + + m, err := p.Run() + if err != nil { + return err + } + + if initModel, ok := m.(InitModel); ok { + for _, state := range initModel.states { + if state == taskFailed { + return fmt.Errorf("repository initialisation failed") + } + } + } + + return nil +} + +func BuildTasks(cfg config.Config, repoName string, remoteNames []string) []Task { + var tasks []Task + + repos := resolveRepos(cfg, repoName) + + for _, fullName := range repos { + repo := cfg.Repos[fullName] + remotes := resolveRemotes(cfg, repo, remoteNames) + + for _, remoteName := range remotes { + if url, ok := repo.Remotes[remoteName]; ok { + tasks = append(tasks, Task{ + RepoName: fullName, + RemoteName: remoteName, + RemoteURL: url, + RepoPath: repo.ExpandPath(), + }) + } + } + } + + return tasks +} + +type RepoInit struct { + Name string + Path string + Remotes map[string]string +} + +type InitResult struct { + Repo string + Output string + Error error + Success bool +} + +type initTaskResult struct { + init RepoInit + result InitResult +} + +type InitModel struct { + inits []RepoInit + states map[string]taskState + results map[string]InitResult + spinner spinner.Model + verbose bool + done bool +} + +func NewInitModel(inits []RepoInit, verbose bool) InitModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + states := make(map[string]taskState) + + for _, init := range inits { + states[init.Path] = taskPending + } + + return InitModel{ + inits: inits, + states: states, + results: make(map[string]InitResult), + spinner: s, + verbose: verbose, + } +} + +func (m InitModel) Init() tea.Cmd { + cmds := []tea.Cmd{m.spinner.Tick} + + for _, init := range m.inits { + cmds = append(cmds, m.runInit(init)) + } + + return tea.Batch(cmds...) +} + +func (m InitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + + return m, cmd + + case initTaskResult: + if msg.result.Success { + m.states[msg.init.Path] = taskSuccess + } else { + m.states[msg.init.Path] = taskFailed + } + m.results[msg.init.Path] = msg.result + + if m.allDone() { + m.done = true + return m, tea.Quit + } + } + + return m, nil +} + +func (m InitModel) View() string { + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")). + Render("Initialising repositories") + + b.WriteString(title + "\n\n") + + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + + for _, init := range m.inits { + state := m.states[init.Path] + + var status string + switch state { + case taskPending: + status = dimStyle.Render("○") + case taskRunning: + status = m.spinner.View() + case taskSuccess: + status = successStyle.Render("✓") + case taskFailed: + status = failStyle.Render("✗") + } + + repoName := filepath.Base(init.Name) + line := fmt.Sprintf("%s %s", status, repoName) + + if result, ok := m.results[init.Path]; ok && result.Output != "" { + if m.verbose || !result.Success { + line += "\n" + indentOutput(result.Output, dimStyle) + } + } + + b.WriteString(line + "\n") + } + + if m.done { + b.WriteString("\n") + } + + return b.String() +} + +func (m *InitModel) runInit(init RepoInit) tea.Cmd { + return func() tea.Msg { + result := InitRepo(context.Background(), init) + + return initTaskResult{init: init, result: result} + } +} + +func (m InitModel) allDone() bool { + for _, state := range m.states { + if state == taskPending || state == taskRunning { + return false + } + } + + return true +} + +func NeedsInit(tasks []Task) []RepoInit { + seen := make(map[string]bool) + + var inits []RepoInit + + for _, task := range tasks { + if seen[task.RepoPath] { + continue + } + + seen[task.RepoPath] = true + + if _, err := os.Stat(task.RepoPath); os.IsNotExist(err) { + inits = append(inits, collectRepoInit(tasks, task.RepoPath, task.RepoName)) + + continue + } + + if !git.IsRepo(task.RepoPath) { + inits = append(inits, collectRepoInit(tasks, task.RepoPath, task.RepoName)) + } + } + + return inits +} + +func collectRepoInit(tasks []Task, path, name string) RepoInit { + remotes := make(map[string]string) + + for _, t := range tasks { + if t.RepoPath == path { + remotes[t.RemoteName] = t.RemoteURL + } + } + + return RepoInit{Name: name, Path: path, Remotes: remotes} +} + +func InitRepo(ctx context.Context, init RepoInit) InitResult { + result := InitResult{Repo: init.Name} + + var firstRemote, firstURL string + + for name, url := range init.Remotes { + firstRemote = name + firstURL = url + + break + } + + if err := os.MkdirAll(filepath.Dir(init.Path), 0o755); err != nil { + result.Error = err + result.Output = err.Error() + + return result + } + + cloneResult := git.Clone(ctx, firstURL, init.Path) + + if cloneResult.Error != nil { + result.Error = cloneResult.Error + result.Output = cloneResult.Output + + return result + } + + outputs := []string{fmt.Sprintf("Cloned from %s", firstRemote)} + + renameResult := git.RenameRemote(ctx, init.Path, "origin", firstRemote) + + if renameResult.Error != nil { + result.Error = renameResult.Error + result.Output = renameResult.Output + + return result + } + + for name, url := range init.Remotes { + if name == firstRemote { + continue + } + + addResult := git.AddRemote(ctx, init.Path, name, url) + + if addResult.Error != nil { + result.Error = addResult.Error + result.Output = addResult.Output + + return result + } + + outputs = append(outputs, fmt.Sprintf("Added remote %s", name)) + } + + result.Success = true + result.Output = strings.Join(outputs, "\n") + + return result +} + +func resolveRepos(cfg config.Config, name string) []string { + if name == remote.All { + return cfg.AllRepos() + } + + if fullName, _, ok := cfg.FindRepo(name); ok { + return []string{fullName} + } + + return nil +} + +func resolveRemotes(cfg config.Config, repo config.Repo, names []string) []string { + if len(names) == 1 && names[0] == remote.All { + remotes := make([]string, 0, len(repo.Remotes)) + + for name := range repo.Remotes { + remotes = append(remotes, name) + } + + return remotes + } + + resolved := make([]string, 0, len(names)) + + for _, name := range names { + resolved = append(resolved, cfg.ResolveAlias(name)) + } + + return resolved +} |