Compare commits

..

5 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
e639df78ec Add certgen.TestCA for in-memory test certificate infrastructure
Provides a P-256 CA that issues leaf certificates for TLS testing
with full verification enabled. No files written to disk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:44:36 -07:00
5dbb46c3ee Add AIA fields (OCSPServer, IssuingCertificateURL) to certgen.Profile
The Profile struct now supports optional OCSPServer and
IssuingCertificateURL fields. When populated, these are set on the
x509.Certificate template as Authority Information Access extensions
before signing. Empty slices are omitted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:49:28 -07:00
30b5a6699a Ensure CN is included as a DNS SAN when FQDN. 2026-03-15 14:06:36 -07:00
5 changed files with 892 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"math/big"
"net"
"slices"
"strings"
"time"
@@ -88,12 +89,17 @@ func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateR
}
}
dnsNames := cs.Subject.DNSNames
if isFQDN(cs.Subject.CommonName) && !slices.Contains(dnsNames, cs.Subject.CommonName) {
dnsNames = append(dnsNames, cs.Subject.CommonName)
}
req := &x509.CertificateRequest{
PublicKeyAlgorithm: 0,
PublicKey: getPublic(priv),
Subject: subject,
EmailAddresses: cs.Subject.Email,
DNSNames: cs.Subject.DNSNames,
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
@@ -125,11 +131,13 @@ func (cs CertificateRequest) Generate() (crypto.PrivateKey, *x509.CertificateReq
}
type Profile struct {
IsCA bool `yaml:"is_ca"`
PathLen int `yaml:"path_len"`
KeyUse []string `yaml:"key_uses"`
ExtKeyUsages []string `yaml:"ext_key_usages"`
Expiry string `yaml:"expiry"`
IsCA bool `yaml:"is_ca"`
PathLen int `yaml:"path_len"`
KeyUse []string `yaml:"key_uses"`
ExtKeyUsages []string `yaml:"ext_key_usages"`
Expiry string `yaml:"expiry"`
OCSPServer []string `yaml:"ocsp_server,omitempty"`
IssuingCertificateURL []string `yaml:"issuing_certificate_url,omitempty"`
}
func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certificate, error) {
@@ -175,6 +183,13 @@ func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certif
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, eku)
}
if len(p.OCSPServer) > 0 {
certTemplate.OCSPServer = p.OCSPServer
}
if len(p.IssuingCertificateURL) > 0 {
certTemplate.IssuingCertificateURL = p.IssuingCertificateURL
}
return certTemplate, nil
}
@@ -210,6 +225,32 @@ func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey)
return p.SignRequest(certTemplate, req, priv)
}
// isFQDN returns true if s looks like a fully-qualified domain name.
func isFQDN(s string) bool {
if s == "" {
return false
}
// Must contain at least one dot and no spaces.
if !strings.Contains(s, ".") || strings.ContainsAny(s, " \t") {
return false
}
// Each label must be non-empty and consist of letters, digits, or hyphens.
for label := range strings.SplitSeq(strings.TrimSuffix(s, "."), ".") {
if label == "" {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
}
return true
}
func SerialNumber() (*big.Int, error) {
serialNumberBytes := make([]byte, 20)
_, err := rand.Read(serialNumberBytes)

View File

@@ -0,0 +1,188 @@
package certgen
import (
"slices"
"testing"
)
func TestIsFQDN(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"example.com", true},
{"sub.example.com", true},
{"example.com.", true}, // trailing dot
{"localhost", false}, // no dot
{"", false},
{"foo bar.com", false}, // space
{"-bad.com", false}, // leading hyphen
{"bad-.com", false}, // trailing hyphen
{"a..b.com", false}, // empty label
}
for _, tt := range tests {
got := isFQDN(tt.input)
if got != tt.want {
t.Errorf("isFQDN(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestRequestAddsFQDNAsDNSSAN(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "example.com",
Organization: "Test Org",
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !slices.Contains(req.DNSNames, "example.com") {
t.Errorf("expected DNS SAN to contain %q, got %v", "example.com", req.DNSNames)
}
}
func TestRequestFQDNNotDuplicated(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "example.com",
Organization: "Test Org",
DNSNames: []string{"example.com", "www.example.com"},
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
count := 0
for _, name := range req.DNSNames {
if name == "example.com" {
count++
}
}
if count != 1 {
t.Errorf("expected exactly 1 occurrence of %q in DNS SANs, got %d: %v", "example.com", count, req.DNSNames)
}
}
func TestProfileAIAFieldsInCertificate(t *testing.T) {
caKey := KeySpec{Algorithm: "ecdsa", Size: 256}
_, caPriv, err := caKey.Generate()
if err != nil {
t.Fatalf("generate CA key: %v", err)
}
caProfile := Profile{
IsCA: true,
PathLen: 1,
KeyUse: []string{"cert sign", "crl sign"},
Expiry: "8760h",
}
caReq := &CertificateRequest{
KeySpec: caKey,
Subject: Subject{CommonName: "Test CA", Organization: "Test"},
Profile: caProfile,
}
caCSR, err := caReq.Request(caPriv)
if err != nil {
t.Fatalf("generate CA CSR: %v", err)
}
caCert, err := caProfile.SelfSign(caCSR, caPriv)
if err != nil {
t.Fatalf("self-sign CA: %v", err)
}
leafProfile := Profile{
KeyUse: []string{"digital signature"},
ExtKeyUsages: []string{"server auth"},
Expiry: "24h",
OCSPServer: []string{"https://ocsp.example.com"},
IssuingCertificateURL: []string{"https://pki.example.com/ca.pem"},
}
leafReq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{CommonName: "leaf.example.com", Organization: "Test"},
Profile: leafProfile,
}
_, leafCSR, err := leafReq.Generate()
if err != nil {
t.Fatalf("generate leaf CSR: %v", err)
}
leafCert, err := leafProfile.SignRequest(caCert, leafCSR, caPriv)
if err != nil {
t.Fatalf("sign leaf: %v", err)
}
if len(leafCert.OCSPServer) != 1 || leafCert.OCSPServer[0] != "https://ocsp.example.com" {
t.Errorf("OCSPServer = %v, want [https://ocsp.example.com]", leafCert.OCSPServer)
}
if len(leafCert.IssuingCertificateURL) != 1 || leafCert.IssuingCertificateURL[0] != "https://pki.example.com/ca.pem" {
t.Errorf("IssuingCertificateURL = %v, want [https://pki.example.com/ca.pem]", leafCert.IssuingCertificateURL)
}
}
func TestProfileWithoutAIAOmitsExtension(t *testing.T) {
profile := Profile{
KeyUse: []string{"digital signature"},
ExtKeyUsages: []string{"server auth"},
Expiry: "24h",
}
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{CommonName: "noaia.example.com", Organization: "Test"},
Profile: profile,
}
cert, _, err := GenerateSelfSigned(creq)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(cert.OCSPServer) != 0 {
t.Errorf("OCSPServer = %v, want empty", cert.OCSPServer)
}
if len(cert.IssuingCertificateURL) != 0 {
t.Errorf("IssuingCertificateURL = %v, want empty", cert.IssuingCertificateURL)
}
}
func TestRequestNonFQDNCommonNameNotAdded(t *testing.T) {
creq := &CertificateRequest{
KeySpec: KeySpec{Algorithm: "ecdsa", Size: 256},
Subject: Subject{
CommonName: "localhost",
Organization: "Test Org",
},
Profile: Profile{
Expiry: "1h",
},
}
_, req, err := creq.Generate()
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if slices.Contains(req.DNSNames, "localhost") {
t.Errorf("expected DNS SANs to not contain %q, got %v", "localhost", req.DNSNames)
}
}

213
certlib/certgen/testca.go Normal file
View File

@@ -0,0 +1,213 @@
package certgen
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"net"
"time"
)
// TestCA is an in-memory certificate authority for use in tests. It
// provides a root CA certificate and the ability to issue leaf
// certificates for TLS testing with full verification enabled.
type TestCA struct {
cert *x509.Certificate
key *ecdsa.PrivateKey
}
// NewTestCA creates a new TestCA with a self-signed P-256 root
// certificate. The CA is valid for 1 hour.
func NewTestCA() (*TestCA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("certgen: generating CA key: %w", err)
}
serial, err := SerialNumber()
if err != nil {
return nil, fmt.Errorf("certgen: generating serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Test CA",
Organization: []string{"Test"},
},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(1 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("certgen: creating CA certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("certgen: parsing CA certificate: %w", err)
}
return &TestCA{cert: cert, key: key}, nil
}
// Certificate returns the root CA certificate.
func (ca *TestCA) Certificate() *x509.Certificate {
return ca.cert
}
// CertificatePEM returns the root CA certificate as a PEM-encoded
// byte slice.
func (ca *TestCA) CertificatePEM() []byte {
return pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: ca.cert.Raw,
})
}
// CertPool returns a certificate pool containing the root CA
// certificate, suitable for use as a TLS root CA pool.
func (ca *TestCA) CertPool() *x509.CertPool {
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
return pool
}
// Issue creates a new leaf certificate signed by the CA for the given
// DNS names and IP addresses. It returns the leaf private key and
// certificate. The leaf certificate is valid for 1 hour with key
// usage appropriate for a TLS server.
func (ca *TestCA) Issue(dnsNames []string, ips []net.IP) (crypto.Signer, *x509.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("certgen: generating leaf key: %w", err)
}
serial, err := SerialNumber()
if err != nil {
return nil, nil, fmt.Errorf("certgen: generating serial: %w", err)
}
cn := "localhost"
if len(dnsNames) > 0 {
cn = dnsNames[0]
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: cn,
Organization: []string{"Test"},
},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(1 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
DNSNames: dnsNames,
IPAddresses: ips,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key)
if err != nil {
return nil, nil, fmt.Errorf("certgen: creating leaf certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, nil, fmt.Errorf("certgen: parsing leaf certificate: %w", err)
}
return key, cert, nil
}
// IssueServer is a convenience wrapper around Issue for the common
// case of a server certificate for localhost (both DNS and IP).
func (ca *TestCA) IssueServer() (crypto.Signer, *x509.Certificate, error) {
return ca.Issue(
[]string{"localhost"},
[]net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
)
}
// TLSConfig returns a tls.Config (by value) configured with the CA's
// root pool for verification. The caller can set additional fields
// (e.g., Certificates) or modify the returned config safely.
func (ca *TestCA) TLSConfig() tls.Config {
return tls.Config{
RootCAs: ca.CertPool(),
MinVersion: tls.VersionTLS13,
}
}
// ServerTLSConfig returns a tls.Config (by value) for a TLS server
// using the given leaf key and certificate, with client verification
// against the CA root pool. Pass key and cert from Issue or
// IssueServer.
func (ca *TestCA) ServerTLSConfig(key crypto.Signer, cert *x509.Certificate) tls.Config {
return tls.Config{
Certificates: []tls.Certificate{
{
Certificate: [][]byte{cert.Raw},
PrivateKey: key,
Leaf: cert,
},
},
ClientCAs: ca.CertPool(),
MinVersion: tls.VersionTLS13,
}
}
// TLSKeyPair returns a tls.Certificate from the given key and
// certificate, suitable for use in a tls.Config.Certificates slice.
func TLSKeyPair(key crypto.Signer, cert *x509.Certificate) tls.Certificate {
return tls.Certificate{
Certificate: [][]byte{cert.Raw},
PrivateKey: key,
Leaf: cert,
}
}
// MustTestCA calls NewTestCA and panics on error. Intended for use
// in TestMain or test helpers where error handling is impractical.
func MustTestCA() *TestCA {
ca, err := NewTestCA()
if err != nil {
panic("certgen: " + err.Error())
}
return ca
}
// CertificatePEM returns a PEM-encoded byte slice for the given
// certificate.
func CertificatePEM(cert *x509.Certificate) []byte {
return pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
})
}
// PrivateKeyPEM returns a PEM-encoded PKCS#8 byte slice for the
// given private key.
func PrivateKeyPEM(key crypto.Signer) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("certgen: marshaling private key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: der,
}), nil
}

