@@ -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+ }
0 commit comments