🍰 Personal Multi-Git Remote Manager
go
git
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}