Show Docker-style per-mirror progress in the wizard · Entire

Show Docker-style per-mirror progress in the wizard

0dbc9eb→main·

toothbrush·3w ago·5 files·+184 added/-16 removed

Replace the single "Creating N mirror(s)…" spinner with one live line per (repo, region), each updating independently as its CreateMirror + clone poll advance (processing → ready), then the existing summary table. Lines read "owner/repo @ <spinner|✓|✗> ". A per-poll status callback now flows through awaitMirrorReady/createAndAwaitMirror (one-shot passes nil and keeps its single spinner). Non-TTY degrades to one printed line per mirror as it finalizes.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

18dbd0a73461View transcript

Changes

5

172 unmodified lines

173
174
175
176
176
177
178
179
180
181
24 unmodified lines

206
207
208
207
209
210
211
212
7 unmodified lines

220
221
222
221
223
224
225
226

172 unmodified lines

return runCoreForCluster(cmd, clusterHost, func(ctx context.Context, c *coreapi.Client) error {
            errW := cmd.ErrOrStderr()
            stop := startSpinner(errW, fmt.Sprintf("Cloning %s/%s into %s", owner, repo, clusterHost))
            outcome, err := createAndAwaitMirror(ctx, c, owner, repo, clusterHost, noWait, waitTimeout)
            // nil onStatus: the one-shot's single spinner shows liveness; the
            // per-mirror progress lines are the wizard's concern.
            outcome, err := createAndAwaitMirror(ctx, c, owner, repo, clusterHost, noWait, waitTimeout, nil)
            // Only a confirmed-ready clone earns the ✓; everything else
            // (empty, --no-wait, suspended, failed, timeout) erases the line
            // and lets reportOneShotMirror print the specific outcome.
            24 unmodified lines

// status. The returned error is the create error (when outcome.created is nil)
// or the wait error — a status sentinel (errMirrorCloneFailed /
// errMirrorSuspended) or a timeout; callers read outcome.status for the state.
func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, clusterHost string, noWait bool, timeout time.Duration) (mirrorCreateOutcome, error) {
func createAndAwaitMirror(ctx context.Context, c *coreapi.Client, owner, repo, clusterHost string, noWait bool, timeout time.Duration, onStatus func(coreapi.MirrorStatus)) (mirrorCreateOutcome, error) {
    created, err := c.CreateMirror(ctx, &coreapi.CreateMirrorInputBody{
        Provider:    coreapi.CreateMirrorInputBodyProviderGithub,
        Owner:       owner,
    7 unmodified lines

if noWait || created.Empty {
        return outcome, nil
    }
    status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout)
    status, werr := awaitMirrorReady(ctx, c, created.MirrorId, timeout, onStatus)
    outcome.status = status
    outcome.polled = true
    return outcome, werr
}

Mcmd/entire/cli/repo_mirror.go+5/-3

7 unmodified lines

8
9
10
11
12
13
14
2 unmodified lines

17
18
19
20
21
22
23
416 unmodified lines

440
441
442
441
443
444
445
446
447
448
449
450
451
452
453
454
455
456
447
457
458
459
460
461
3 unmodified lines

465
466
467
457
468
469
470
471
472
473
474
464
465
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
473
474
491
492
493
494
495
496
497
498
499
4 unmodified lines

504
505
506
507
508
509
510
18 unmodified lines

529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655

7 unmodified lines

"net/url"
    "sort"
    "strings"
    "sync"
    "time"

"charm.land/huh/v2"
2 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/api"
    "github.com/entireio/cli/cmd/entire/cli/auth"
    "github.com/entireio/cli/cmd/entire/cli/interactive"
    "github.com/entireio/cli/internal/coreapi"
}

416 unmodified lines

}
    }

stop := startSpinner(errW, fmt.Sprintf("Creating %d mirror(s)…", len(targets)))
    // Docker-pull-style live progress: one line per (repo, region), each
    // updating independently as its CreateMirror + clone poll advance.
    labels := make([]string, len(targets))
    for i, t := range targets {
        labels[i] = t.owner + "/" + t.repo + " @ " + t.region.host
    }
    prog := newMirrorProgress(errW, labels)
    prog.start()

results := make([]mirrorResult, len(targets))
    g := new(errgroup.Group)
    g.SetLimit(mirrorCreateConcurrency)
    for i, t := range targets {
        g.Go(func() error {
            results[i] = createOneMirror(ctx, t, clientByHost[t.region.host], clientErrByHost[t.region.host], noWait, waitTimeout)
            results[i] = createOneMirror(ctx, t, clientByHost[t.region.host], clientErrByHost[t.region.host], noWait, waitTimeout,
                func(status string, final, ok bool) { prog.set(i, status, final, ok) })
            return nil
        })
    }
3 unmodified lines

if err := g.Wait(); err != nil {
        fmt.Fprintf(errW, "mirror creation: %v\n", err)
    }
    stop(true)
    prog.stop()
    return results
}