View File

@@ -0,0 +1,223 @@
package certgen
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewTestCA(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
cert := ca.Certificate()
if !cert.IsCA {
t.Fatal("expected CA certificate")
}
if cert.Subject.CommonName != "Test CA" {
t.Fatalf("got CN %q, want %q", cert.Subject.CommonName, "Test CA")
}
}
func TestCertificatePEMRoundtrip(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
pemBytes := ca.CertificatePEM()
block, _ := pem.Decode(pemBytes)
if block == nil {
t.Fatal("failed to decode PEM")
}
if block.Type != "CERTIFICATE" {
t.Fatalf("got PEM type %q, want CERTIFICATE", block.Type)
}
parsed, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
if !parsed.Equal(ca.Certificate()) {
t.Fatal("parsed certificate does not match original")
}
}
func TestCertPool(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
pool := ca.CertPool()
// Verify the CA cert validates against its own pool.
chains, err := ca.Certificate().Verify(x509.VerifyOptions{
Roots: pool,
})
if err != nil {
t.Fatalf("verify CA cert against its own pool: %v", err)
}
if len(chains) == 0 {
t.Fatal("expected at least one chain")
}
}
func TestIssue(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
dnsNames := []string{"example.test", "www.example.test"}
ips := []net.IP{net.IPv4(10, 0, 0, 1)}
key, cert, err := ca.Issue(dnsNames, ips)
if err != nil {
t.Fatalf("Issue: %v", err)
}
if key == nil {
t.Fatal("expected non-nil key")
}
if cert.IsCA {
t.Fatal("leaf cert should not be CA")
}
if cert.Subject.CommonName != "example.test" {
t.Fatalf("got CN %q, want %q", cert.Subject.CommonName, "example.test")
}
// Verify the leaf cert chains to the CA.
_, err = cert.Verify(x509.VerifyOptions{
Roots: ca.CertPool(),
DNSName: "example.test",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
if err != nil {
t.Fatalf("verify leaf cert: %v", err)
}
}
func TestIssueServerTLS(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
key, cert, err := ca.IssueServer()
if err != nil {
t.Fatalf("IssueServer: %v", err)
}
// Start a TLS server with the issued cert.
serverCfg := ca.ServerTLSConfig(key, cert)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
srv.TLS = &serverCfg
srv.StartTLS()
defer srv.Close()
// Create a client that verifies the server cert against the CA.
clientCfg := ca.TLSConfig()
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &clientCfg,
},
}
resp, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("GET: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("got status %d, want 200", resp.StatusCode)
}
}
func TestTLSConfigReturnsByValue(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
cfg1 := ca.TLSConfig()
cfg2 := ca.TLSConfig()
// Modifying one should not affect the other.
cfg1.ServerName = "modified"
if cfg2.ServerName == "modified" {
t.Fatal("TLSConfig should return independent values")
}
}
func TestTLSConfigEnforcesTLS13(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
cfg := ca.TLSConfig()
if cfg.MinVersion != tls.VersionTLS13 {
t.Fatalf("got MinVersion %d, want TLS 1.3 (%d)", cfg.MinVersion, tls.VersionTLS13)
}
}
func TestMustTestCA(t *testing.T) {
// Should not panic.
ca := MustTestCA()
if ca.Certificate() == nil {
t.Fatal("expected non-nil certificate")
}
}
func TestPrivateKeyPEM(t *testing.T) {
ca, err := NewTestCA()
if err != nil {
t.Fatalf("NewTestCA: %v", err)
}
key, _, err := ca.IssueServer()
if err != nil {
t.Fatalf("IssueServer: %v", err)
}
pemBytes, err := PrivateKeyPEM(key)
if err != nil {
t.Fatalf("PrivateKeyPEM: %v", err)
}
block, _ := pem.Decode(pemBytes)
if block == nil {
t.Fatal("failed to decode PEM")
}
if block.Type != "PRIVATE KEY" {
t.Fatalf("got PEM type %q, want PRIVATE KEY", block.Type)
}
}
func TestUntrustedCAFails(t *testing.T) {
ca1 := MustTestCA()
ca2 := MustTestCA()
// Issue a cert from ca1, try to verify against ca2's pool.
_, cert, err := ca1.IssueServer()
if err != nil {
t.Fatalf("IssueServer: %v", err)
}
_, err = cert.Verify(x509.VerifyOptions{
Roots: ca2.CertPool(),
DNSName: "localhost",
})
if err == nil {
t.Fatal("expected verification to fail with wrong CA")
}
}

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)
}