refactor(sync): improves sync logic, adds AutoFetch and AutoPush to config

This commit is contained in:
Lewis Wynne 2025-12-19 22:49:17 +00:00
parent 1baff6b65d
commit 2499e94ba1
2 changed files with 194 additions and 94 deletions

View file

@ -49,7 +49,9 @@ type StoreConfig struct {
} }
type GitConfig struct { type GitConfig struct {
AutoFetch bool `toml:"auto_fetch"`
AutoCommit bool `toml:"auto_commit"` AutoCommit bool `toml:"auto_commit"`
AutoPush bool `toml:"auto_push"`
} }
var ( var (
@ -82,7 +84,9 @@ func defaultConfig() Config {
AlwaysPromptDelete: true, AlwaysPromptDelete: true,
}, },
Git: GitConfig{ Git: GitConfig{
AutoFetch: false,
AutoCommit: false, AutoCommit: false,
AutoPush: false,
}, },
} }
} }
@ -128,10 +132,18 @@ func loadConfig() (Config, error) {
cfg.Key.AlwaysPromptOverwrite = defaultConfig().Key.AlwaysPromptOverwrite cfg.Key.AlwaysPromptOverwrite = defaultConfig().Key.AlwaysPromptOverwrite
} }
if !md.IsDefined("git", "auto_fetch") {
cfg.Git.AutoFetch = defaultConfig().Git.AutoFetch
}
if !md.IsDefined("git", "auto_commit") { if !md.IsDefined("git", "auto_commit") {
cfg.Git.AutoCommit = defaultConfig().Git.AutoCommit cfg.Git.AutoCommit = defaultConfig().Git.AutoCommit
} }
if !md.IsDefined("git", "auto_push") {
cfg.Git.AutoPush = defaultConfig().Git.AutoPush
}
return cfg, nil return cfg, nil
} }

View file

