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