Compare commits

..

2 Commits

Author SHA1 Message Date
5344bed1ea 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>
2026-03-27 21:52:41 -07:00
f5327e432e add git-sync 2026-03-27 21:44:23 -07:00

221
cmd/git-sync/main.go Normal file
View File

@@ -0,0 +1,221 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// repo represents a git repository to sync.
type repo struct {
path string
name string
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
absRoot, err := filepath.Abs(root)
if err != nil {
fatalf("cannot resolve path %q: %v", root, err)
}
repos, err := discoverRepos(absRoot)
if err != nil {
fatalf("discovery failed: %v", err)
}
if len(repos) == 0 {
fatalf("no git repositories found under %s", absRoot)
}
fmt.Printf("Found %d repo(s) to sync.\n\n", len(repos))
var failed []string
for _, r := range repos {
if err := syncRepo(r); err != nil {
fmt.Printf(" %s: %v\n\n", r.name, err)
failed = append(failed, r.name)
}
}
fmt.Println(strings.Repeat("", 60))
if len(failed) > 0 {
fmt.Printf("Done. %d/%d succeeded; failures: %s\n",
len(repos)-len(failed), len(repos), strings.Join(failed, ", "))
os.Exit(1)
}
fmt.Printf("Done. All %d repo(s) synced.\n", len(repos))
}
// discoverRepos finds the root repo and any immediate child repos.
func discoverRepos(root string) ([]repo, error) {
var repos []repo
// Check if root itself is a git repo.
if isGitRepo(root) {
repos = append(repos, repo{path: root, name: filepath.Base(root) + " (root)"})
}
entries, err := os.ReadDir(root)
if err != nil {
return nil, fmt.Errorf("reading directory: %w", err)
}
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
child := filepath.Join(root, e.Name())
if isGitRepo(child) {
repos = append(repos, repo{path: child, name: e.Name()})
}
}
return repos, nil
}
func isGitRepo(dir string) bool {
gitDir := filepath.Join(dir, ".git")
info, err := os.Stat(gitDir)
if err != nil {
return false
}
// .git can be a directory or a file (worktrees / submodules).
return info.IsDir() || info.Mode().IsRegular()
}
// syncRepo performs the full sync sequence on a single repository:
//
// fetch --all --prune → stash (if dirty) → pull --rebase each remote →
// stash pop → push each remote → push tags each remote
func syncRepo(r repo) error {
fmt.Printf(" %s (%s)\n", r.name, r.path)
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 {
return fmt.Errorf("fetch: %w", err)
}
// 2. Stash if the working tree is dirty.
dirty, err := isDirty(r.path)
if err != nil {
return fmt.Errorf("checking dirty state: %w", err)
}
if dirty {
fmt.Println(" stashing uncommitted changes")
if err := git(r.path, "stash", "push", "-m", "git-sync auto-stash"); err != nil {
return fmt.Errorf("stash push: %w", err)
}
}
// 3. Pull --rebase from each remote.
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).
if dirty {
fmt.Println(" restoring stashed changes")
if popErr := git(r.path, "stash", "pop"); popErr != nil {
fmt.Printf(" stash pop failed: %v (changes remain in stash)\n", popErr)
}
}
if pullErr != nil {
return pullErr
}
// 5. Push to each remote.
for _, rem := range rems {
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")
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.
func isDirty(dir string) (bool, error) {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return false, err
}
return len(strings.TrimSpace(string(out))) > 0, nil
}
// git runs a git command in the given directory, forwarding stderr.
func git(dir string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}