// createOneMirror registers a single (repo, region) mirror and, unless noWait
// or the upstream is empty, waits for its initial clone. It never returns an
// error: every outcome is folded into the mirrorResult so a single failure
// can't sink the batch.
func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, clientErr error, noWait bool, waitTimeout time.Duration) mirrorResult {
// can't sink the batch. report (may be nil) is called as the mirror moves
// through its phases so the caller can render live progress; the final call has
// final=true and ok set to whether it succeeded.
func createOneMirror(ctx context.Context, t mirrorTarget, c *coreapi.Client, clientErr error, noWait bool, waitTimeout time.Duration, report func(status string, final, ok bool)) mirrorResult {
    if report == nil {
        report = func(string, bool, bool) {}
    }
    res := mirrorResult{owner: t.owner, repo: t.repo, regionLabel: regionLabel(t.region)}
    if clientErr != nil {
        res.status, res.err = mirrorStatusError, clientErr
        report(mirrorStatusError, true, false)
        return res
    }
    report("creating", false, false)
    // Same create-then-wait path as the one-shot `repo mirror create <url>`
    // (createAndAwaitMirror), so both report identical lifecycle states. The
    // poll is silent; the wizard's aggregate spinner shows liveness.
    outcome, err := createAndAwaitMirror(ctx, c, t.owner, t.repo, t.region.host, noWait, waitTimeout)
    // per-poll status drives this mirror's progress line.
    outcome, err := createAndAwaitMirror(ctx, c, t.owner, t.repo, t.region.host, noWait, waitTimeout,
        func(s coreapi.MirrorStatus) { report(string(s), false, false) })
    if outcome.created == nil {
        res.status, res.err = mirrorStatusError, renderCoreError(err)
        report(mirrorStatusError, true, false)
        return res
    }
    res.cloneURL = outcome.created.MirrorUrl
4 unmodified lines

} else {
        res.status = mirrorStatusRegistered
    }
    report(res.status, true, true)
    return res
}

18 unmodified lines

default:
        nonTerminal()
    }
    report(res.status, true, res.err == nil)
    return res
}

// mirrorProgress renders a Docker-pull-style live list: one line per mirror,
// each showing its label and a status that updates independently — a spinner
// while in flight, ✓/✗ once terminal. On a non-terminal writer (pipes, tests)
// it degrades to one printed line per mirror as each reaches a terminal state.
type mirrorProgress struct {
    w       io.Writer
    tty     bool
    labelW  int
    mu      sync.Mutex
    lines   []mirrorProgressLine
    frame   int
    painted bool
    done    chan struct{}
    stopped chan struct{}
}

type mirrorProgressLine struct {
    label   string
    status  string
    final   bool
    ok      bool
    printed bool // non-tty: terminal line already emitted
}

func newMirrorProgress(w io.Writer, labels []string) *mirrorProgress {
    lines := make([]mirrorProgressLine, len(labels))
    labelW := 0
    for i, l := range labels {
        lines[i] = mirrorProgressLine{label: l, status: "queued"}
        if n := len(l); n > labelW {
            labelW = n
        }
    }
    return &mirrorProgress{w: w, tty: interactive.IsTerminalWriter(w), labelW: labelW, lines: lines}
}

// start paints the initial block and, on a TTY, begins animating the spinner.
func (p *mirrorProgress) start() {
    if !p.tty {
        return
    }
    p.done = make(chan struct{})
    p.stopped = make(chan struct{})
    p.mu.Lock()
    p.renderLocked()
    p.mu.Unlock()
    go func() {
        defer close(p.stopped)
        ticker := time.NewTicker(spinnerInterval)
        defer ticker.Stop()
        for {
            select {
            case <-p.done:
                return
            case <-ticker.C:
                p.mu.Lock()
                p.frame++
                p.renderLocked()
                p.mu.Unlock()
            }
        }
    }()
}

// set updates one mirror's line. On a TTY it repaints immediately; otherwise it
// prints a single line the first time the mirror reaches a terminal state.
func (p *mirrorProgress) set(i int, status string, final, ok bool) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.lines[i].status = status
    p.lines[i].final = final
    p.lines[i].ok = ok
    switch {
    case p.tty:
        p.renderLocked()
    case final && !p.lines[i].printed:
        p.lines[i].printed = true
        fmt.Fprintf(p.w, "%s %s %s\n", terminalIcon(ok), p.lines[i].label, status)
    }
}

// stop ends the animation and leaves the final state painted.
func (p *mirrorProgress) stop() {
    if !p.tty {
        return
    }
    close(p.done)
    <-p.stopped
    p.mu.Lock()
    p.renderLocked()
    p.mu.Unlock()
}

// renderLocked repaints the whole block in place. Caller holds p.mu.
func (p *mirrorProgress) renderLocked() {
    if p.painted {
        fmt.Fprintf(p.w, "\033[%dA", len(p.lines)) // move up to the block's top\
    }
    p.painted = true\
    for _, ln := range p.lines {\
        var icon string\
        if ln.final {\
            icon = terminalIcon(ln.ok)\
        } else {\
            icon = spinnerFrames[p.frame%len(spinnerFrames)]\
        }\
        fmt.Fprintf(p.w, "\r\033[K%-*s  %s %s\n", p.labelW, ln.label, icon, ln.status)\
    }\
}

