Skip to content

Commit 43605ba

Browse files
feat(push): quiet, threshold-gated pre-push checkpoint progress
Rework of the pre-push checkpoint-sync progress UX so it is useful to a human at a terminal but silent for the agents and CI that actually run most `git push`es. Now that git-refs is the default checkpoint backend, the old path printed an unconditional "[entire] Pushing N checkpoint ref(s)..." line plus a dot spinner to stderr that nothing downstream reads. - New pushReporter: presence-gated on IsTerminalWriter (non-TTY writes zero bytes), reveals a single in-place line only after a ~2s threshold, and clears it on completion (no scrollback residue). - git-refs default path (flushCheckpointRefsQueue) rewired to the reporter; removed the now-dead startProgressDots. - git --progress transfer detail routed to .entire/logs/ (operational metadata only) on BOTH backends, regardless of TTY. - Legacy git-branch path also silenced for non-TTY; error/actionable-hint lines still print unconditionally on both paths. - Push semantics unchanged (display only, fast-forward-only, never blocks the user's push). Builds on and supersedes #1684. Co-Authored-By: snowingfox <snowingfox@users.noreply.github.com> Entire-Checkpoint: 01KYW08431VR6EKA854NP1A0SR
1 parent 279b988 commit 43605ba

11 files changed

Lines changed: 1280 additions & 104 deletions

cmd/entire/cli/checkpoint/remote/git.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package remote
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/base64"
67
"errors"
@@ -403,7 +404,8 @@ func FetchBlobs(ctx context.Context, remote string, hashes []string) error {
403404

404405
// PushResult holds raw porcelain output from git push.
405406
type PushResult struct {
406-
Output string
407+
Output string // stdout (porcelain status lines)
408+
Stderr string // stderr (git --progress transfer lines)
407409
}
408410

409411
// PushOptions configures a git push operation.
@@ -431,7 +433,7 @@ func PushWithOptions(ctx context.Context, opts PushOptions) (PushResult, error)
431433
return PushResult{}, fmt.Errorf("resolve push target: %w", err)
432434
}
433435

434-
args := []string{"push", "--no-verify", "--porcelain"}
436+
args := []string{"push", "--no-verify", "--porcelain", "--progress"}
435437
args = append(args, opts.ExtraArgs...)
436438
args = append(args, pushTarget)
437439
args = append(args, opts.RefSpecs...)
@@ -441,11 +443,27 @@ func PushWithOptions(ctx context.Context, opts PushOptions) (PushResult, error)
441443
cmd.Dir = opts.Dir
442444
}
443445
disableTerminalPrompt(cmd)
444-
output, err := cmd.CombinedOutput()
446+
447+
var stdout, stderr bytes.Buffer
448+
cmd.Stdout = &stdout
449+
cmd.Stderr = &stderr
450+
err = cmd.Run()
451+
result := PushResult{
452+
Output: stdout.String(),
453+
Stderr: stderr.String(),
454+
}
445455
if err != nil {
446-
return PushResult{Output: string(output)}, fmt.Errorf("git push: %w", err)
456+
combined := stdout.String()
457+
if stderr.Len() > 0 {
458+
if combined != "" {
459+
combined += "\n"
460+
}
461+
combined += stderr.String()
462+
}
463+
result.Output = combined
464+
return result, fmt.Errorf("git push: %w", err)
447465
}
448-
return PushResult{Output: string(output)}, nil
466+
return result, nil
449467
}
450468

451469
// LsRemoteInDir is like LsRemote but runs in a specific directory.

cmd/entire/cli/strategy/common.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ func replayLocalCommits(ctx context.Context, repo *git.Repository, localRefName
227227
return setRefHash(repo, localRefName, targetHash)
228228
}
229229

230-
newTip, err := cherryPickOnto(ctx, repo, targetHash, localCommits, shallow)
230+
newTip, err := cherryPickOnto(ctx, repo, targetHash, localCommits, shallow, nil)
231231
if err != nil {
232232
return fmt.Errorf("failed to replay local commits for %s: %w", localRefName, err)
233233
}

