fix(sync): stop reporting success when no branch was actually synced

hub sync silently dropped any local branch whose upstream it could not
resolve: it cleared its internal remote branch while leaving the "gone"
flag false, so neither the update nor the delete path ran. A run could
then print "Already up to date" having done nothing, which is why a
workspace-wide sync looked like it worked when it did not.

- report unmatched branches as UNTRACKED with the reason, so
  "Already up to date" is only said when it is true
- resolve the remote default branch via `git ls-remote --symref` when
  refs/remotes/<remote>/HEAD is missing, so merged branches whose
  upstream was deleted are still cleaned up
- make --create-missing opt-in, keeping hub's default of never creating
  a local branch that does not already exist
- summarise runs as changed/unchanged with per-action counts, and list
  deletions and failures in full instead of leaving them in scrollback
- restore per-command flag help, which the custom Usage had dropped
- add an in-place CLI progress line, drawn only to a terminal so piped
  and --json output stay free of cursor control
- group the TUI selection screen by outcome so blocked and dirty
  repositories are visible before a run starts

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-09-28 19:57:42 +01:00
parent 4f6e9c9991
commit f4286edff2
7 changed files with 885 additions and 72 deletions
+164 -9
View File
@@ -31,7 +31,36 @@ type localBranch struct {
oid string
}
// upstreamState describes what stack-sync could work out about where a local
// branch comes from on the main remote.
type upstreamState int
const (
// upstreamTracked means the local branch has a resolvable remote branch.
upstreamTracked upstreamState = iota
// upstreamDeleted means the branch had a configured upstream which the
// fetch has just pruned, so the remote branch is gone.
upstreamDeleted
// upstreamUnmatched means the branch has no configured upstream and no
// same-named branch on the remote. Such a branch cannot be fast-forwarded
// automatically, and it used to be dropped from the report entirely, which
// made the run claim to be "Already up to date" while silently doing
// nothing at all for it.
upstreamUnmatched
)
// syncOptions tunes a single repository sync.
type syncOptions struct {
// CreateMissing creates a local branch for every remote branch that has no
// local counterpart, so work started on another machine shows up here.
CreateMissing bool
}
func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ignoredNested []string) (repositorySyncReport, error) {
return syncRepositoryWith(path, timeout, initiallyDirty, ignoredNested, syncOptions{})
}
func syncRepositoryWith(path string, timeout time.Duration, initiallyDirty bool, ignoredNested []string, opts syncOptions) (repositorySyncReport, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -62,7 +91,7 @@ func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ign
}
for _, branch := range branches {
target, gone, err := upstreamForBranch(ctx, path, remote, branch.name)
target, state, err := upstreamForBranch(ctx, path, remote, branch.name)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
@@ -113,7 +142,8 @@ func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ign
continue
}
if !gone {
if state == upstreamUnmatched {
report.add(branch.name, "untracked", "no branch "+branch.name+" on "+remote+" and no upstream configured; left unchanged")
continue
}
if defaultRef == "" {
@@ -162,6 +192,16 @@ func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ign
report.add(branch.name, "deleted", "upstream was deleted and the branch was merged into "+defaultBranch)
}
if opts.CreateMissing {
created, err := createMissingBranches(ctx, path, remote, branches)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
for _, name := range created {
report.add(name, "created", "no local branch; created from "+remote+"/"+name)
}
}
if len(report.Branches) == 0 {
report.Message = "Already up to date."
} else {
@@ -170,6 +210,69 @@ func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ign
return report, nil
}
// createMissingBranches creates a local branch for each remote branch that has
// no local counterpart. This never touches the worktree, never moves an
// existing branch, and never checks anything out: it only fills in branches
// that exist on the remote so that a workspace which is "in sync" really is.
func createMissingBranches(ctx context.Context, path, remote string, existing []localBranch) ([]string, error) {
remoteBranches, err := remoteTrackingBranches(ctx, path, remote)
if err != nil {
return nil, err
}
have := make(map[string]bool, len(existing))
for _, branch := range existing {
have[branch.name] = true
}
var created []string
for _, remoteBranch := range remoteBranches {
if have[remoteBranch.name] {
continue
}
// --track records the upstream, so later runs report and fast-forward
// this branch like any other.
output, err := gitCombinedContext(ctx, path, "branch", "--track", remoteBranch.name, remote+"/"+remoteBranch.name)
if err != nil {
// A branch created between the listing and here is not a failure.
if exists, existsErr := refExists(ctx, path, "refs/heads/"+remoteBranch.name); existsErr == nil && exists {
have[remoteBranch.name] = true
continue
}
return nil, gitCommandError("create branch "+remoteBranch.name+" from "+remote, output, err)
}
created = append(created, remoteBranch.name)
}
return created, nil
}
// remoteTrackingBranches lists the branches the main remote advertises, as
// resolved into refs/remotes. The remote's symbolic HEAD is not a branch and is
// excluded.
func remoteTrackingBranches(ctx context.Context, path, remote string) ([]localBranch, error) {
prefix := "refs/remotes/" + remote + "/"
output, err := gitBytesContext(ctx, path, "for-each-ref", "--format=%(refname)%00%(refname:short)%00%(objectname)", prefix)
if err != nil {
return nil, fmt.Errorf("list %s branches: %w", remote, err)
}
var branches []localBranch
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte{'\n'}) {
parts := bytes.Split(line, []byte{0})
if len(parts) != 3 {
continue
}
// Derive the name from the full ref rather than %(refname:short):
// git shortens refs to the shortest unambiguous form, so
// refs/remotes/origin/HEAD is reported as plain "origin" and would
// otherwise be mistaken for a real branch called "origin".
name, ok := strings.CutPrefix(string(parts[0]), prefix)
if !ok || name == "" || name == "HEAD" {
continue
}
branches = append(branches, localBranch{ref: string(parts[0]), name: name, oid: string(parts[2])})
}
sort.Slice(branches, func(i, j int) bool { return branches[i].name < branches[j].name })
return branches, nil
}
func (r *repositorySyncReport) add(branch, action, message string) {
r.Branches = append(r.Branches, branchSyncResult{Branch: branch, Action: action, Message: message})
}
@@ -221,7 +324,7 @@ func localBranches(ctx context.Context, path string) ([]localBranch, error) {
return branches, nil
}
func upstreamForBranch(ctx context.Context, path, remote, branch string) (target string, gone bool, err error) {
func upstreamForBranch(ctx context.Context, path, remote, branch string) (target string, state upstreamState, err error) {
configuredRemote, _ := gitOutputContext(ctx, path, "config", "--get", "branch."+branch+".remote")
configuredRemote = strings.TrimSpace(configuredRemote)
mergeRef, _ := gitOutputContext(ctx, path, "config", "--get", "branch."+branch+".merge")
@@ -230,22 +333,22 @@ func upstreamForBranch(ctx context.Context, path, remote, branch string) (target
target = "refs/remotes/" + remote + "/" + strings.TrimPrefix(mergeRef, "refs/heads/")
exists, err := refExists(ctx, path, target)
if err != nil {
return "", false, err
return "", upstreamTracked, err
}
if !exists {
return "", true, nil
return "", upstreamDeleted, nil
}
return target, false, nil
return target, upstreamTracked, nil
}
target = "refs/remotes/" + remote + "/" + branch
exists, err := refExists(ctx, path, target)
if err != nil {
return "", false, err
return "", upstreamTracked, err
}
if !exists {
return "", false, nil
return "", upstreamUnmatched, nil
}
return target, false, nil
return target, upstreamTracked, nil
}
func remoteDefaultBranch(ctx context.Context, path, remote string) (ref, branch string, err error) {
@@ -262,9 +365,61 @@ func remoteDefaultBranch(ctx context.Context, path, remote string) (ref, branch
} else if ctx.Err() != nil {
return "", "", ctx.Err()
}
// refs/remotes/<remote>/HEAD is a local convenience symref and is absent
// in plenty of real clones. Without it we could not tell which branch a
// deleted-and-merged branch had been merged into, so merged branches were
// kept with a vague "remote default branch is unknown" warning. Ask the
// remote itself instead of giving up.
name, oid, err := remoteHead(ctx, path, remote)
if err != nil {
if ctx.Err() != nil {
return "", "", ctx.Err()
}
// The remote simply did not tell us; fall back to "unknown".
return "", "", nil
}
ref = "refs/remotes/" + remote + "/" + name
if exists, existsErr := refExists(ctx, path, ref); existsErr == nil && exists {
return ref, name, nil
}
// The fetch has just run, so the tracking ref should be there. If it is
// not, the raw object id is still a valid commit for ancestry checks.
if oid != "" {
return oid, name, nil
}
return "", "", nil
}
// remoteHead asks the remote which branch its HEAD points at. It returns the
// short branch name and, when advertised, the object id of that commit.
func remoteHead(ctx context.Context, path, remote string) (branch, oid string, err error) {
output, err := gitBytesContext(ctx, path, "ls-remote", "--symref", remote, "HEAD")
if err != nil {
return "", "", err
}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if rest, ok := strings.CutPrefix(line, "ref: "); ok {
name, _, _ := strings.Cut(rest, "\t")
name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(name), "refs/heads/"))
if name != "" {
return name, oid, nil
}
continue
}
if fields := strings.Fields(line); len(fields) == 2 {
oid = fields[0]
}
}
if branch == "" && oid == "" {
return "", "", errors.New("remote did not advertise HEAD")
}
return "", oid, nil
}
func checkedOutBranches(ctx context.Context, path string) (map[string]bool, error) {
output, err := gitOutputContext(ctx, path, "worktree", "list", "--porcelain")
if err != nil {