Skip to content

Commit

Permalink
feat: why not change everything?
Browse files Browse the repository at this point in the history
+ Use optionparser instead of flag
+ Remove `help` and `version` commands; make those flags only
+ Add `sync` subcommand: it runs update and clone in that order since
  (presumably) you don't need to update repos that you have just cloned
  • Loading branch information
telemachus committed Dec 4, 2024
1 parent d6ffc0c commit 3308afa
Show file tree
Hide file tree
Showing 19 changed files with 871 additions and 346 deletions.
2 changes: 1 addition & 1 deletion cmd/gitmirror/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import (
)

func main() {
os.Exit(cli.CmdGitmirror(os.Args[1:]))
os.Exit(cli.CmdGitmirror(os.Args))
}
7 changes: 2 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
module github.com/telemachus/gitmirror

go 1.23
go 1.23.4

require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/google/go-cmp v0.6.0
)
require github.com/google/go-cmp v0.6.0
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
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=
124 changes: 26 additions & 98 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
@@ -1,119 +1,47 @@
// Package cli creates and runs a command line interface.
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
type appEnv struct {
cmd string
subCmd string
cfgFile string
homeDir string
storageDir string
exitValue int
quiet bool
}

// NoOp determines whether an App should bail out.
func (app *App) NoOp() bool {
return app.ExitValue != exitSuccess
func (app *appEnv) noOp() bool {
return app.exitValue != exitSuccess
}

// NewApp returns a new App pointer.
func NewApp(cmdUsage string) *App {
func newAppEnv(cfg *cfg) *appEnv {
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
fmt.Fprintf(os.Stderr, "%s %s: %s\n", cfg.cmd, cfg.subCmd, err)
return &appEnv{exitValue: exitFailure}
}
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
if cfg.defaultCfgFile {
cfg.cfgFile = filepath.Join(homeDir, cfg.cfgFile)
}
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
return &appEnv{
cmd: cfg.cmd,
subCmd: cfg.subCmd,
cfgFile: cfg.cfgFile,
homeDir: homeDir,
storageDir: filepath.Join(homeDir, ".local/share/gitmirror"),
quiet: cfg.quiet,
exitValue: exitSuccess,
}
// 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)
func (app *appEnv) prettyPath(s string) string {
return strings.Replace(s, app.homeDir, "~", 1)
}
47 changes: 23 additions & 24 deletions internal/cli/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,48 @@ import (
"path/filepath"
)

// Clone runs git repote update on a group of repositories.
func (app *App) Clone(repos []Repo) {
if app.NoOp() {
// TODO: make this configurable rather than hardwired.
const defaultStorage = ".local/share/gitmirror"

func subCmdClone(app *appEnv) {
repos := app.getRepos()
app.clone(repos)
}

func (app *appEnv) clone(repos []Repo) {
if app.noOp() {
return
}
err := os.MkdirAll(filepath.Join(app.HomeDir, defaultStorage), os.ModePerm)
err := os.MkdirAll(filepath.Join(app.homeDir, defaultStorage), os.ModePerm)
if err != nil {
app.ExitValue = exitFailure
fmt.Fprintf(os.Stderr, "%s %s: %s\n", app.cmd, app.subCmd, err)
app.exitValue = exitFailure
return
}
ch := make(chan result)
for _, repo := range repos {
go app.clone(repo, ch)
go app.cloneOne(repo, ch)
}
for range repos {
res := <-ch
res.publish(app.QuietWanted)
res.publish(app.quiet)
}
}

func (app *App) clone(repo Repo, ch chan<- result) {
func (app *appEnv) cloneOne(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)
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)
prettyPath := app.prettyPath(storagePath)
ch <- result{
isErr: false,
msg: fmt.Sprintf("%s: already present in %s", repo.Name, prettyPath),
msg: fmt.Sprintf("%s %s: %s already present in %s", app.cmd, app.subCmd, repo.Name, prettyPath),
}
return
}
Expand All @@ -49,13 +57,13 @@ func (app *App) clone(repo Repo, ch chan<- result) {
noGitPrompt := "GIT_TERMINAL_PROMPT=0"
env := append(os.Environ(), noGitPrompt)
cmd.Env = env
cmd.Dir = filepath.Join(app.HomeDir, defaultStorage)
cmd.Dir = filepath.Join(app.homeDir, defaultStorage)
err := cmd.Run()
if err != nil {
app.ExitValue = exitFailure
app.exitValue = exitFailure
ch <- result{
isErr: true,
msg: fmt.Sprintf("%s: %s", repo.Name, err),
msg: fmt.Sprintf("%s %s: %s: %s", app.cmd, app.subCmd, repo.Name, err),
}
return
}
Expand All @@ -64,12 +72,3 @@ func (app *App) clone(repo Repo, ch chan<- result) {
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
}
53 changes: 53 additions & 0 deletions internal/cli/getrepos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package cli

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func makeRepos() []Repo {
return []Repo{
{URL: "https://github.com/foo/foo.git", Name: "foo.git"},
{URL: "https://github.com/bar/bar.git", Name: "bar.git"},
{URL: "https://example.com/buzz/fizz.git", Name: "random.git"},
}
}

func makeNewAppEnv(cfgFile string) *appEnv {
cfg := &cfg{
cmd: "test",
subCmd: "testing",
cfgFile: cfgFile,
defaultCfgFile: false,
}
return newAppEnv(cfg)
}

func TestGetReposSuccess(t *testing.T) {
expected := makeRepos()
app := makeNewAppEnv("testdata/backups.json")
actual := app.getRepos()
if app.exitValue != exitSuccess {
t.Fatal("app.exitValue != exitSuccess")
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("app.getRepos(\"testdata/backups.json\") failure (-want +got)\n%s", diff)
}
}

func TestGetReposFailure(t *testing.T) {
app := makeNewAppEnv("testdata/nope.json")
app.getRepos()
if app.exitValue != exitFailure {
t.Errorf("app.getRepos(\"testdata/nope.json\") exit value: %d; expected %d", app.exitValue, exitFailure)
}
}

func TestRepoChecks(t *testing.T) {
app := makeNewAppEnv("testdata/repo-checks.json")
actual := app.getRepos()
if len(actual) != 0 {
t.Errorf("app.getRepos(\"testdata/repo-checks.json\") expected len(repos) = 0; actual: %d", len(actual))
}
}
Loading

0 comments on commit 3308afa

Please sign in to comment.