🍰 Personal Multi-Git Remote Manager
go git
at main 650 lines 13 kB view raw
1package ui 2 3import ( 4 "context" 5 "fmt" 6 "os" 7 "path/filepath" 8 "strings" 9 10 "github.com/charmbracelet/bubbles/spinner" 11 tea "github.com/charmbracelet/bubbletea" 12 "github.com/charmbracelet/lipgloss" 13 "github.com/ebisu/mugi/internal/config" 14 "github.com/ebisu/mugi/internal/git" 15 "github.com/ebisu/mugi/internal/remote" 16) 17 18type Task struct { 19 RepoName string 20 RemoteName string 21 RemoteURL string 22 RepoPath string 23 Op remote.Operation 24} 25 26type taskState int 27 28const ( 29 taskPending taskState = iota 30 taskRunning 31 taskSuccess 32 taskFailed 33) 34 35type taskResult struct { 36 task Task 37 result git.Result 38} 39 40type Model struct { 41 tasks []Task 42 states map[string]taskState 43 results map[string]git.Result 44 spinner spinner.Model 45 operation remote.Operation 46 verbose bool 47 force bool 48 linear bool 49 currentTask int 50 done bool 51} 52 53func NewModel(op remote.Operation, tasks []Task, verbose, force, linear bool) Model { 54 s := spinner.New() 55 s.Spinner = spinner.Dot 56 s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 57 58 states := make(map[string]taskState) 59 60 for _, t := range tasks { 61 states[taskKey(t)] = taskPending 62 } 63 64 return Model{ 65 tasks: tasks, 66 states: states, 67 results: make(map[string]git.Result), 68 spinner: s, 69 operation: op, 70 verbose: verbose, 71 force: force, 72 linear: linear, 73 } 74} 75 76func taskKey(t Task) string { 77 return t.RepoName + ":" + t.RemoteName 78} 79 80func (m Model) Init() tea.Cmd { 81 cmds := []tea.Cmd{m.spinner.Tick} 82 83 if m.linear { 84 if len(m.tasks) > 0 { 85 cmds = append(cmds, m.runTask(m.tasks[0])) 86 } 87 } else { 88 for _, task := range m.tasks { 89 cmds = append(cmds, m.runTask(task)) 90 } 91 } 92 93 return tea.Batch(cmds...) 94} 95 96func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 97 switch msg := msg.(type) { 98 case tea.KeyMsg: 99 switch msg.String() { 100 case "q", "ctrl+c": 101 return m, tea.Quit 102 } 103 104 case spinner.TickMsg: 105 var cmd tea.Cmd 106 m.spinner, cmd = m.spinner.Update(msg) 107 108 return m, cmd 109 110 case taskResult: 111 key := taskKey(msg.task) 112 113 if msg.result.Error != nil { 114 m.states[key] = taskFailed 115 } else { 116 m.states[key] = taskSuccess 117 } 118 119 m.results[key] = msg.result 120 m.currentTask++ 121 122 if m.allDone() { 123 m.done = true 124 125 return m, tea.Quit 126 } 127 128 if m.linear && m.currentTask < len(m.tasks) { 129 return m, m.runTask(m.tasks[m.currentTask]) 130 } 131 } 132 133 return m, nil 134} 135 136func (m Model) View() string { 137 var b strings.Builder 138 139 title := lipgloss.NewStyle(). 140 Bold(true). 141 Foreground(lipgloss.Color("212")). 142 Render(fmt.Sprintf("%s repositories", m.operation.Verb())) 143 144 b.WriteString(title + "\n\n") 145 146 successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) 147 failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) 148 dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 149 150 for _, task := range m.tasks { 151 key := taskKey(task) 152 state := m.states[key] 153 154 var status string 155 switch state { 156 case taskPending: 157 status = dimStyle.Render("○") 158 case taskRunning: 159 status = m.spinner.View() 160 case taskSuccess: 161 status = successStyle.Render("✓") 162 case taskFailed: 163 status = failStyle.Render("✗") 164 } 165 166 repoName := filepath.Base(task.RepoName) 167 line := fmt.Sprintf("%s %s → %s", status, repoName, task.RemoteName) 168 169 if result, ok := m.results[key]; ok && result.Output != "" { 170 if m.verbose { 171 line += "\n" + indentOutput(result.Output, dimStyle) 172 } else if state == taskFailed { 173 line += dimStyle.Render(" " + firstLine(result.Output)) 174 } 175 } 176 177 b.WriteString(line + "\n") 178 } 179 180 if m.done { 181 b.WriteString("\n") 182 183 success, failed := m.summary() 184 185 if failed > 0 { 186 b.WriteString(failStyle.Render(fmt.Sprintf("%d failed", failed))) 187 b.WriteString(", ") 188 } 189 190 b.WriteString(successStyle.Render(fmt.Sprintf("%d succeeded", success))) 191 b.WriteString("\n") 192 } 193 194 return b.String() 195} 196 197func (m *Model) runTask(task Task) tea.Cmd { 198 return func() tea.Msg { 199 op := task.Op 200 201 if op == 0 { 202 op = m.operation 203 } 204 205 result := git.Execute(context.Background(), op, task.RepoPath, task.RemoteName, m.force) 206 207 return taskResult{task: task, result: result} 208 } 209} 210 211func (m Model) allDone() bool { 212 for _, state := range m.states { 213 if state == taskPending || state == taskRunning { 214 return false 215 } 216 } 217 218 return true 219} 220 221func (m Model) summary() (success, failed int) { 222 for _, state := range m.states { 223 switch state { 224 case taskSuccess: 225 success++ 226 case taskFailed: 227 failed++ 228 } 229 } 230 return 231} 232 233func firstLine(s string) string { 234 if idx := strings.Index(s, "\n"); idx != -1 { 235 return s[:idx] 236 } 237 238 return s 239} 240 241func indentOutput(s string, style lipgloss.Style) string { 242 lines := strings.Split(s, "\n") 243 244 for i, line := range lines { 245 lines[i] = " " + style.Render(line) 246 } 247 248 return strings.Join(lines, "\n") 249} 250 251func Run(op remote.Operation, tasks []Task, verbose, force, linear bool) error { 252 if op == remote.Pull { 253 inits := NeedsInit(tasks) 254 if len(inits) > 0 { 255 if err := runInit(inits, verbose); err != nil { 256 return err 257 } 258 } 259 } 260 261 syncRemotes(tasks) 262 263 if op == remote.Pull { 264 tasks = adjustPullTasks(tasks) 265 } 266 267 model := NewModel(op, tasks, verbose, force, linear) 268 p := tea.NewProgram(model) 269 270 _, err := p.Run() 271 272 return err 273} 274 275func syncRemotes(tasks []Task) { 276 ctx := context.Background() 277 seen := make(map[string]bool) 278 279 for _, task := range tasks { 280 key := task.RepoPath + ":" + task.RemoteName 281 282 if seen[key] { 283 continue 284 } 285 286 seen[key] = true 287 288 if !git.IsRepo(task.RepoPath) { 289 continue 290 } 291 292 currentURL := git.GetRemoteURL(task.RepoPath, task.RemoteName) 293 294 if currentURL == "" { 295 git.AddRemote(ctx, task.RepoPath, task.RemoteName, task.RemoteURL) 296 } else if currentURL != task.RemoteURL { 297 git.SetRemoteURL(ctx, task.RepoPath, task.RemoteName, task.RemoteURL) 298 } 299 } 300} 301 302func adjustPullTasks(tasks []Task) []Task { 303 firstPerRepo := make(map[string]bool) 304 result := make([]Task, len(tasks)) 305 306 for i, task := range tasks { 307 result[i] = task 308 309 if firstPerRepo[task.RepoPath] { 310 result[i].Op = remote.Fetch 311 } else { 312 result[i].Op = remote.Pull 313 firstPerRepo[task.RepoPath] = true 314 } 315 } 316 317 return result 318} 319 320func runInit(inits []RepoInit, verbose bool) error { 321 model := NewInitModel(inits, verbose) 322 p := tea.NewProgram(model) 323 324 m, err := p.Run() 325 if err != nil { 326 return err 327 } 328 329 if initModel, ok := m.(InitModel); ok { 330 for _, state := range initModel.states { 331 if state == taskFailed { 332 return fmt.Errorf("repository initialisation failed") 333 } 334 } 335 } 336 337 return nil 338} 339 340func BuildTasks(cfg config.Config, repoName string, remoteNames []string) []Task { 341 var tasks []Task 342 343 repos := resolveRepos(cfg, repoName) 344 345 for _, fullName := range repos { 346 repo := cfg.Repos[fullName] 347 remotes := resolveRemotes(cfg, repo, remoteNames) 348 349 for _, remoteName := range remotes { 350 if url, ok := repo.Remotes[remoteName]; ok { 351 tasks = append(tasks, Task{ 352 RepoName: fullName, 353 RemoteName: remoteName, 354 RemoteURL: url, 355 RepoPath: repo.ExpandPath(), 356 }) 357 } 358 } 359 } 360 361 return tasks 362} 363 364type RepoInit struct { 365 Name string 366 Path string 367 Remotes map[string]string 368} 369 370type InitResult struct { 371 Repo string 372 Output string 373 Error error 374 Success bool 375} 376 377type initTaskResult struct { 378 init RepoInit 379 result InitResult 380} 381 382type InitModel struct { 383 inits []RepoInit 384 states map[string]taskState 385 results map[string]InitResult 386 spinner spinner.Model 387 verbose bool 388 done bool 389} 390 391func NewInitModel(inits []RepoInit, verbose bool) InitModel { 392 s := spinner.New() 393 s.Spinner = spinner.Dot 394 s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 395 396 states := make(map[string]taskState) 397 398 for _, init := range inits { 399 states[init.Path] = taskPending 400 } 401 402 return InitModel{ 403 inits: inits, 404 states: states, 405 results: make(map[string]InitResult), 406 spinner: s, 407 verbose: verbose, 408 } 409} 410 411func (m InitModel) Init() tea.Cmd { 412 cmds := []tea.Cmd{m.spinner.Tick} 413 414 for _, init := range m.inits { 415 cmds = append(cmds, m.runInit(init)) 416 } 417 418 return tea.Batch(cmds...) 419} 420 421func (m InitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 422 switch msg := msg.(type) { 423 case tea.KeyMsg: 424 switch msg.String() { 425 case "q", "ctrl+c": 426 return m, tea.Quit 427 } 428 429 case spinner.TickMsg: 430 var cmd tea.Cmd 431 m.spinner, cmd = m.spinner.Update(msg) 432 433 return m, cmd 434 435 case initTaskResult: 436 if msg.result.Success { 437 m.states[msg.init.Path] = taskSuccess 438 } else { 439 m.states[msg.init.Path] = taskFailed 440 } 441 442 m.results[msg.init.Path] = msg.result 443 444 if m.allDone() { 445 m.done = true 446 447 return m, tea.Quit 448 } 449 } 450 451 return m, nil 452} 453 454func (m InitModel) View() string { 455 var b strings.Builder 456 457 title := lipgloss.NewStyle(). 458 Bold(true). 459 Foreground(lipgloss.Color("212")). 460 Render("Initialising repositories") 461 462 b.WriteString(title + "\n\n") 463 464 successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) 465 failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) 466 dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 467 468 for _, init := range m.inits { 469 state := m.states[init.Path] 470 471 var status string 472 switch state { 473 case taskPending: 474 status = dimStyle.Render("○") 475 case taskRunning: 476 status = m.spinner.View() 477 case taskSuccess: 478 status = successStyle.Render("✓") 479 case taskFailed: 480 status = failStyle.Render("✗") 481 } 482 483 repoName := filepath.Base(init.Name) 484 line := fmt.Sprintf("%s %s", status, repoName) 485 486 if result, ok := m.results[init.Path]; ok && result.Output != "" { 487 if m.verbose || !result.Success { 488 line += "\n" + indentOutput(result.Output, dimStyle) 489 } 490 } 491 492 b.WriteString(line + "\n") 493 } 494 495 if m.done { 496 b.WriteString("\n") 497 } 498 499 return b.String() 500} 501 502func (m *InitModel) runInit(init RepoInit) tea.Cmd { 503 return func() tea.Msg { 504 result := InitRepo(context.Background(), init) 505 506 return initTaskResult{init: init, result: result} 507 } 508} 509 510func (m InitModel) allDone() bool { 511 for _, state := range m.states { 512 if state == taskPending || state == taskRunning { 513 return false 514 } 515 } 516 517 return true 518} 519 520func NeedsInit(tasks []Task) []RepoInit { 521 seen := make(map[string]bool) 522 523 var inits []RepoInit 524 525 for _, task := range tasks { 526 if seen[task.RepoPath] { 527 continue 528 } 529 530 seen[task.RepoPath] = true 531 532 if _, err := os.Stat(task.RepoPath); os.IsNotExist(err) { 533 inits = append(inits, collectRepoInit(tasks, task.RepoPath, task.RepoName)) 534 535 continue 536 } 537 538 if !git.IsRepo(task.RepoPath) { 539 inits = append(inits, collectRepoInit(tasks, task.RepoPath, task.RepoName)) 540 } 541 } 542 543 return inits 544} 545 546func collectRepoInit(tasks []Task, path, name string) RepoInit { 547 remotes := make(map[string]string) 548 549 for _, t := range tasks { 550 if t.RepoPath == path { 551 remotes[t.RemoteName] = t.RemoteURL 552 } 553 } 554 555 return RepoInit{Name: name, Path: path, Remotes: remotes} 556} 557 558func InitRepo(ctx context.Context, init RepoInit) InitResult { 559 result := InitResult{Repo: init.Name} 560 561 var firstRemote, firstURL string 562 563 for name, url := range init.Remotes { 564 firstRemote = name 565 firstURL = url 566 567 break 568 } 569 570 if err := os.MkdirAll(filepath.Dir(init.Path), 0o755); err != nil { 571 result.Error = err 572 result.Output = err.Error() 573 574 return result 575 } 576 577 cloneResult := git.Clone(ctx, firstURL, init.Path) 578 579 if cloneResult.Error != nil { 580 result.Error = cloneResult.Error 581 result.Output = cloneResult.Output 582 583 return result 584 } 585 586 outputs := []string{fmt.Sprintf("Cloned from %s", firstRemote)} 587 588 renameResult := git.RenameRemote(ctx, init.Path, "origin", firstRemote) 589 590 if renameResult.Error != nil { 591 result.Error = renameResult.Error 592 result.Output = renameResult.Output 593 594 return result 595 } 596 597 for name, url := range init.Remotes { 598 if name == firstRemote { 599 continue 600 } 601 602 addResult := git.AddRemote(ctx, init.Path, name, url) 603 604 if addResult.Error != nil { 605 result.Error = addResult.Error 606 result.Output = addResult.Output 607 608 return result 609 } 610 611 outputs = append(outputs, fmt.Sprintf("Added remote %s", name)) 612 } 613 614 result.Success = true 615 result.Output = strings.Join(outputs, "\n") 616 617 return result 618} 619 620func resolveRepos(cfg config.Config, name string) []string { 621 if name == remote.All { 622 return cfg.AllRepos() 623 } 624 625 if fullName, _, ok := cfg.FindRepo(name); ok { 626 return []string{fullName} 627 } 628 629 return nil 630} 631 632func resolveRemotes(cfg config.Config, repo config.Repo, names []string) []string { 633 if len(names) == 1 && names[0] == remote.All { 634 remotes := make([]string, 0, len(repo.Remotes)) 635 636 for name := range repo.Remotes { 637 remotes = append(remotes, name) 638 } 639 640 return remotes 641 } 642 643 resolved := make([]string, 0, len(names)) 644 645 for _, name := range names { 646 resolved = append(resolved, cfg.ResolveAlias(name)) 647 } 648 649 return resolved 650}