cmd/entire/cli/strategy/manual_commit_push.go

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ import (
99
"os"
1010
"os/exec"
1111
"strings"
12+
"time"
1213

1314
git "github.com/go-git/go-git/v6"
1415
"github.com/go-git/go-git/v6/plumbing"
1516

1617
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
1718
checkpointremote "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
19+
"github.com/entireio/cli/cmd/entire/cli/interactive"
1820
"github.com/entireio/cli/cmd/entire/cli/logging"
21+
"github.com/entireio/cli/cmd/entire/cli/paths"
1922
"github.com/entireio/cli/cmd/entire/cli/settings"
23+
"github.com/entireio/cli/cmd/entire/cli/trailers"
2024
"github.com/entireio/cli/perf"
2125
"github.com/entireio/cli/redact"
2226
)
@@ -105,6 +109,13 @@ func (s *ManualCommitStrategy) prePush(ctx context.Context, remote string, prote
105109
}
106110
}
107111

112+
// Session-tree summary is progress, not an error/hint — skip the git-log
113+
// work entirely on non-TTY stderr (agents/CI) instead of computing it only
114+
// to have pushProgressOutput() discard it.
115+
if interactive.IsTerminalWriter(os.Stderr) {
116+
printPushSummary(ctx, remote, ps.pushTarget())
117+
}
118+
108119
// OPF pre-push rewrite: if OPF is configured, resolve the user's
109120
// decision (env > settings > prompt > non-TTY auto-run), then
110121
// re-redact unpushed v1 commits with OPF (producing the OPF-applied,
@@ -351,30 +362,30 @@ func flushCheckpointRefsQueue(ctx context.Context, repo *git.Repository, pushTar
351362
}
352363

353364
// Progress: pushing many refs over the network can take tens of seconds, so
354-
// surface it (matching the v1 path's "[entire] Pushing ..." line) instead of
355-
// leaving the user's git push apparently hung. Written to stderr, which git
356-
// shows during the pre-push hook.
365+
// surface it via the threshold-gated reporter instead of leaving the user's
366+
// git push apparently hung. TTY-gated and threshold-delayed: agents and CI
367+
// (non-TTY stderr) see zero progress bytes.
357368
displayTarget := displayPushTarget(pushTarget)
358-
fmt.Fprintf(os.Stderr, "[entire] Pushing %d checkpoint ref(s) to %s...", len(existing), displayTarget)
359-
stop := startProgressDots(os.Stderr)
369+
rep := newPushReporter(ctx, os.Stderr, interactive.IsTerminalWriter(os.Stderr), 2*time.Second)
370+
rep.phase(fmt.Sprintf("syncing %d checkpoint(s) to %s", len(existing), displayTarget))
360371

361372
// Fast path: push all refs in one round-trip (fast-forward-only). If every
362373
// ref was up to date or fast-forwarded, we're done.
363374
batchErr := batchPushRefs(pushCtx, pushTarget, existing)
364375
if batchErr == nil {
365-
stop(" done")
376+
rep.finish(fmt.Sprintf("pushed %d checkpoint(s)", len(existing)))
366377
if removeErr := queue.Remove(existing); removeErr != nil {
367378
logging.Warn(ctx, "git-refs push: clear pushed refs from queue failed",
368379
slog.String("error", removeErr.Error()))
369380
}
370381
return len(existing), nil
371382
}
372-
stop("")
373383