@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@ -35,93 +36,90 @@ var vcsSyncCmd = &cobra.Command{
Short: "export, commit, pull, restore, and push changes", Short: "export, commit, pull, restore, and push changes",
SilenceUsage: true, SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
store := &Store{} return sync(true)
repoDir, err := preGitHook(store) },
}
func sync(manual bool) error {
store := &Store{}
repoDir, err := ensureVCSInitialized()
if err != nil {
return err
}
remoteInfo, err := repoRemoteInfo(repoDir)
if err != nil {
return err
}
var ahead int
if remoteInfo.Ref != "" {
if manual || config.Git.AutoFetch {
if err := runGit(repoDir, "fetch", "--prune"); err != nil {
return err
}
}
remoteAhead, behind, err := repoAheadBehind(repoDir, remoteInfo.Ref)
if err != nil { if err != nil {
return err return err
} }
ahead = remoteAhead
msg := fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) if behind > 0 {
if err := runGit(repoDir, "add", storeDirName); err != nil { if ahead > 0 {
return err return fmt.Errorf("repo diverged from remote (ahead %d, behind %d); resolve manually", ahead, behind)
}
fmt.Printf("remote has %d commit(s) not present locally; discard local changes and pull? (y/n)\n", behind)
var confirm string
if _, err := fmt.Scanln(&confirm); err != nil {
return fmt.Errorf("cannot continue sync: %w", err)
}
if strings.ToLower(confirm) != "y" {
return fmt.Errorf("aborted sync")
}
dirty, err := repoHasChanges(repoDir)
if err != nil {
return err
}
if dirty {
stashMsg := fmt.Sprintf("pda sync: %s", time.Now().UTC().Format(time.RFC3339))
if err := runGit(repoDir, "stash", "push", "-u", "-m", stashMsg); err != nil {
return err
}
}
if err := pullRemote(repoDir, remoteInfo); err != nil {
return err
}
return restoreAllSnapshots(store, repoDir)
} }
}
if err := exportAllStores(store, repoDir); err != nil {
return err
}
if err := runGit(repoDir, "add", storeDirName); err != nil {
return err
}
changed, err := repoHasStagedChanges(repoDir)
if err != nil {
return err
}
madeCommit := false
if !changed {
fmt.Println("no changes to commit")
} else {
msg := fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339))
if err := runGit(repoDir, "commit", "-m", msg); err != nil { if err := runGit(repoDir, "commit", "-m", msg); err != nil {
return err return err
} }
madeCommit = true
pulled := false }
hasUpstream, err := repoHasUpstream(repoDir) if manual || config.Git.AutoPush {
if err != nil { if remoteInfo.Ref != "" && (madeCommit || ahead > 0) {
return err return pushRemote(repoDir, remoteInfo)
} }
if hasUpstream {
if err := runGit(repoDir, "pull"); err != nil {
return err
}
pulled = true
} else {
hasOrigin, err := repoHasRemote(repoDir, "origin")
if err != nil {
return err
}
if hasOrigin {
branch, err := currentBranch(repoDir)
if err != nil {
return err
}
if branch == "" {
branch = "main"
}
fmt.Printf("running: git pull origin %s\n", branch)
if err := runGit(repoDir, "pull", "origin", branch); err != nil {
return err
}
pulled = true
} else {
fmt.Println("no remote configured; skipping pull")
}
}
if pulled {
conflicted, err := hasMergeConflicts(repoDir)
if err != nil {
return err
}
if conflicted {
return fmt.Errorf("git pull left merge conflicts; resolve and re-run sync")
}
if err := restoreAllSnapshots(store, repoDir); err != nil {
return err
}
}
hasUpstream, err = repoHasUpstream(repoDir)
if err != nil {
return err
}
if hasUpstream {
return runGit(repoDir, "push")
}
hasOrigin, err := repoHasRemote(repoDir, "origin")
if err != nil {
return err
}
if hasOrigin {
branch, err := currentBranch(repoDir)
if err != nil {
return err
}
if branch == "" {
branch = "main"
}
fmt.Printf("running: git push -u origin %s\n", branch)
return runGit(repoDir, "push", "-u", "origin", branch)
}
fmt.Println("no remote configured; skipping push") fmt.Println("no remote configured; skipping push")
return nil }
}, return nil
} }
const storeDirName = "stores" const storeDirName = "stores"
@ -227,20 +225,6 @@ func ensureVCSInitialized() (string, error) {
return repoDir, nil return repoDir, nil
} }
func preGitHook(store *Store) (string, error) {
repoDir, err := ensureVCSInitialized()
if err != nil {
return "", err
}
if store == nil {
store = &Store{}
}
if err := exportAllStores(store, repoDir); err != nil {
return "", err
}
return repoDir, nil
}
func writeGitignore(repoDir string) error { func writeGitignore(repoDir string) error {
path := filepath.Join(repoDir, ".gitignore") path := filepath.Join(repoDir, ".gitignore")
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
@ -338,6 +322,111 @@ func runGit(dir string, args ...string) error {
return cmd.Run() return cmd.Run()
} }
type gitRemoteInfo struct {
Ref string
HasUpstream bool
Remote string
Branch string
}
func repoRemoteInfo(dir string) (gitRemoteInfo, error) {
hasUpstream, err := repoHasUpstream(dir)
if err != nil {
return gitRemoteInfo{}, err
}
if hasUpstream {
return gitRemoteInfo{Ref: "@{u}", HasUpstream: true}, nil
}
hasOrigin, err := repoHasRemote(dir, "origin")
if err != nil {
return gitRemoteInfo{}, err
}
if !hasOrigin {
return gitRemoteInfo{}, nil
}
branch, err := currentBranch(dir)
if err != nil {
return gitRemoteInfo{}, err
}
if branch == "" {
branch = "main"
}
return gitRemoteInfo{
Ref: fmt.Sprintf("origin/%s", branch),
Remote: "origin",
Branch: branch,
}, nil
}
func repoAheadBehind(dir, ref string) (int, int, error) {
cmd := exec.Command("git", "rev-list", "--left-right", "--count", "HEAD..."+ref)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return 0, 0, err
}
fields := strings.Fields(string(out))
if len(fields) != 2 {
return 0, 0, fmt.Errorf("unexpected rev-list output: %q", strings.TrimSpace(string(out)))
}
ahead, err := strconv.Atoi(fields[0])
if err != nil {
return 0, 0, fmt.Errorf("parse ahead count: %w", err)
}
behind, err := strconv.Atoi(fields[1])
if err != nil {
return 0, 0, fmt.Errorf("parse behind count: %w", err)
}
return ahead, behind, nil
}
func repoHasChanges(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(bytes.TrimSpace(out)) > 0, nil
}
func repoHasStagedChanges(dir string) (bool, error) {
cmd := exec.Command("git", "diff", "--cached", "--quiet")
cmd.Dir = dir
err := cmd.Run()
if err == nil {
return false, nil
}
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return true, nil
}
return false, err
}
func pullRemote(dir string, info gitRemoteInfo) error {
if info.HasUpstream {
return runGit(dir, "pull", "--ff-only")
}
if info.Remote != "" && info.Branch != "" {
fmt.Printf("running: git pull --ff-only %s %s\n", info.Remote, info.Branch)
return runGit(dir, "pull", "--ff-only", info.Remote, info.Branch)
}
fmt.Println("no remote configured; skipping pull")
return nil
}
func pushRemote(dir string, info gitRemoteInfo) error {
if info.HasUpstream {
return runGit(dir, "push")
}
if info.Remote != "" && info.Branch != "" {
fmt.Printf("running: git push -u %s %s\n", info.Remote, info.Branch)
return runGit(dir, "push", "-u", info.Remote, info.Branch)
}
fmt.Println("no remote configured; skipping push")
return nil
}
func repoHasUpstream(dir string) (bool, error) { func repoHasUpstream(dir string) (bool, error) {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
cmd.Dir = dir cmd.Dir = dir
@ -526,6 +615,5 @@ func autoSync() error {
if !config.Git.AutoCommit { if !config.Git.AutoCommit {
return nil return nil
} }
// Reuse the sync command end-to-end (export, commit, pull/restore, push). return sync(false)
return vcsSyncCmd.RunE(vcsSyncCmd, []string{})
} }