b80713b334
Fetch and prune the preferred remote, then reconcile every local branch independently. Fast-forward branches that are behind, preserve divergent work, and delete a branch only when its configured upstream is gone and its commits are already contained by the remote default branch. Keep dirty repositories eligible while protecting the active branch and refs checked out in linked worktrees. Preserve the previous all-clean policy behind --strict, expose structured per-branch results, add real bare-remote integration tests, and retain the upstream MIT notice for the adapted hub sync behavior.
399 lines
10 KiB
Go
399 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
var (
|
||
amber = lipgloss.Color("214")
|
||
green = lipgloss.Color("42")
|
||
red = lipgloss.Color("196")
|
||
muted = lipgloss.Color("241")
|
||
panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1)
|
||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(amber)
|
||
readyStyle = lipgloss.NewStyle().Foreground(green)
|
||
blockStyle = lipgloss.NewStyle().Foreground(red)
|
||
mutedStyle = lipgloss.NewStyle().Foreground(muted)
|
||
)
|
||
|
||
type tuiMode int
|
||
|
||
const (
|
||
modeReady tuiMode = iota
|
||
modeConfirm
|
||
modeSyncing
|
||
)
|
||
|
||
type tickMsg time.Time
|
||
type scanDoneMsg struct {
|
||
repos []repo
|
||
err error
|
||
}
|
||
type syncDoneMsg struct {
|
||
results []syncResult
|
||
repos []repo
|
||
err error
|
||
}
|
||
|
||
type tuiModel struct {
|
||
root string
|
||
repos []repo
|
||
selected map[string]bool
|
||
opts options
|
||
cursor int
|
||
offset int
|
||
width int
|
||
height int
|
||
mode tuiMode
|
||
frame int
|
||
status string
|
||
lastResult []syncResult
|
||
}
|
||
|
||
func runTUI(root string, repos []repo, opts options) error {
|
||
m := newTUIModel(root, repos, opts)
|
||
program := tea.NewProgram(m, tea.WithAltScreen())
|
||
_, err := program.Run()
|
||
return err
|
||
}
|
||
|
||
func newTUIModel(root string, repos []repo, opts options) tuiModel {
|
||
selected := make(map[string]bool)
|
||
for _, r := range repos {
|
||
selected[r.Path] = r.Eligible
|
||
}
|
||
return tuiModel{root: root, repos: repos, selected: selected, opts: opts, mode: modeReady}
|
||
}
|
||
|
||
func (m tuiModel) Init() tea.Cmd { return nil }
|
||
|
||
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
m.clampViewport()
|
||
return m, nil
|
||
case tickMsg:
|
||
if m.mode == modeSyncing {
|
||
m.frame++
|
||
return m, tickCmd()
|
||
}
|
||
return m, nil
|
||
case scanDoneMsg:
|
||
if msg.err != nil {
|
||
m.status = "Refresh failed: " + msg.err.Error()
|
||
return m, nil
|
||
}
|
||
m.repos = msg.repos
|
||
m.reconcileSelection()
|
||
m.status = "Workspace refreshed"
|
||
m.clampViewport()
|
||
return m, nil
|
||
case syncDoneMsg:
|
||
m.mode = modeReady
|
||
m.lastResult = msg.results
|
||
if msg.err != nil {
|
||
m.status = "Sync failed: " + msg.err.Error()
|
||
return m, nil
|
||
}
|
||
m.repos = msg.repos
|
||
m.reconcileSelection()
|
||
var ok, failed, skipped int
|
||
for _, result := range msg.results {
|
||
switch {
|
||
case result.Success:
|
||
ok++
|
||
case result.Skipped:
|
||
skipped++
|
||
default:
|
||
failed++
|
||
}
|
||
}
|
||
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
|
||
return m, nil
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
value := key.String()
|
||
if m.mode == modeSyncing {
|
||
if value == "q" || value == "ctrl+c" {
|
||
m.status = "Sync is running; wait for it to finish before quitting"
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.mode == modeConfirm {
|
||
switch value {
|
||
case "y", "Y":
|
||
selected := m.selectedRepos()
|
||
m.mode = modeSyncing
|
||
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
|
||
return m, tea.Batch(m.syncCmd(selected), tickCmd())
|
||
case "n", "N", "esc", "q":
|
||
m.mode = modeReady
|
||
m.status = "Sync cancelled; nothing changed"
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
switch value {
|
||
case "q", "ctrl+c":
|
||
return m, tea.Quit
|
||
case "up", "k":
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
}
|
||
case "down", "j":
|
||
if m.cursor+1 < len(m.repos) {
|
||
m.cursor++
|
||
}
|
||
case " ":
|
||
if len(m.repos) > 0 && m.repos[m.cursor].Eligible {
|
||
path := m.repos[m.cursor].Path
|
||
m.selected[path] = !m.selected[path]
|
||
}
|
||
case "a":
|
||
for _, r := range m.repos {
|
||
if r.Eligible {
|
||
m.selected[r.Path] = true
|
||
}
|
||
}
|
||
m.status = "Selected every eligible repository"
|
||
case "n":
|
||
for path := range m.selected {
|
||
m.selected[path] = false
|
||
}
|
||
m.status = "Selection cleared"
|
||
case "r":
|
||
m.status = "Refreshing workspace…"
|
||
return m, m.scanCmd()
|
||
case "s", "enter":
|
||
count := len(m.selectedRepos())
|
||
if count == 0 {
|
||
m.status = "Select at least one eligible repository"
|
||
} else {
|
||
m.mode = modeConfirm
|
||
}
|
||
}
|
||
m.clampViewport()
|
||
return m, nil
|
||
}
|
||
|
||
func (m tuiModel) View() string {
|
||
width := m.width
|
||
if width <= 0 {
|
||
width = 100
|
||
}
|
||
contentWidth := max(40, width-6)
|
||
ready, blocked, selected := eligibleCount(m.repos), blockedCount(m.repos), len(m.selectedRepos())
|
||
|
||
var b strings.Builder
|
||
b.WriteString(titleStyle.Render("STACK SYNC"))
|
||
b.WriteString(mutedStyle.Render(" workspace control deck"))
|
||
b.WriteString("\n")
|
||
b.WriteString(mutedStyle.Render(truncateMiddle(m.root, contentWidth)))
|
||
b.WriteString("\n\n")
|
||
b.WriteString(readyStyle.Render(fmt.Sprintf("● %d ready", ready)))
|
||
b.WriteString(" ")
|
||
b.WriteString(blockStyle.Render(fmt.Sprintf("● %d blocked", blocked)))
|
||
b.WriteString(" ")
|
||
b.WriteString(titleStyle.Render(fmt.Sprintf("◆ %d selected", selected)))
|
||
b.WriteString("\n\n")
|
||
|
||
visible := m.visibleRows()
|
||
nameWidth := max(12, min(42, contentWidth-38))
|
||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %-3s %-*s %-16s %s", "", nameWidth, "REPOSITORY", "BRANCH", "STATE")))
|
||
b.WriteString("\n")
|
||
for i := visible.start; i < visible.end; i++ {
|
||
r := m.repos[i]
|
||
pointer := " "
|
||
if i == m.cursor {
|
||
pointer = "›"
|
||
}
|
||
check := "[ ]"
|
||
if !r.Eligible {
|
||
check = "[–]"
|
||
} else if m.selected[r.Path] {
|
||
check = "[×]"
|
||
}
|
||
state := readyStyle.Render("ready")
|
||
if !r.Eligible {
|
||
state = blockStyle.Render("blocked")
|
||
} else if len(r.Dirty) > 0 {
|
||
state = titleStyle.Render("ready (dirty)")
|
||
}
|
||
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
|
||
if i == m.cursor {
|
||
line = lipgloss.NewStyle().Bold(true).Foreground(amber).Render(line)
|
||
}
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
if visible.start > 0 || visible.end < len(m.repos) {
|
||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" showing %d–%d of %d", visible.start+1, visible.end, len(m.repos))))
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
b.WriteString("\n")
|
||
b.WriteString(m.detailView(contentWidth))
|
||
b.WriteString("\n")
|
||
|
||
switch m.mode {
|
||
case modeConfirm:
|
||
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
|
||
b.WriteString("\n")
|
||
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
|
||
case modeSyncing:
|
||
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
|
||
b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected)))
|
||
b.WriteString("\n")
|
||
b.WriteString(mutedStyle.Render("Safety is rechecked immediately before each repository. Please wait."))
|
||
default:
|
||
if m.status != "" {
|
||
b.WriteString(m.status)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit"))
|
||
}
|
||
return panel.Width(contentWidth).Render(b.String())
|
||
}
|
||
|
||
type rowRange struct{ start, end int }
|
||
|
||
func (m tuiModel) visibleRows() rowRange {
|
||
available := m.height - 15
|
||
if available < 4 {
|
||
available = 4
|
||
}
|
||
end := min(len(m.repos), m.offset+available)
|
||
return rowRange{start: min(m.offset, end), end: end}
|
||
}
|
||
|
||
func (m *tuiModel) clampViewport() {
|
||
if len(m.repos) == 0 {
|
||
m.cursor, m.offset = 0, 0
|
||
return
|
||
}
|
||
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
|
||
available := max(4, m.height-15)
|
||
if m.cursor < m.offset {
|
||
m.offset = m.cursor
|
||
}
|
||
if m.cursor >= m.offset+available {
|
||
m.offset = m.cursor - available + 1
|
||
}
|
||
}
|
||
|
||
func (m tuiModel) detailView(width int) string {
|
||
if len(m.repos) == 0 {
|
||
return mutedStyle.Render("No repositories found")
|
||
}
|
||
r := m.repos[m.cursor]
|
||
var lines []string
|
||
if r.Eligible {
|
||
state := readyStyle.Render("READY")
|
||
if len(r.Dirty) > 0 {
|
||
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
|
||
}
|
||
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
|
||
} else {
|
||
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
|
||
}
|
||
for i, item := range r.Dirty {
|
||
if i == 3 {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf("… and %d more changes", len(r.Dirty)-3)))
|
||
break
|
||
}
|
||
lines = append(lines, " "+truncateMiddle(item, width-4))
|
||
}
|
||
if len(r.NestedRepoEntries) > 0 {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf("%d nested repository path(s) safely excluded from parent dirtiness", len(r.NestedRepoEntries))))
|
||
}
|
||
for _, result := range m.lastResult {
|
||
if result.Path != r.RelativePath || result.Message == "" {
|
||
continue
|
||
}
|
||
state := readyStyle.Render("LAST SYNC")
|
||
if !result.Success {
|
||
state = blockStyle.Render("LAST SYNC FAILED")
|
||
}
|
||
lines = append(lines, state+mutedStyle.Render(" "+result.Duration))
|
||
output := strings.Split(strings.ReplaceAll(result.Message, "\r", "\n"), "\n")
|
||
shown := 0
|
||
for _, outputLine := range output {
|
||
if strings.TrimSpace(outputLine) == "" {
|
||
continue
|
||
}
|
||
if shown == 3 {
|
||
lines = append(lines, mutedStyle.Render(" … more output available in the CLI/JSON interface"))
|
||
break
|
||
}
|
||
lines = append(lines, " "+truncateMiddle(strings.TrimSpace(outputLine), width-4))
|
||
shown++
|
||
}
|
||
break
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m tuiModel) selectedRepos() []repo {
|
||
selected := make([]repo, 0)
|
||
for _, r := range m.repos {
|
||
if r.Eligible && m.selected[r.Path] {
|
||
selected = append(selected, r)
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func (m *tuiModel) reconcileSelection() {
|
||
next := make(map[string]bool)
|
||
for _, r := range m.repos {
|
||
next[r.Path] = r.Eligible && m.selected[r.Path]
|
||
}
|
||
m.selected = next
|
||
}
|
||
|
||
func (m tuiModel) scanCmd() tea.Cmd {
|
||
return func() tea.Msg {
|
||
paths, err := discover(m.root, m.opts.exclusions)
|
||
if err != nil {
|
||
return scanDoneMsg{err: err}
|
||
}
|
||
return scanDoneMsg{repos: inspectAll(m.root, paths, m.opts.jobs)}
|
||
}
|
||
}
|
||
|
||
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
|
||
return func() tea.Msg {
|
||
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false)
|
||
paths, err := discover(m.root, m.opts.exclusions)
|
||
if err != nil {
|
||
return syncDoneMsg{results: results, err: err}
|
||
}
|
||
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
|
||
}
|
||
}
|
||
|
||
func tickCmd() tea.Cmd {
|
||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||
}
|
||
|
||
func truncateMiddle(value string, width int) string {
|
||
if width < 2 || len(value) <= width {
|
||
return value
|
||
}
|
||
left := (width - 1) / 2
|
||
right := width - left - 1
|
||
return value[:left] + "…" + value[len(value)-right:]
|
||
}
|