f4286edff2
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>
539 lines
18 KiB
Go
539 lines
18 KiB
Go
package main
|
|
|
|
// The branch synchronization behavior in this file is adapted from hub's
|
|
// `hub sync` command. See THIRD_PARTY_NOTICES.md for attribution and license.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type branchSyncResult struct {
|
|
Branch string `json:"branch"`
|
|
Action string `json:"action"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
type repositorySyncReport struct {
|
|
Message string
|
|
Branches []branchSyncResult
|
|
}
|
|
|
|
type localBranch struct {
|
|
name string
|
|
ref string
|
|
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()
|
|
|
|
var report repositorySyncReport
|
|
remote, err := mainRemote(ctx, path)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
fetchOutput, err := gitCombinedContext(ctx, path, "fetch", "--prune", "--quiet", "--progress", remote)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, gitCommandError("fetch "+remote, fetchOutput, err))
|
|
}
|
|
if operation := gitOperation(path); operation != "" {
|
|
return failedReport(report, ctx, timeout, fmt.Errorf("%s started during sync", operation))
|
|
}
|
|
|
|
current, checkedOut, err := worktreeState(ctx, path)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
branches, err := localBranches(ctx, path)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
defaultRef, defaultBranch, err := remoteDefaultBranch(ctx, path, remote)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
|
|
for _, branch := range branches {
|
|
target, state, err := upstreamForBranch(ctx, path, remote, branch.name)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
if target != "" {
|
|
targetOID, err := resolveCommit(ctx, path, target)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, fmt.Errorf("resolve upstream for %s: %w", branch.name, err))
|
|
}
|
|
if targetOID == branch.oid {
|
|
continue
|
|
}
|
|
behind, err := isAncestor(ctx, path, branch.oid, targetOID)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, fmt.Errorf("compare branch %s with its upstream: %w", branch.name, err))
|
|
}
|
|
if !behind {
|
|
report.add(branch.name, "warning", "contains commits that are not in its upstream")
|
|
continue
|
|
}
|
|
current, checkedOut, err = worktreeState(ctx, path)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
|
|
if branch.name == current {
|
|
dirty, err := worktreeDirty(ctx, path, ignoredNested)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
if initiallyDirty || dirty {
|
|
report.add(branch.name, "protected", "checked-out branch has worktree changes")
|
|
continue
|
|
}
|
|
output, err := gitCombinedContext(ctx, path, "merge", "--ff-only", "--quiet", target)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, gitCommandError("fast-forward branch "+branch.name, output, err))
|
|
}
|
|
} else if checkedOut[branch.ref] {
|
|
report.add(branch.name, "protected", "checked out in another worktree")
|
|
continue
|
|
} else {
|
|
output, err := gitCombinedContext(ctx, path, "update-ref", branch.ref, targetOID, branch.oid)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, gitCommandError("fast-forward branch "+branch.name, output, err))
|
|
}
|
|
}
|
|
report.add(branch.name, "updated", "fast-forwarded from "+abbreviateOID(branch.oid))
|
|
continue
|
|
}
|
|
|
|
if state == upstreamUnmatched {
|
|
report.add(branch.name, "untracked", "no branch "+branch.name+" on "+remote+" and no upstream configured; left unchanged")
|
|
continue
|
|
}
|
|
if defaultRef == "" {
|
|
report.add(branch.name, "warning", "upstream was deleted; kept because the remote default branch is unknown")
|
|
continue
|
|
}
|
|
merged, err := isAncestor(ctx, path, branch.oid, defaultRef)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, fmt.Errorf("check whether branch %s is merged: %w", branch.name, err))
|
|
}
|
|
if !merged {
|
|
report.add(branch.name, "warning", "upstream was deleted, but the branch is not merged into "+defaultBranch)
|
|
continue
|
|
}
|
|
current, checkedOut, err = worktreeState(ctx, path)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
if checkedOut[branch.ref] {
|
|
if branch.name != current {
|
|
report.add(branch.name, "protected", "upstream was deleted, but the branch is checked out in another worktree")
|
|
continue
|
|
}
|
|
dirty, err := worktreeDirty(ctx, path, ignoredNested)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, err)
|
|
}
|
|
if initiallyDirty || dirty {
|
|
report.add(branch.name, "protected", "upstream was deleted, but the checked-out branch has worktree changes")
|
|
continue
|
|
}
|
|
if !hasLocalBranch(branches, defaultBranch) {
|
|
report.add(branch.name, "protected", "upstream was deleted, but the local default branch does not exist")
|
|
continue
|
|
}
|
|
output, err := gitCombinedContext(ctx, path, "checkout", "--quiet", defaultBranch)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, gitCommandError("check out default branch "+defaultBranch, output, err))
|
|
}
|
|
current = defaultBranch
|
|
}
|
|
output, err := gitCombinedContext(ctx, path, "branch", "-D", "--", branch.name)
|
|
if err != nil {
|
|
return failedReport(report, ctx, timeout, gitCommandError("delete merged branch "+branch.name, output, err))
|
|
}
|
|
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 {
|
|
report.Message = formatBranchResults(report.Branches)
|
|
}
|
|
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})
|
|
}
|
|
|
|
func failedReport(report repositorySyncReport, ctx context.Context, timeout time.Duration, err error) (repositorySyncReport, error) {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
err = fmt.Errorf("timed out after %s", timeout)
|
|
}
|
|
report.Message = err.Error()
|
|
return report, err
|
|
}
|
|
|
|
func mainRemote(ctx context.Context, path string) (string, error) {
|
|
output, err := gitOutputContext(ctx, path, "remote")
|
|
if err != nil {
|
|
return "", fmt.Errorf("list remotes: %w", err)
|
|
}
|
|
remotes := strings.Fields(output)
|
|
if len(remotes) == 0 {
|
|
return "", errors.New("no remotes")
|
|
}
|
|
for _, preferred := range []string{"upstream", "github", "origin"} {
|
|
for _, remote := range remotes {
|
|
if remote == preferred {
|
|
return remote, nil
|
|
}
|
|
}
|
|
}
|
|
if len(remotes) == 1 {
|
|
return remotes[0], nil
|
|
}
|
|
return "", fmt.Errorf("cannot choose a main remote from %s; name one upstream, github, or origin", strings.Join(remotes, ", "))
|
|
}
|
|
|
|
func localBranches(ctx context.Context, path string) ([]localBranch, error) {
|
|
output, err := gitBytesContext(ctx, path, "for-each-ref", "--format=%(refname)%00%(refname:short)%00%(objectname)", "refs/heads")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list local branches: %w", 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
|
|
}
|
|
branches = append(branches, localBranch{ref: string(parts[0]), name: string(parts[1]), oid: string(parts[2])})
|
|
}
|
|
sort.Slice(branches, func(i, j int) bool { return branches[i].name < branches[j].name })
|
|
return branches, nil
|
|
}
|
|
|
|
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")
|
|
mergeRef = strings.TrimSpace(mergeRef)
|
|
if configuredRemote == remote && mergeRef != "" {
|
|
target = "refs/remotes/" + remote + "/" + strings.TrimPrefix(mergeRef, "refs/heads/")
|
|
exists, err := refExists(ctx, path, target)
|
|
if err != nil {
|
|
return "", upstreamTracked, err
|
|
}
|
|
if !exists {
|
|
return "", upstreamDeleted, nil
|
|
}
|
|
return target, upstreamTracked, nil
|
|
}
|
|
target = "refs/remotes/" + remote + "/" + branch
|
|
exists, err := refExists(ctx, path, target)
|
|
if err != nil {
|
|
return "", upstreamTracked, err
|
|
}
|
|
if !exists {
|
|
return "", upstreamUnmatched, nil
|
|
}
|
|
return target, upstreamTracked, nil
|
|
}
|
|
|
|
func remoteDefaultBranch(ctx context.Context, path, remote string) (ref, branch string, err error) {
|
|
head := "refs/remotes/" + remote + "/HEAD"
|
|
if output, symbolicErr := gitOutputContext(ctx, path, "symbolic-ref", "--quiet", head); symbolicErr == nil {
|
|
ref = strings.TrimSpace(output)
|
|
exists, existsErr := refExists(ctx, path, ref)
|
|
if existsErr != nil {
|
|
return "", "", existsErr
|
|
}
|
|
if exists {
|
|
return ref, strings.TrimPrefix(ref, "refs/remotes/"+remote+"/"), nil
|
|
}
|
|
} 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 {
|
|
return nil, fmt.Errorf("list worktrees: %w", err)
|
|
}
|
|
branches := make(map[string]bool)
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if strings.HasPrefix(line, "branch ") {
|
|
branches[strings.TrimSpace(strings.TrimPrefix(line, "branch "))] = true
|
|
}
|
|
}
|
|
return branches, nil
|
|
}
|
|
|
|
func worktreeState(ctx context.Context, path string) (string, map[string]bool, error) {
|
|
current, err := gitOutputContext(ctx, path, "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
if err != nil {
|
|
return "", nil, errors.New("cannot determine the checked-out branch after fetch")
|
|
}
|
|
checkedOut, err := checkedOutBranches(ctx, path)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return strings.TrimSpace(current), checkedOut, nil
|
|
}
|
|
|
|
func worktreeDirty(ctx context.Context, path string, ignoredNested []string) (bool, error) {
|
|
output, err := gitBytesContext(ctx, path, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none")
|
|
if err != nil {
|
|
return false, fmt.Errorf("read worktree status: %w", err)
|
|
}
|
|
for _, change := range parsePorcelain(output) {
|
|
if change.code != "??" || !belongsToNestedRepo(change.path, ignoredNested) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func resolveCommit(ctx context.Context, path, ref string) (string, error) {
|
|
output, err := gitOutputContext(ctx, path, "rev-parse", "--verify", ref+"^{commit}")
|
|
return strings.TrimSpace(output), err
|
|
}
|
|
|
|
func refExists(ctx context.Context, path, ref string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "git", "-C", path, "show-ref", "--verify", "--quiet", ref)
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
func isAncestor(ctx context.Context, path, older, newer string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "git", "-C", path, "merge-base", "--is-ancestor", older, newer)
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
func hasLocalBranch(branches []localBranch, name string) bool {
|
|
for _, branch := range branches {
|
|
if branch.name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func gitOutputContext(ctx context.Context, path string, args ...string) (string, error) {
|
|
output, err := gitBytesContext(ctx, path, args...)
|
|
return string(output), err
|
|
}
|
|
|
|
func gitBytesContext(ctx context.Context, path string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
|
|
return cmd.Output()
|
|
}
|
|
|
|
func gitCombinedContext(ctx context.Context, path string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
func gitCommandError(action string, output []byte, err error) error {
|
|
detail := strings.TrimSpace(string(output))
|
|
if detail == "" {
|
|
return fmt.Errorf("%s: %w", action, err)
|
|
}
|
|
return fmt.Errorf("%s: %s", action, firstLine(detail))
|
|
}
|
|
|
|
func formatBranchResults(results []branchSyncResult) string {
|
|
lines := make([]string, 0, len(results))
|
|
for _, result := range results {
|
|
lines = append(lines, fmt.Sprintf("%-9s %s: %s", strings.ToUpper(result.Action), result.Branch, result.Message))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func abbreviateOID(oid string) string {
|
|
if len(oid) <= 7 {
|
|
return oid
|
|
}
|
|
return oid[:7]
|
|
}
|