func terminalIcon(ok bool) string {\
    if ok {\
        return "✓"\
    }  
    return "✗"\
}\

// reportMirrorResults renders the results table, a copy-pasteable git-clone\
// block for the ready mirrors, and per-failure detail. It returns a\
// SilentError (so the table isn't reprinted) when any mirror failed, giving the\

Mcmd/entire/cli/repo_mirror_create_wizard.go+146/-7

1 2 3 4 5 6 7 128 unmodified lines

136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161

package cli

import ( "bytes" "testing"

"github.com/stretchr/testify/require" 128 unmodified lines

) }

func TestMirrorProgress_NonTTY(t *testing.T) { t.Parallel() // A bytes.Buffer is non-interactive, so the progress degrades to one printed // line per mirror as it reaches a terminal state — no cursor escapes, and // non-final updates print nothing. var buf bytes.Buffer p := newMirrorProgress(&buf, []string{"a/x @ aws-eu-central-1.entire.io", "b/y @ aws-us-east-2.entire.io"}) p.start() p.set(0, "processing", false, false) // in-flight: prints nothing require.Empty(t, buf.String()) p.set(0, "ready", true, true) p.set(1, "failed", true, false) p.stop()

out := buf.String() require.Contains(t, out, "✓ a/x @ aws-eu-central-1.entire.io ready") require.Contains(t, out, "✗ b/y @ aws-us-east-2.entire.io failed") require.NotContains(t, out, "\033[", "non-tty output must not emit cursor escapes") }

func TestClustersToRegions(t *testing.T) { t.Parallel() in := []coreapi.Cluster{


Mcmd/entire/cli/repo_mirror_create_wizard_test.go+21

94 unmodified lines

95 96 97 98 98 99 100 101 102 103 104 13 unmodified lines

118 119 120 121 122 123 124 125 126

94 unmodified lines // info/refs probe: the control plane now reports clone readiness directly via // Mirror.status, so a single authenticated control-plane call per tick suffices // — no repo-scoped token exchange or data-plane round trip. func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string, timeout time.Duration) { // // onStatus (may be nil) is invoked with each observed status so callers can show // live per-mirror progress (e.g. the wizard's Docker-style line list). func awaitMirrorReady(ctx context.Context, c mirrorStatusGetter, mirrorID string, timeout time.Duration, onStatus func(coreapi.MirrorStatus)) (coreapi.MirrorStatus, error) { if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout)

13 unmodified lines

}
if s, ok := m.Status.Get(); ok { last = s if onStatus != nil { onStatus(s) } switch s { case coreapi.MirrorStatusReady: return s, nil


Mcmd/entire/cli/repo_mirror_probe.go+7/-1

59 unmodified lines

60 61 62 63 63 64 65 66 2 unmodified lines

69 70 71 72 72 73 74 75 1 unmodified line

77 78 79 80 80 81 82 83 84 85 86 87 87 88 89 90 91 92 93 94 94 95 96 97

59 unmodified lines t.Run("ready resolves with no error", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusReady}} status, err := awaitMirrorReady(ctx, f, "m", time.Second) status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.NoError(t, err) require.Equal(t, coreapi.MirrorStatusReady, status) }) 2 unmodified lines

f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{ coreapi.MirrorStatusProcessing, coreapi.MirrorStatusProcessing, coreapi.MirrorStatusReady, }} status, err := awaitMirrorReady(ctx, f, "m", time.Second) status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.NoError(t, err) require.Equal(t, coreapi.MirrorStatusReady, status) require.GreaterOrEqual(t, f.calls, 3) 1 unmodified line

t.Run("failed returns errMirrorCloneFailed", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusFailed}} status, err := awaitMirrorReady(ctx, f, "m", time.Second) status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.ErrorIs(t, err, errMirrorCloneFailed) require.Equal(t, coreapi.MirrorStatusFailed, status) })

t.Run("suspended returns errMirrorSuspended", func(t *testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusSuspended}} status, err := awaitMirrorReady(ctx, f, "m", time.Second) status, err := awaitMirrorReady(ctx, f, "m", time.Second, nil) require.ErrorIs(t, err, errMirrorSuspended) require.Equal(t, coreapi.MirrorStatusSuspended, status) })

t.Run("never-ready times out", func(t testing.T) { f := &fakeMirrorGetter{statuses: []coreapi.MirrorStatus{coreapi.MirrorStatusProcessing}} _, err := awaitMirrorReady(ctx, f, "m", 20time.Millisecond) _, err := awaitMirrorReady(ctx, f, "m", 20*time.Millisecond, nil) require.ErrorIs(t, err, context.DeadlineExceeded) }) }


Mcmd/entire/cli/repo_mirror_test.go+5/-5

94 unmodified lines