From d6ffc0cea40d651737d0db4b93b87276ba4add03 Mon Sep 17 00:00:00 2001 From: Peter Aronoff Date: Sun, 24 Nov 2024 19:42:50 -0500 Subject: [PATCH] feat: almost ready for v1.0.0 TODO: + Move flag parsing code to internal/cli/flag.go and rename the function to Parse. + Make the configuration json a proper object rather than only an array. + Move configuration unmarshaling code to internal/cli/config.go and rename the function Load. + Look for other parts of the internal code that I can make private. --- .gitignore | 2 +- Makefile | 16 +- TODO.txt | 11 + cli/app.go | 189 ------------------ cli/result.go | 34 ---- cli/run.go | 31 --- cmd/gitmirror/main.go | 11 + go.mod | 5 +- go.sum | 2 + internal/cli/app.go | 119 +++++++++++ internal/cli/clone.go | 75 +++++++ internal/cli/gitmirror.go | 40 ++++ internal/cli/help.go | 46 +++++ internal/cli/result.go | 22 ++ {cli => internal/cli}/testdata/backups.json | 0 .../cli}/testdata/repo-checks.json | 0 {cli => internal/cli}/unmarshal_test.go | 9 +- internal/cli/update.go | 79 ++++++++ internal/cli/usage.go | 55 +++++ internal/cli/version.go | 26 +++ internal/git/fetchhead.go | 20 ++ internal/git/fetchhead_test.go | 54 +++++ internal/git/testdata/differentFetchHead | 4 + internal/git/testdata/identicalFetchHead | 4 + internal/git/testdata/longerFetchHead | 5 + internal/git/testdata/originalFetchHead | 4 + internal/git/testdata/shorterFetchHead | 3 + main.go | 11 - 28 files changed, 601 insertions(+), 276 deletions(-) create mode 100644 TODO.txt delete mode 100644 cli/app.go delete mode 100644 cli/result.go delete mode 100644 cli/run.go create mode 100644 cmd/gitmirror/main.go create mode 100644 internal/cli/app.go create mode 100644 internal/cli/clone.go create mode 100644 internal/cli/gitmirror.go create mode 100644 internal/cli/help.go create mode 100644 internal/cli/result.go rename {cli => internal/cli}/testdata/backups.json (100%) rename {cli => internal/cli}/testdata/repo-checks.json (100%) rename {cli => internal/cli}/unmarshal_test.go (87%) create mode 100644 internal/cli/update.go create mode 100644 internal/cli/usage.go create mode 100644 internal/cli/version.go create mode 100644 internal/git/fetchhead.go create mode 100644 internal/git/fetchhead_test.go create mode 100644 internal/git/testdata/differentFetchHead create mode 100644 internal/git/testdata/identicalFetchHead create mode 100644 internal/git/testdata/longerFetchHead create mode 100644 internal/git/testdata/originalFetchHead create mode 100644 internal/git/testdata/shorterFetchHead delete mode 100644 main.go diff --git a/.gitignore b/.gitignore index ad91027..91e6eff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -gitmirror +/gitmirror diff --git a/Makefile b/Makefile index 3ae732f..11ab49b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ .DEFAULT_GOAL := test +PREFIX := $(HOME)/local/gitmirror + fmt: golangci-lint run --disable-all --no-config -Egofmt --fix golangci-lint run --disable-all --no-config -Egofumpt --fix @@ -8,21 +10,25 @@ lint: fmt golangci-lint run test: - go test -shuffle on github.com/telemachus/gitmirror/cli + go test -shuffle on github.com/telemachus/gitmirror/internal/cli + go test -shuffle on github.com/telemachus/gitmirror/internal/git testv: - go test -shuffle on -v github.com/telemachus/gitmirror/cli + go test -shuffle on -v github.com/telemachus/gitmirror/internal/cli + go test -shuffle on -v github.com/telemachus/gitmirror/internal/git testr: - go test -race -shuffle on github.com/telemachus/gitmirror/cli + go test -race -shuffle on github.com/telemachus/gitmirror/internal/cli + go test -race -shuffle on github.com/telemachus/gitmirror/internal/git build: lint testr - go build . + go build ./cmd/gitmirror install: build - go install . + go install ./cmd/gitmirror clean: + rm -f gitmirror go clean -i -r -cache .PHONY: fmt lint build install test testv testr clean diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..d456cb3 --- /dev/null +++ b/TODO.txt @@ -0,0 +1,11 @@ +# TODO + +## Clean up + +This works now as a single command, but there’s a lot of overlapping +responsibilities and cruft. + ++ How should I handle the command and subcommand names? (Store them in the app? + Store them as package constants? Something else?) ++ What is the responsibility of the app versus just a function call? ++ Maybe I can handle flags and flag-parsing better? diff --git a/cli/app.go b/cli/app.go deleted file mode 100644 index 9b9c394..0000000 --- a/cli/app.go +++ /dev/null @@ -1,189 +0,0 @@ -package cli - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" -) - -// App stores information about the application's state. -type App struct { - HomeDir string - ExitValue int - InitWanted bool - HelpWanted bool - QuietWanted bool - VersionWanted bool -} - -// NoOp determines whether an App should bail out. -func (app *App) NoOp() bool { - return app.ExitValue != exitSuccess || app.HelpWanted || app.VersionWanted -} - -// NewApp returns a new App pointer. -func NewApp() *App { - homeDir, err := os.UserHomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", appName, err) - return &App{ExitValue: exitFailure} - } - return &App{ExitValue: exitSuccess, HomeDir: homeDir} -} - -// ParseFlags handles flags and options in my finicky way. -func (app *App) ParseFlags(args []string) (string, bool) { - if app.NoOp() { - return "", false - } - flags := flag.NewFlagSet("gitmirror", flag.ContinueOnError) - flags.SetOutput(io.Discard) - var configFile string - var configIsDefault bool - flags.BoolVar(&app.HelpWanted, "help", false, "") - flags.BoolVar(&app.HelpWanted, "h", false, "") - flags.BoolVar(&app.InitWanted, "init", false, "") - flags.BoolVar(&app.InitWanted, "i", false, "") - flags.BoolVar(&app.QuietWanted, "quiet", false, "") - flags.BoolVar(&app.QuietWanted, "q", false, "") - flags.BoolVar(&app.VersionWanted, "version", false, "") - flags.BoolVar(&app.VersionWanted, "v", false, "") - flags.StringVar(&configFile, "config", "", "") - flags.StringVar(&configFile, "c", "", "") - err := flags.Parse(args) - switch { - // This must precede all other checks. - case err != nil: - fmt.Fprintf(os.Stderr, "%s: %s\n%s\n", appName, err, appUsage) - app.ExitValue = exitFailure - case app.HelpWanted: - fmt.Println(appUsage) - case app.VersionWanted: - fmt.Printf("%s: %s\n", appName, appVersion) - case configFile == "": - configFile = defaultConfig - configIsDefault = true - } - return configFile, configIsDefault -} - -// Repo stores information about a git repository. -type Repo struct { - URL string - Name string -} - -// Unmarshal reads a configuration file and returns a slice of Repo. -func (app *App) Unmarshal(configFile string, configIsDefault bool) []Repo { - if app.NoOp() { - return nil - } - if configIsDefault { - configFile = filepath.Join(app.HomeDir, configFile) - } - blob, err := os.ReadFile(configFile) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", appName, err) - app.ExitValue = exitFailure - return nil - } - repos := make([]Repo, 0, 20) - err = json.Unmarshal(blob, &repos) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %s\n", appName, err) - app.ExitValue = exitFailure - return nil - } - // We cannot mirror a repository without a URL and a directory name. - return slices.DeleteFunc(repos, func(repo Repo) bool { - return repo.URL == "" || repo.Name == "" - }) -} - -// Initialize runs git repote update on a group of repositories. -func (app *App) Initialize(repos []Repo) { - if app.NoOp() { - return - } - err := os.MkdirAll(filepath.Join(app.HomeDir, defaultStorage), os.ModePerm) - if err != nil { - app.ExitValue = exitFailure - return - } - ch := make(chan Publisher) - for _, repo := range repos { - go app.initialize(repo, ch) - } - for range repos { - result := <-ch - result.Publish(app.QuietWanted) - } -} - -func (app *App) initialize(repo Repo, ch chan<- Publisher) { - // Normally, it is a bad idea to check whether a directory exists - // before trying an operation. However, this case is an exception. - // git clone --mirror /path/to/existing/repo.git will fail with an - // error, but for the purpose of this app, there is no error. - // If a directory with the same name already exists, I simply want - // to send a Success on the channel and return. - repoPath := filepath.Join(app.HomeDir, defaultStorage, repo.Name) - if _, err := os.Stat(repoPath); err == nil { - ch <- Success{msg: fmt.Sprintf("%s: %s already exists", appName, repoPath)} - return - } - args := []string{"clone", "--mirror", repo.URL, repo.Name} - cmd := exec.Command("git", args...) - cmd.Dir = filepath.Join(app.HomeDir, defaultStorage) - cmdString := fmt.Sprintf( - "git %s (in %s)", - strings.Join(args, " "), - strings.Replace(cmd.Dir, app.HomeDir, "~", 1), - ) - err := cmd.Run() - if err != nil { - app.ExitValue = exitFailure - ch <- Failure{msg: fmt.Sprintf("%s: %s: %s", appName, cmdString, err)} - return - } - ch <- Success{msg: fmt.Sprintf("%s: %s", appName, cmdString)} -} - -// Update runs git repote update on a group of repositories. -func (app *App) Update(repos []Repo) { - if app.InitWanted || app.NoOp() { - return - } - ch := make(chan Publisher) - for _, repo := range repos { - go app.update(repo, ch) - } - for range repos { - result := <-ch - result.Publish(app.QuietWanted) - } -} - -func (app *App) update(repo Repo, ch chan<- Publisher) { - args := []string{"remote", "update"} - cmd := exec.Command("git", args...) - cmd.Dir = filepath.Join(app.HomeDir, defaultStorage, repo.Name) - cmdString := fmt.Sprintf( - "git %s (in %s)", - strings.Join(args, " "), - strings.Replace(cmd.Dir, app.HomeDir, "~", 1), - ) - err := cmd.Run() - if err != nil { - app.ExitValue = exitFailure - ch <- Failure{msg: fmt.Sprintf("%s: %s: %s", appName, cmdString, err)} - return - } - ch <- Success{msg: fmt.Sprintf("%s: %s", appName, cmdString)} -} diff --git a/cli/result.go b/cli/result.go deleted file mode 100644 index 8e23e36..0000000 --- a/cli/result.go +++ /dev/null @@ -1,34 +0,0 @@ -package cli - -import ( - "fmt" - "os" -) - -// Publisher is the interface that wraps the Publish method. -type Publisher interface { - Publish(quietWanted bool) -} - -// Success represents `git push --mirror` when nothing goes wrong. -type Success struct { - msg string -} - -// Failure represents `git push --mirror` when something goes wrong. -type Failure struct { - msg string -} - -// Publish prints a Success's message to stdout unless the user passed -quiet. -func (s Success) Publish(quietWanted bool) { - if quietWanted { - return - } - fmt.Fprintln(os.Stdout, s.msg) -} - -// Publish unconditionally prints a Failure's message to stderr. -func (f Failure) Publish(_ bool) { - fmt.Fprintln(os.Stderr, f.msg) -} diff --git a/cli/run.go b/cli/run.go deleted file mode 100644 index 2db6683..0000000 --- a/cli/run.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package cli organizes and implements a command line program. -package cli - -const ( - appName = "gitmirror" - appVersion = "v0.1.0" - appUsage = `usage: gitmirror [-config FILENAME] - -options: - -config/-c FILENAME Specify a configuration file (default = ~/.gitmirror.json) - -init/-i Clone repositories for later mirroring - -quiet/-q Suppress output unless an error occurs - -help/-h Show this message - -version/-v Show version` - defaultConfig = ".gitmirror.json" - defaultStorage = ".local/share/gitmirror" - exitSuccess = 0 - exitFailure = 1 -) - -// Run creates an App, performs the App's tasks, and returns an exit value. -func Run(args []string) int { - app := NewApp() - configFile, configIsDefault := app.ParseFlags(args) - repos := app.Unmarshal(configFile, configIsDefault) - if app.InitWanted { - app.Initialize(repos) - } - app.Update(repos) - return app.ExitValue -} diff --git a/cmd/gitmirror/main.go b/cmd/gitmirror/main.go new file mode 100644 index 0000000..61308f9 --- /dev/null +++ b/cmd/gitmirror/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/telemachus/gitmirror/internal/cli" +) + +func main() { + os.Exit(cli.CmdGitmirror(os.Args[1:])) +} diff --git a/go.mod b/go.mod index 607850a..e39b0bb 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/telemachus/gitmirror go 1.23 -require github.com/google/go-cmp v0.6.0 +require ( + github.com/MakeNowJust/heredoc v1.0.0 + github.com/google/go-cmp v0.6.0 +) diff --git a/go.sum b/go.sum index 5a8d551..2559825 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..3ec271e --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,119 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +const ( + defaultConfig = ".gitmirror.json" + defaultStorage = ".local/share/gitmirror" + exitSuccess = 0 + exitFailure = 1 +) + +// App stores information about the application's state. +type App struct { + HomeDir string + CmdName string + Usage string + ExitValue int + HelpWanted bool + QuietWanted bool +} + +// NoOp determines whether an App should bail out. +func (app *App) NoOp() bool { + return app.ExitValue != exitSuccess +} + +// NewApp returns a new App pointer. +func NewApp(cmdUsage string) *App { + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", gitmirrorName, err) + return &App{ExitValue: exitFailure} + } + return &App{ + CmdName: gitmirrorName, + Usage: gitmirrorUsage, + ExitValue: exitSuccess, + HomeDir: homeDir, + } +} + +// Repo stores information about a git repository. +type Repo struct { + URL string + Name string +} + +// Flags handles flags and options in my finicky way. +func (app *App) Flags(args []string) (string, bool) { + if app.NoOp() { + return "", false + } + cmdFlags := flag.NewFlagSet("gitmirror", flag.ContinueOnError) + cmdFlags.SetOutput(io.Discard) + var configFile string + var configIsDefault bool + cmdFlags.BoolVar(&app.HelpWanted, "help", false, "") + cmdFlags.BoolVar(&app.HelpWanted, "h", false, "") + cmdFlags.BoolVar(&app.QuietWanted, "quiet", false, "") + cmdFlags.BoolVar(&app.QuietWanted, "q", false, "") + cmdFlags.StringVar(&configFile, "config", "", "") + cmdFlags.StringVar(&configFile, "c", "", "") + err := cmdFlags.Parse(args) + switch { + // This must precede all other checks. + case err != nil: + fmt.Fprintf(os.Stderr, "%s: %s\n%s", app.CmdName, err, app.Usage) + app.ExitValue = exitFailure + case app.HelpWanted: + fmt.Fprintf(os.Stderr, "%s: use 'help' not '-help' or '-h'\n", app.CmdName) + fmt.Fprint(os.Stderr, app.Usage) + app.ExitValue = exitFailure + case configFile == "": + configFile = defaultConfig + configIsDefault = true + } + return configFile, configIsDefault +} + +// Unmarshal reads a configuration file and returns a slice of Repo. +func (app *App) Unmarshal(configFile string, configIsDefault bool) []Repo { + if app.NoOp() { + return nil + } + if configIsDefault { + configFile = filepath.Join(app.HomeDir, configFile) + } + blob, err := os.ReadFile(configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", app.CmdName, err) + app.ExitValue = exitFailure + return nil + } + repos := make([]Repo, 0, 20) + err = json.Unmarshal(blob, &repos) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", app.CmdName, err) + app.ExitValue = exitFailure + return nil + } + // Every repository must have a URL and a directory name. + return slices.DeleteFunc(repos, func(repo Repo) bool { + return repo.URL == "" || repo.Name == "" + }) +} + +// PrettyPath replaces a user's home directory with ~ in a string. +func (app *App) PrettyPath(s string) string { + return strings.Replace(s, app.HomeDir, "~", 1) +} diff --git a/internal/cli/clone.go b/internal/cli/clone.go new file mode 100644 index 0000000..72f1119 --- /dev/null +++ b/internal/cli/clone.go @@ -0,0 +1,75 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Clone runs git repote update on a group of repositories. +func (app *App) Clone(repos []Repo) { + if app.NoOp() { + return + } + err := os.MkdirAll(filepath.Join(app.HomeDir, defaultStorage), os.ModePerm) + if err != nil { + app.ExitValue = exitFailure + return + } + ch := make(chan result) + for _, repo := range repos { + go app.clone(repo, ch) + } + for range repos { + res := <-ch + res.publish(app.QuietWanted) + } +} + +func (app *App) clone(repo Repo, ch chan<- result) { + // Normally, it is a bad idea to check whether a directory exists + // before trying an operation. However, this case is an exception. + // git clone --mirror /path/to/existing/repo.git will fail with an + // error, but for the purpose of this app, there is no error. + // If a directory with the repo's name exists, I simply want to send + // a result saying that the repo exists. + repoPath := filepath.Join(app.HomeDir, defaultStorage, repo.Name) + storagePath := filepath.Join(app.HomeDir, defaultStorage) + if _, err := os.Stat(repoPath); err == nil { + prettyPath := app.PrettyPath(storagePath) + ch <- result{ + isErr: false, + msg: fmt.Sprintf("%s: already present in %s", repo.Name, prettyPath), + } + return + } + args := []string{"clone", "--mirror", repo.URL, repo.Name} + cmd := exec.Command("git", args...) + noGitPrompt := "GIT_TERMINAL_PROMPT=0" + env := append(os.Environ(), noGitPrompt) + cmd.Env = env + cmd.Dir = filepath.Join(app.HomeDir, defaultStorage) + err := cmd.Run() + if err != nil { + app.ExitValue = exitFailure + ch <- result{ + isErr: true, + msg: fmt.Sprintf("%s: %s", repo.Name, err), + } + return + } + ch <- result{ + isErr: false, + msg: fmt.Sprintf("%s: successfully cloned", repo.Name), + } +} + +// CmdClone clones requested repos locally for mirroring. +func CmdClone(args []string) int { + app := NewApp(cloneUsage) + configFile, configIsDefault := app.Flags(args) + repos := app.Unmarshal(configFile, configIsDefault) + app.Clone(repos) + return app.ExitValue +} diff --git a/internal/cli/gitmirror.go b/internal/cli/gitmirror.go new file mode 100644 index 0000000..4d78a87 --- /dev/null +++ b/internal/cli/gitmirror.go @@ -0,0 +1,40 @@ +package cli + +import ( + "fmt" + "os" +) + +const ( + gitmirrorName = "gitmirror" + gitmirrorVersion = "v0.9.0" +) + +// CmdWrapper turns, e.g., `gitmirror update -q` into `gitmirror-update -q`. +func CmdGitmirror(args []string) int { + if len(args) < 1 { + fmt.Fprint(os.Stderr, gitmirrorUsage) + return exitFailure + } + // Give the user a hand if they try `gitmirror --help update`. + if args[0] == "--help" || args[0] == "-help" || args[0] == "-h" { + args[0] = "help" + } + var exitValue int + switch args[0] { + case "clone": + exitValue = CmdClone(args[1:]) + case "update", "up": + exitValue = CmdUpdate(args[1:]) + case "version": + exitValue = CmdVersion(args[1:]) + case "help": + // TODO: write the help in Asciidoc? + exitValue = CmdHelp(args[1:]) + default: + fmt.Fprintf(os.Stderr, "gitmirror: unrecognized subcommand: \"%s\"\n", args[0]) + fmt.Fprint(os.Stderr, gitmirrorUsage) + exitValue = exitFailure + } + return exitValue +} diff --git a/internal/cli/help.go b/internal/cli/help.go new file mode 100644 index 0000000..0e66d87 --- /dev/null +++ b/internal/cli/help.go @@ -0,0 +1,46 @@ +package cli + +import ( + "fmt" + "os" +) + +// Help displays the help message for a command. +func (app *App) Help(args []string) { + if app.NoOp() { + return + } + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "%s help: no subcommand given\n", gitmirrorName) + fmt.Fprint(os.Stderr, helpUsage) + app.ExitValue = exitFailure + return + } + if len(args) > 1 { + fmt.Fprintf(os.Stderr, "%s help: too many arguments: %+v\n", gitmirrorName, args) + fmt.Fprint(os.Stderr, helpUsage) + app.ExitValue = exitFailure + return + } + switch args[0] { + case "clone": + fmt.Fprint(os.Stdout, cloneUsage) + case "up", "update": + fmt.Fprint(os.Stdout, updateUsage) + case "help": + fmt.Fprint(os.Stdout, helpUsage) + case "version": + fmt.Fprint(os.Stdout, versionUsage) + default: + fmt.Fprintf(os.Stderr, "%s help: unrecognized subcommand: \"%s\"\n", gitmirrorName, args[0]) + fmt.Fprint(os.Stderr, helpUsage) + app.ExitValue = exitFailure + } +} + +// CmdHelp clones requested repos locally for mirroring. +func CmdHelp(args []string) int { + app := NewApp(helpUsage) + app.Help(args) + return app.ExitValue +} diff --git a/internal/cli/result.go b/internal/cli/result.go new file mode 100644 index 0000000..710355e --- /dev/null +++ b/internal/cli/result.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + "os" +) + +type result struct { + msg string + isErr bool +} + +func (r result) publish(quiet bool) { + if r.isErr { + fmt.Fprintln(os.Stderr, r.msg) + return + } + if quiet { + return + } + fmt.Fprintln(os.Stdout, r.msg) +} diff --git a/cli/testdata/backups.json b/internal/cli/testdata/backups.json similarity index 100% rename from cli/testdata/backups.json rename to internal/cli/testdata/backups.json diff --git a/cli/testdata/repo-checks.json b/internal/cli/testdata/repo-checks.json similarity index 100% rename from cli/testdata/repo-checks.json rename to internal/cli/testdata/repo-checks.json diff --git a/cli/unmarshal_test.go b/internal/cli/unmarshal_test.go similarity index 87% rename from cli/unmarshal_test.go rename to internal/cli/unmarshal_test.go index 91dd358..782856c 100644 --- a/cli/unmarshal_test.go +++ b/internal/cli/unmarshal_test.go @@ -4,12 +4,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/telemachus/gitmirror/cli" + "github.com/telemachus/gitmirror/internal/cli" ) const ( exitFailure = 1 exitSuccess = 0 + testUsage = "usage: gitmirror-test" ) func makeRepos() []cli.Repo { @@ -22,7 +23,7 @@ func makeRepos() []cli.Repo { func TestUnmarshalSuccess(t *testing.T) { expected := makeRepos() - app := cli.NewApp() + app := cli.NewApp(testUsage) actual := app.Unmarshal("testdata/backups.json", false) if app.ExitValue != exitSuccess { t.Fatal("app.ExitValue != exitSuccess") @@ -33,7 +34,7 @@ func TestUnmarshalSuccess(t *testing.T) { } func TestUnmarshalFailure(t *testing.T) { - app := cli.NewApp() + app := cli.NewApp(testUsage) app.Unmarshal("testdata/nope.json", false) if app.ExitValue != exitFailure { t.Errorf("app.Unmarshal(\"testdata/nope.json\") exit value: %d; expected %d", app.ExitValue, exitFailure) @@ -41,7 +42,7 @@ func TestUnmarshalFailure(t *testing.T) { } func TestUnmarshalRepoChecks(t *testing.T) { - app := cli.NewApp() + app := cli.NewApp(testUsage) actual := app.Unmarshal("testdata/repo-checks.json", false) if len(actual) != 0 { t.Errorf("app.Unmarshal(\"testdata/repo-checks.json\") expected len(repos) = 0; actual: %d", len(actual)) diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..5800020 --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/telemachus/gitmirror/internal/git" +) + +// Update runs git repote update on a group of repositories. +func (app *App) Update(repos []Repo) { + if app.NoOp() { + return + } + ch := make(chan result) + for _, repo := range repos { + go app.update(repo, ch) + } + for range repos { + res := <-ch + res.publish(app.QuietWanted) + } +} + +func (app *App) update(repo Repo, ch chan<- result) { + repoDir := filepath.Join(app.HomeDir, defaultStorage, repo.Name) + fhBefore, err := git.NewFetchHead(filepath.Join(repoDir, "FETCH_HEAD")) + if err != nil { + ch <- result{ + isErr: true, + msg: fmt.Sprintf("%s: %s", repo.Name, err), + } + return + } + args := []string{"remote", "update"} + cmd := exec.Command("git", args...) + noGitPrompt := "GIT_TERMINAL_PROMPT=0" + env := append(os.Environ(), noGitPrompt) + cmd.Env = env + cmd.Dir = repoDir + err = cmd.Run() + if err != nil { + ch <- result{ + isErr: true, + msg: fmt.Sprintf("%s: %s", repo.Name, err), + } + return + } + fhAfter, err := git.NewFetchHead(filepath.Join(repoDir, "FETCH_HEAD")) + if err != nil { + ch <- result{ + isErr: true, + msg: fmt.Sprintf("%s: %s", repo.Name, err), + } + return + } + if fhBefore.Equals(fhAfter) { + ch <- result{ + isErr: false, + msg: fmt.Sprintf("%s: already up-to-date", repo.Name), + } + return + } + ch <- result{ + isErr: false, + msg: fmt.Sprintf("%s: updated", repo.Name), + } +} + +// CmdUpdate runs `git remote update` on repos listed in a config file. +func CmdUpdate(args []string) int { + app := NewApp(updateUsage) + configFile, configIsDefault := app.Flags(args) + repos := app.Unmarshal(configFile, configIsDefault) + app.Update(repos) + return app.ExitValue +} diff --git a/internal/cli/usage.go b/internal/cli/usage.go new file mode 100644 index 0000000..ebf9f93 --- /dev/null +++ b/internal/cli/usage.go @@ -0,0 +1,55 @@ +package cli + +import "github.com/MakeNowJust/heredoc" + +var gitmirrorUsage = heredoc.Docf(` + Usage: gitmirror [options] + + Back up git repositories using git itself. + + Subcommands: + clone Clone repositories to mirror + update|up Update mirrored repositories + help Show detailed information about a subcommand + version Show version + + Run %[1]sgitmirror help %[1]s for more information about a subcommand. +`, "`") + +var helpUsage = heredoc.Docf(` + Usage: gitmirror help + + %[1]sgitmirror help%[1]s displays detailed information about a subcommand. + + Subcommands: + clone Clone repositories to mirror + update|up Update mirrored repositories + help Show detailed information about a subcommand + version Show version +`, "`") + +var cloneUsage = heredoc.Docf( + `Usage: gitmirror clone [-config FILENAME] + + %[1]sgitmirror clone%[1]s runs %[1]sgit clone --mirror%[1]s on repos in a configuration file. + + Options: + -config/-c FILENAME Specify configuration file (default ~/.gitmirror.json) + -quiet/-q Suppress output unless an error occurs +`, "`") + +var updateUsage = heredoc.Docf( + `Usage: gitmirror update|up [-config FILENAME] + + %[1]sgitmirror update%[1]s runs %[1]sgit remote update%[1]s on repos in a configuration file. + + Options: + -config/-c FILENAME Specify configuration file (default ~/.gitmirror.json) + -quiet/-q Suppress output unless an error occurs +`, "`") + +var versionUsage = heredoc.Docf(` + Usage: gitmirror version + + %[1]sgitmirror version%[1]s displays version information for gitmirror. +`, "`") diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..cd879d0 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,26 @@ +package cli + +import ( + "fmt" + "os" +) + +// Version displays gitmirror's version. +func (app *App) Version(args []string) { + if app.NoOp() { + return + } + if len(args) != 0 { + fmt.Fprintf(os.Stderr, "%s version: no arguments accepted\n", gitmirrorName) + fmt.Fprint(os.Stderr, versionUsage) + app.ExitValue = exitFailure + return + } + fmt.Fprintf(os.Stdout, "%s: %s\n", gitmirrorName, gitmirrorVersion) +} + +func CmdVersion(args []string) int { + app := NewApp(gitmirrorUsage) + app.Version(args) + return app.ExitValue +} diff --git a/internal/git/fetchhead.go b/internal/git/fetchhead.go new file mode 100644 index 0000000..665e041 --- /dev/null +++ b/internal/git/fetchhead.go @@ -0,0 +1,20 @@ +package git + +import ( + "bytes" + "os" +) + +type FetchHead []byte + +func NewFetchHead(f string) (FetchHead, error) { + fh, err := os.ReadFile(f) + if err != nil { + return nil, err + } + return FetchHead(fh), nil +} + +func (fh FetchHead) Equals(fhOther FetchHead) bool { + return bytes.Equal(fh, fhOther) +} diff --git a/internal/git/fetchhead_test.go b/internal/git/fetchhead_test.go new file mode 100644 index 0000000..dea9929 --- /dev/null +++ b/internal/git/fetchhead_test.go @@ -0,0 +1,54 @@ +package git_test + +import ( + "testing" + + "github.com/telemachus/gitmirror/internal/git" +) + +func TestFetchHeadEquality(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + fhBefore string + fhAfter string + expected bool + }{ + "original should equal identical": { + fhBefore: "testdata/originalFetchHead", + fhAfter: "testdata/identicalFetchHead", + expected: true, + }, + "original should not equal different": { + fhBefore: "testdata/originalFetchHead", + fhAfter: "testdata/differentFetchHead", + expected: false, + }, + "original should not equal longer": { + fhBefore: "testdata/originalFetchHead", + fhAfter: "testdata/longerFetchHead", + expected: false, + }, + "original should not equal shorter": { + fhBefore: "testdata/originalFetchHead", + fhAfter: "testdata/shorterFetchHead", + expected: false, + }, + } + for msg, tc := range testCases { + t.Run(msg, func(t *testing.T) { + t.Parallel() + fhBefore, err := git.NewFetchHead(tc.fhBefore) + if err != nil { + t.Fatalf("%s: %s", tc.fhBefore, err) + } + fhAfter, err := git.NewFetchHead(tc.fhAfter) + if err != nil { + t.Fatalf("%s: %s", tc.fhAfter, err) + } + got := fhBefore.Equals(fhAfter) + if got != tc.expected { + t.Errorf("fhBefore.Equals(fhAfter) = %v; want %v", got, tc.expected) + } + }) + } +} diff --git a/internal/git/testdata/differentFetchHead b/internal/git/testdata/differentFetchHead new file mode 100644 index 0000000..de5a5a5 --- /dev/null +++ b/internal/git/testdata/differentFetchHead @@ -0,0 +1,4 @@ +089293721eb4f586907a17a18783fee1eae2f445 not-for-merge branch 'bad-and-feel-bad' of https://github.com/owner/repo +fc558a102bc00e11580aef6033692f92d964a638 not-for-merge branch 'clone-mirror' of https://github.com/owner/repo +aae458c89dd2c7267a88d84fe4bf1a71df274e33 not-for-merge branch 'main' of https://github.com/owner/repo +c2647b449c1bdf91109048fe0327d738b83da1e5 not-for-merge branch 'subcommands' of https://github.com/owner/repo diff --git a/internal/git/testdata/identicalFetchHead b/internal/git/testdata/identicalFetchHead new file mode 100644 index 0000000..65de96f --- /dev/null +++ b/internal/git/testdata/identicalFetchHead @@ -0,0 +1,4 @@ +089293721eb4f586907a17a18783fee1eae2f445 not-for-merge branch 'bad-and-feel-bad' of https://github.com/owner/repo +fc558a102bc00e11580aef6033692f92d964a638 not-for-merge branch 'clone-mirror' of https://github.com/owner/repo +aae258c89dd2c7267a88d84fe4bf1a71df274e33 not-for-merge branch 'main' of https://github.com/owner/repo +c2647b449c1bdf91109048fe0327d738b83da1e5 not-for-merge branch 'subcommands' of https://github.com/owner/repo diff --git a/internal/git/testdata/longerFetchHead b/internal/git/testdata/longerFetchHead new file mode 100644 index 0000000..f8cdaf7 --- /dev/null +++ b/internal/git/testdata/longerFetchHead @@ -0,0 +1,5 @@ +089293721eb4f586907a17a18783fee1eae2f445 not-for-merge branch 'bad-and-feel-bad' of https://github.com/owner/repo +fc558a102bc00e11580aef6033692f92d964a638 not-for-merge branch 'clone-mirror' of https://github.com/owner/repo +aae258c89dd2c7267a88d84fe4bf1a71df274e33 not-for-merge branch 'main' of https://github.com/owner/repo +497c6dbe51ac3adf1291aed2b9d6ec9de74a72e4 not-for-merge branch 'multiple-commands' of https://github.com/owner/repo +c2647b449c1bdf91109048fe0327d738b83da1e5 not-for-merge branch 'subcommands' of https://github.com/owner/repo diff --git a/internal/git/testdata/originalFetchHead b/internal/git/testdata/originalFetchHead new file mode 100644 index 0000000..65de96f --- /dev/null +++ b/internal/git/testdata/originalFetchHead @@ -0,0 +1,4 @@ +089293721eb4f586907a17a18783fee1eae2f445 not-for-merge branch 'bad-and-feel-bad' of https://github.com/owner/repo +fc558a102bc00e11580aef6033692f92d964a638 not-for-merge branch 'clone-mirror' of https://github.com/owner/repo +aae258c89dd2c7267a88d84fe4bf1a71df274e33 not-for-merge branch 'main' of https://github.com/owner/repo +c2647b449c1bdf91109048fe0327d738b83da1e5 not-for-merge branch 'subcommands' of https://github.com/owner/repo diff --git a/internal/git/testdata/shorterFetchHead b/internal/git/testdata/shorterFetchHead new file mode 100644 index 0000000..36bd5ce --- /dev/null +++ b/internal/git/testdata/shorterFetchHead @@ -0,0 +1,3 @@ +089293721eb4f586907a17a18783fee1eae2f445 not-for-merge branch 'bad-and-feel-bad' of https://github.com/owner/repo +fc558a102bc00e11580aef6033692f92d964a638 not-for-merge branch 'clone-mirror' of https://github.com/owner/repo +aae258c89dd2c7267a88d84fe4bf1a71df274e33 not-for-merge branch 'main' of https://github.com/owner/repo diff --git a/main.go b/main.go deleted file mode 100644 index ad40eac..0000000 --- a/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "os" - - "github.com/telemachus/gitmirror/cli" -) - -func main() { - os.Exit(cli.Run(os.Args[1:])) -}