374384
// Non-interactive SSH auth failures cannot be fixed by per-ref
375385
// fetch+replay. Surface the same actionable hint as the v1 doPushRef path
376386
// (issue #1523) instead of only logging to .entire/logs/.
377387
if nonInteractiveSSHAuthFailure(pushCtx, batchErr) {
388+
rep.finish("")
378389
fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't push checkpoint refs: %v\n", batchErr)
379390
printNonInteractiveSSHAuthHint()
380391
printCheckpointRemoteHint(pushTarget)
@@ -386,8 +397,7 @@ func flushCheckpointRefsQueue(ctx context.Context, repo *git.Repository, pushTar
386397
// fetch+replay recovery, and remove from the queue only the refs that land
387398
// (a genuine cherry-pick conflict leaves that ref queued for a later push,
388399
// never force-overwriting the remote).
389-
fmt.Fprintf(os.Stderr, "[entire] Some checkpoint refs diverged; syncing %d ref(s) individually...", len(existing))
390-
stop = startProgressDots(os.Stderr)
400+
rep.phase(fmt.Sprintf("resolving %d diverged checkpoint(s)", len(existing)))
391401
pushed := make([]plumbing.ReferenceName, 0, len(existing))
392402
var firstErr error
393403
for _, ref := range existing {
@@ -404,7 +414,7 @@ func flushCheckpointRefsQueue(ctx context.Context, repo *git.Repository, pushTar
404414
}
405415
pushed = append(pushed, ref)
406416
}
407-
stop(fmt.Sprintf(" pushed %d of %d", len(pushed), len(existing)))
417+
rep.finish(fmt.Sprintf("pushed %d of %d checkpoint(s)", len(pushed), len(existing)))
408418
if err := queue.Remove(pushed); err != nil {
409419
logging.Warn(ctx, "git-refs push: clear pushed refs from queue failed",
410420
slog.String("error", err.Error()))
@@ -430,3 +440,66 @@ func cleanupPushedShadowBranches(ctx context.Context) {
430440
)
431441
}
432442
}
443+
444+
// printPushSummary writes a session-level summary of pending checkpoint commits.
445+
// Silent on any failure — never blocks the push.
446+
func printPushSummary(ctx context.Context, remoteName, target string) {
447+
repoRoot, err := paths.WorktreeRoot(ctx)
448+
if err != nil {
449+
return
450+
}
451+
452+
branchName := paths.MetadataBranchName
453+
logFormat := "%H|%s|%(trailers:key=" + trailers.SessionTrailerKey + ",valueonly,separator=%x20)|%aI"
454+
455+
var logOutput string
456+
isNewBranch := false
457+
458+
if !checkpointremote.IsURL(target) {
459+
rangeSpec := "refs/remotes/" + remoteName + "/" + branchName + "..refs/heads/" + branchName
460+
firstOut, firstErr := runPushSummaryGitLog(ctx, repoRoot, logFormat, rangeSpec)
461+
if firstErr == nil && strings.TrimSpace(firstOut) != "" {
462+
logOutput = firstOut
463+
} else {
464+
fallbackOut, fallbackErr := runPushSummaryGitLog(ctx, repoRoot, logFormat, "refs/heads/"+branchName)
465+
if fallbackErr == nil && strings.TrimSpace(fallbackOut) != "" {
466+
logOutput = fallbackOut
467+
isNewBranch = true
468+
}
469+
}
470+
} else {
471+
fallbackOut, fallbackErr := runPushSummaryGitLog(ctx, repoRoot, logFormat, "refs/heads/"+branchName)
472+
if fallbackErr == nil {
473+
logOutput = fallbackOut
474+
isNewBranch = strings.TrimSpace(logOutput) != ""
475+
}
476+
}
477+
if strings.TrimSpace(logOutput) == "" {
478+
return
479+
}
480+
481+
summaries := parsePushSummaryFromLog(logOutput)
482+
if len(summaries) == 0 {
483+
return
484+
}
485+
486+
totalCommits := len(strings.Split(strings.TrimSpace(logOutput), "\n"))
487+
lines := formatSessionTree(summaries, formatSessionTreeOpts{
488+
TotalCommits: totalCommits,
489+
IsNewBranch: isNewBranch,
490+
})
491+
w := pushProgressOutput()
492+
for _, line := range lines {
493+
fmt.Fprintln(w, line)
494+
}
495+
}
496+
497+
func runPushSummaryGitLog(ctx context.Context, repoRoot, format, rangeSpec string) (string, error) {
498+
cmd := exec.CommandContext(ctx, "git", "log", "--format="+format, rangeSpec)
499+
cmd.Dir = repoRoot
500+
out, err := cmd.Output()
501+
if err != nil {
502+
return "", fmt.Errorf("git log: %w", err)
503+
}
504+
return string(out), nil
505+
}

cmd/entire/cli/strategy/manual_commit_push_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import (
55
"os/exec"
66
"testing"
77

8+
"github.com/entireio/cli/cmd/entire/cli/paths"
89
"github.com/entireio/cli/cmd/entire/cli/testutil"
910

11+
git "github.com/go-git/go-git/v6"
12+
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
1114
)
1215

@@ -54,3 +57,38 @@ func TestDeferCheckpointPushOnEmptyRemote_UsesLocalTrackingRefs(t *testing.T) {
5457
deferCheckpointPushOnEmptyRemote(ctx, pushSettings{remote: "origin", checkpointURL: "https://example.invalid/cp.git"}),
5558
"a dedicated checkpoint remote is exempt from the guard")
5659
}
60+
61+
// TestFlushCheckpointRefsQueue_NonTTY_NoProgressOutput verifies the git-refs
62+
// default pre-push path stays silent on a non-TTY writer while still landing
63+
// the queued refs. captureStderr's os.Pipe write end is never a terminal, so
64+
// interactive.IsTerminalWriter(os.Stderr) is false for the duration of the
65+
// call without any extra faking.
66+
//
67+
// Not parallel: uses captureStderr's os.Stderr redirection and t.Chdir.
68+
func TestFlushCheckpointRefsQueue_NonTTY_NoProgressOutput(t *testing.T) {
69+
workDir, bareDir, refs := setupRepoWithCheckpointRefs(t)
70+
t.Chdir(workDir)
71+
paths.ClearWorktreeRootCache()
72+
73+
repo, err := git.PlainOpen(workDir)
74+
require.NoError(t, err)
75+
queue := enqueueRefs(t, repo, refs)
76+
77+
restore := captureStderr(t)
78+
pushed, pushDisabled, err := PushQueuedCheckpointRefs(context.Background(), repo, bareDir)
79+
output := restore()
80+
81+
require.NoError(t, err)
82+
assert.False(t, pushDisabled)
83+
assert.Equal(t, len(refs), pushed)
84+
85+
assert.NotContains(t, output, "[entire] Pushing", "non-TTY stderr must contain no progress banner")
86+
assert.NotContains(t, output, "syncing", "non-TTY stderr must contain no progress text")
87+
88+
for _, ref := range refs {
89+
assert.NotEmpty(t, remoteRefHash(t, bareDir, ref), "ref should still land on the remote")
90+
}
91+
remaining, err := queue.Drain()
92+
require.NoError(t, err)
93+
assert.Empty(t, remaining, "pushed refs are removed from the queue")
94+
}

