From 5a1c5565932360ce6e4092a4610ea0ef1f8d1d8f Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 08:28:40 +0000 Subject: [PATCH] feat(vcs): extracts VCS cmds out. Exposes git command for running arbitrary git command. --- cmd/git.go | 55 +++++++++++++++ cmd/init.go | 120 ++++++++++++++++++++++++++++++++ cmd/sync.go | 133 +++++++++++++++++++++++++++++++++++ cmd/vcs.go | 197 +--------------------------------------------------- 4 files changed, 309 insertions(+), 196 deletions(-) create mode 100644 cmd/git.go create mode 100644 cmd/init.go create mode 100644 cmd/sync.go diff --git a/cmd/git.go b/cmd/git.go new file mode 100644 index 0000000..fc00b9e --- /dev/null +++ b/cmd/git.go @@ -0,0 +1,55 @@ +/* +Copyright © 2025 Lewis Wynne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +var gitCmd = &cobra.Command{ + Use: "git [args...]", + Short: "Run git in the pda VCS repository", + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + repoDir, err := ensureVCSInitialized() + if err != nil { + return err + } + + gitCmd := exec.Command("git", args...) + gitCmd.Dir = repoDir + gitCmd.Stdin = os.Stdin + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + return gitCmd.Run() + }, +} + +func init() { + rootCmd.AddCommand(gitCmd) +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..6f07de8 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,120 @@ +/* +Copyright © 2025 Lewis Wynne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init [remote-url]", + Short: "Initialise (or fetch) Git-backed version control.", + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: vcsInit, +} + +func init() { + initCmd.Flags().Bool("clean", false, "Remove existing VCS directory before initialising") + rootCmd.AddCommand(initCmd) +} + +func vcsInit(cmd *cobra.Command, args []string) error { + repoDir, err := vcsRepoRoot() + if err != nil { + return err + } + store := &Store{} + + clean, err := cmd.Flags().GetBool("clean") + if err != nil { + return err + } + if clean { + entries, err := os.ReadDir(repoDir) + if err == nil && len(entries) > 0 { + fmt.Printf("remove existing VCS directory '%s'? (y/n)\n", repoDir) + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil { + return fmt.Errorf("cannot clean vcs dir: %w", err) + } + if strings.ToLower(confirm) != "y" { + return fmt.Errorf("aborted cleaning vcs dir") + } + } + if err := os.RemoveAll(repoDir); err != nil { + return fmt.Errorf("cannot clean vcs dir: %w", err) + } + + dbs, err := store.AllStores() + if err == nil && len(dbs) > 0 { + fmt.Printf("remove all existing stores? (y/n)\n") + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil { + return fmt.Errorf("cannot clean stores: %w", err) + } + if strings.ToLower(confirm) != "y" { + return fmt.Errorf("aborted cleaning stores") + } + if err := wipeAllStores(store); err != nil { + return fmt.Errorf("cannot clean stores: %w", err) + } + } + } + if err := os.MkdirAll(filepath.Join(repoDir), 0o750); err != nil { + return err + } + + gitDir := filepath.Join(repoDir, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + if len(args) == 1 { + remote := args[0] + fmt.Printf("running: git clone %s %s\n", remote, repoDir) + if err := runGit("", "clone", remote, repoDir); err != nil { + return err + } + } else { + fmt.Printf("running: git init\n") + if err := runGit(repoDir, "init"); err != nil { + return err + } + } + } else { + fmt.Println("vcs already initialised; use --clean to reinitialise") + return nil + } + + if err := writeGitignore(repoDir); err != nil { + return err + } + + if len(args) == 0 { + return nil + } + return restoreAllSnapshots(store, repoDir) +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..9f7e532 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,133 @@ +/* +Copyright © 2025 Lewis Wynne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Manually sync your stores with Git", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return sync(true) + }, +} + +func init() { + rootCmd.AddCommand(syncCmd) +} + +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 { + return err + } + ahead = remoteAhead + if behind > 0 { + if ahead > 0 { + 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 { + return err + } + madeCommit = true + } + if manual || config.Git.AutoPush { + if remoteInfo.Ref != "" && (madeCommit || ahead > 0) { + return pushRemote(repoDir, remoteInfo) + } + fmt.Println("no remote configured; skipping push") + } + return nil +} + +func autoSync() error { + if !config.Git.AutoCommit { + return nil + } + return sync(false) +} diff --git a/cmd/vcs.go b/cmd/vcs.go index dc7c291..53c47e1 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -11,201 +11,13 @@ import ( "path/filepath" "strconv" "strings" - "time" "github.com/dgraph-io/badger/v4" gap "github.com/muesli/go-app-paths" - "github.com/spf13/cobra" ) -var vcsCmd = &cobra.Command{ - Use: "vcs", - Short: "Version control utilities", -} - -var vcsInitCmd = &cobra.Command{ - Use: "init [remote-url]", - Short: "Initialise or fetch a Git repo for version control", - SilenceUsage: true, - Args: cobra.MaximumNArgs(1), - RunE: vcsInit, -} - -var vcsSyncCmd = &cobra.Command{ - Use: "sync", - Short: "export, commit, pull, restore, and push changes", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - return sync(true) - }, -} - -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 { - return err - } - ahead = remoteAhead - if behind > 0 { - if ahead > 0 { - 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 { - return err - } - madeCommit = true - } - if manual || config.Git.AutoPush { - if remoteInfo.Ref != "" && (madeCommit || ahead > 0) { - return pushRemote(repoDir, remoteInfo) - } - fmt.Println("no remote configured; skipping push") - } - return nil -} - const storeDirName = "stores" -func init() { - vcsInitCmd.Flags().Bool("clean", false, "Remove existing VCS directory before initialising") - vcsCmd.AddCommand(vcsInitCmd) - vcsCmd.AddCommand(vcsSyncCmd) - rootCmd.AddCommand(vcsCmd) -} - -func vcsInit(cmd *cobra.Command, args []string) error { - repoDir, err := vcsRepoRoot() - if err != nil { - return err - } - store := &Store{} - - clean, err := cmd.Flags().GetBool("clean") - if err != nil { - return err - } - if clean { - entries, err := os.ReadDir(repoDir) - if err == nil && len(entries) > 0 { - fmt.Printf("remove existing VCS directory '%s'? (y/n)\n", repoDir) - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot clean vcs dir: %w", err) - } - if strings.ToLower(confirm) != "y" { - return fmt.Errorf("aborted cleaning vcs dir") - } - } - if err := os.RemoveAll(repoDir); err != nil { - return fmt.Errorf("cannot clean vcs dir: %w", err) - } - - dbs, err := store.AllStores() - if err == nil && len(dbs) > 0 { - fmt.Printf("remove all existing stores? (y/n)\n") - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot clean stores: %w", err) - } - if strings.ToLower(confirm) != "y" { - return fmt.Errorf("aborted cleaning stores") - } - if err := wipeAllStores(store); err != nil { - return fmt.Errorf("cannot clean stores: %w", err) - } - } - } - if err := os.MkdirAll(filepath.Join(repoDir), 0o750); err != nil { - return err - } - - gitDir := filepath.Join(repoDir, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - if len(args) == 1 { - remote := args[0] - fmt.Printf("running: git clone %s %s\n", remote, repoDir) - if err := runGit("", "clone", remote, repoDir); err != nil { - return err - } - } else { - fmt.Printf("running: git init\n") - if err := runGit(repoDir, "init"); err != nil { - return err - } - } - } else { - fmt.Println("vcs already initialised; use --clean to reinitialise") - return nil - } - - if err := writeGitignore(repoDir); err != nil { - return err - } - - if len(args) == 0 { - return nil - } - return restoreAllSnapshots(store, repoDir) -} - func vcsRepoRoot() (string, error) { scope := gap.NewVendorScope(gap.User, "pda", "vcs") dir, err := scope.DataPath("") @@ -225,7 +37,7 @@ func ensureVCSInitialized() (string, error) { } if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("vcs repository not initialised; run 'pda vcs init' first") + return "", fmt.Errorf("vcs repository not initialised; run 'pda init' first") } return "", err } @@ -618,10 +430,3 @@ func hasMergeConflicts(dir string) (bool, error) { } return len(bytes.TrimSpace(out)) > 0, nil } - -func autoSync() error { - if !config.Git.AutoCommit { - return nil - } - return sync(false) -}