aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-26 05:13:25 +0000
committerFuwn <[email protected]>2026-01-26 05:13:25 +0000
commit567e5b51d32450999935bcf3af143248888e12e9 (patch)
tree41bb8b2a8fcae168bcb7044adb84f08dc89aaec0 /internal
downloadmugi-567e5b51d32450999935bcf3af143248888e12e9.tar.xz
mugi-567e5b51d32450999935bcf3af143248888e12e9.zip
feat: Initial commit
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go176
-rw-r--r--internal/config/config.go250
-rw-r--r--internal/git/git.go214
-rw-r--r--internal/remote/remote.go50
-rw-r--r--internal/ui/ui.go626
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
+}