cmd/entire/cli/strategy/metadata_reconcile.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ func ReconcileDisconnectedMetadataRef(
194194

195195
fmt.Fprintf(w, "[entire] Cherry-picking %d local checkpoint(s) onto remote...\n", len(dataCommits))
196196

197-
newTip, err := cherryPickOnto(ctx, repo, remoteHash, dataCommits, shallow)
197+
newTip, err := cherryPickOnto(ctx, repo, remoteHash, dataCommits, shallow, nil)
198198
if err != nil {
199199
return fmt.Errorf("failed to cherry-pick local commits onto remote: %w", err)
200200
}
@@ -305,6 +305,9 @@ func loadShallowHashes(ctx context.Context, repoPath string) (map[plumbing.Hash]
305305
return set, nil
306306
}
307307

308+
// cherryPickProgress reports cherry-pick replay progress (current/total commits).
309+
type cherryPickProgress func(current, total int)
310+
308311
// cherryPickOnto applies each commit's delta onto base, building a linear chain.
309312
// For each commit, it computes the full diff from its parent (additions, modifications,
310313
// and deletions), then applies that delta onto the current tip's tree.
@@ -314,8 +317,9 @@ func loadShallowHashes(ctx context.Context, repoPath string) (map[plumbing.Hash]
314317
// a shallow-boundary commit would be diffed against a stale parent tree whose
315318
// objects live in the local pack but no longer represent the actual checkpoint
316319
// history — producing nonsense changes when replayed onto the remote tip.
317-
func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Hash, commits []*object.Commit, shallow map[plumbing.Hash]bool) (plumbing.Hash, error) {
320+
func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Hash, commits []*object.Commit, shallow map[plumbing.Hash]bool, onProgress cherryPickProgress) (plumbing.Hash, error) {
318321
currentTip := base
322+
processed := 0
319323

320324
for _, commit := range commits {
321325
changes, err := treeChangesForCherryPick(ctx, repo, commit, shallow)
@@ -343,6 +347,10 @@ func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Has
343347
}
344348

345349
currentTip = newHash
350+
processed++
351+
if onProgress != nil {
352+
onProgress(processed, len(commits))
353+
}
346354
}
347355

348356
return currentTip, nil

0 commit comments

Comments
 (0)