Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5344bed1ea | |||
| f5327e432e |
221
cmd/git-sync/main.go
Normal file
221
cmd/git-sync/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user