git-sync: sync all remotes and push tags

Pull, push, and push tags are now performed against every configured
remote rather than just the default. Operations are grouped by type
so all pulls complete before any push begins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 21:52:41 -07:00
parent f5327e432e
commit 5344bed1ea

View File

@@ -92,11 +92,26 @@ func isGitRepo(dir string) bool {
// syncRepo performs the full sync sequence on a single repository: // syncRepo performs the full sync sequence on a single repository:
// //
// fetch --prune stash (if dirty) pull --rebase stash pop push // fetch --all --prune stash (if dirty) pull --rebase each remote →
// stash pop → push each remote → push tags each remote
func syncRepo(r repo) error { func syncRepo(r repo) error {
fmt.Printf(" %s (%s)\n", r.name, r.path) fmt.Printf(" %s (%s)\n", r.name, r.path)
// 1. Fetch and prune. rems, err := remotes(r.path)
if err != nil {
return fmt.Errorf("listing remotes: %w", err)
}
if len(rems) == 0 {
fmt.Println(" no remotes configured, skipping")
return nil
}
branch, err := currentBranch(r.path)
if err != nil {
return fmt.Errorf("determining current branch: %w", err)
}
// 1. Fetch all remotes.
if err := git(r.path, "fetch", "--all", "--prune"); err != nil { if err := git(r.path, "fetch", "--all", "--prune"); err != nil {
return fmt.Errorf("fetch: %w", err) return fmt.Errorf("fetch: %w", err)
} }
@@ -108,13 +123,19 @@ func syncRepo(r repo) error {
} }
if dirty { if dirty {
fmt.Println(" stashing uncommitted changes") fmt.Println(" stashing uncommitted changes")
if err := git(r.path, "stash", "push", "-m", "sync-repos auto-stash"); err != nil { if err := git(r.path, "stash", "push", "-m", "git-sync auto-stash"); err != nil {
return fmt.Errorf("stash push: %w", err) return fmt.Errorf("stash push: %w", err)
} }
} }
// 3. Pull with rebase. // 3. Pull --rebase from each remote.
pullErr := git(r.path, "pull", "--rebase") var pullErr error
for _, rem := range rems {
if err := git(r.path, "pull", "--rebase", rem, branch); err != nil {
fmt.Printf(" pull from %s failed: %v\n", rem, err)
pullErr = fmt.Errorf("pull from %s: %w", rem, err)
}
}
// 4. Pop stash regardless of pull outcome (best effort to restore state). // 4. Pop stash regardless of pull outcome (best effort to restore state).
if dirty { if dirty {
@@ -125,18 +146,55 @@ func syncRepo(r repo) error {
} }
if pullErr != nil { if pullErr != nil {
return fmt.Errorf("pull: %w", pullErr) return pullErr
} }
// 5. Push. // 5. Push to each remote.
if err := git(r.path, "push"); err != nil { for _, rem := range rems {
return fmt.Errorf("push: %w", err) if err := git(r.path, "push", rem); err != nil {
return fmt.Errorf("push to %s: %w", rem, err)
}
}
// 6. Push tags to each remote.
for _, rem := range rems {
if err := git(r.path, "push", "--tags", rem); err != nil {
return fmt.Errorf("push tags to %s: %w", rem, err)
}
} }
fmt.Println(" synced") fmt.Println(" synced")
return nil return nil
} }
// remotes returns the list of git remote names for a repository.
func remotes(dir string) ([]string, error) {
cmd := exec.Command("git", "remote")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return nil, err
}
var names []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
names = append(names, line)
}
}
return names, nil
}
// currentBranch returns the current branch name.
func currentBranch(dir string) (string, error) {
cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// isDirty returns true if the working tree or index has uncommitted changes. // isDirty returns true if the working tree or index has uncommitted changes.
func isDirty(dir string) (bool, error) { func isDirty(dir string) (bool, error) {
cmd := exec.Command("git", "status", "--porcelain") cmd := exec.Command("git", "status", "--porcelain")