Skip to content

Commit

Permalink
feat: update to use optionparser; add linters
Browse files Browse the repository at this point in the history
1. This version uses optionparser to handle command line parsing.
2. I added revive and staticcheck separately from golangci.  When I use
   these two linters with golangci, the results seem to differ from
   running them alone.  I removed staticcheck from golangci—I was
   already not using revive with golangci—and I added individual make
   target for staticcheck, revive, and golangci as well as a lint target
   that calls all three.  I should probably think more about all of
   this.
  • Loading branch information
telemachus committed Dec 9, 2024
1 parent d6ffc0c commit 34d4edd
Show file tree
Hide file tree
Showing 27 changed files with 955 additions and 369 deletions.
5 changes: 4 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ linters:
- nakedret
- prealloc
- predeclared
- staticcheck
- stylecheck
- thelper
- typecheck
Expand All @@ -82,7 +81,11 @@ linters:
- whitespace

run:
concurrency: 8
issues-exit-code: 1
timeout: 30m
tests: true
allow-parallel-runners: false

issues:
exclude-dirs:
Expand Down
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@ fmt:
golangci-lint run --disable-all --no-config -Egofumpt --fix

lint: fmt
staticcheck ./...
revive -config revive.toml ./...
golangci-lint run

golangci: fmt
golangci-lint run

staticcheck: fmt
staticcheck ./...

revive: fmt
revive -config revive.toml -exclude internal/optionparser ./...

test:
go test -shuffle on github.com/telemachus/gitmirror/internal/cli
go test -shuffle on github.com/telemachus/gitmirror/internal/git
Expand Down
17 changes: 17 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# TODO

+ Improve the json in the configuration file. First, use a whole json document
rather than just an array. (This will require me to put the `repos` array as
the value of a `repos` key.) Second, add a `storage` key in order to let
users specify where they want the repos to be placed.
+ Currently, I add `GIT_TERMINAL_PROMPT=0` to the environment of the git command
that I call with `os.Exec`. This works for me, but others may want git to
prompt them for their username and password. I should make this configurable,
maybe in the configuration file, maybe on the command line, and maybe both.
(This shows up in clone.go and in update.go.)
+ The clone.go and update.go files share a lot of structure and code. Maybe
I can DRY up these two?
+ Investigate linting more. I probably don't need all the linting options
I have now, and I should simplify the Makefile. I should also study the
options for each linter more so that I make sure to use them as well as
possible.
11 changes: 0 additions & 11 deletions TODO.txt

This file was deleted.

3 changes: 2 additions & 1 deletion cmd/gitmirror/main.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// gitmirror uses git commands to backup git repositories.
package main

import (
Expand All @@ -7,5 +8,5 @@ import (
)

func main() {
os.Exit(cli.CmdGitmirror(os.Args[1:]))
os.Exit(cli.Gitmirror(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=
153 changes: 62 additions & 91 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
@@ -1,119 +1,90 @@
// Package cli creates and runs a command line interface.
package cli

import (
"encoding/json"
"flag"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
)

const (
defaultConfig = ".gitmirror.json"
defaultStorage = ".local/share/gitmirror"
exitSuccess = 0
exitFailure = 1
"github.com/telemachus/gitmirror/internal/optionparser"
)

// 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
config string
home string
storage string
exitVal int
quiet bool
}

// NoOp determines whether an App should bail out.
func (app *App) NoOp() bool {
return app.ExitValue != exitSuccess
}
func appFrom(args []string) (*appEnv, error) {
app := &appEnv{cmd: cmd, exitVal: exitSuccess}

// NewApp returns a new App pointer.
func NewApp(cmdUsage string) *App {
homeDir, err := os.UserHomeDir()
op := optionparser.NewOptionParser()
op.On("-c", "--config FILE", "Use FILE as config file (default ~/.gitmirror.json)", &app.config)
op.On("-q", "--quiet", "Print only error messages", &app.quiet)
op.On("--version", "Print version and exit", version)
op.Command("clone", "Clone git repositories using `git clone --mirror`")
op.Command("update|up", "Update git repositories using `git remote update`")
op.Command("sync", "Run both update and clone (in that order)")
op.Start = 24
op.Banner = "usage: gitmirror [options] <subcommand>"

// Do not continue if we cannot parse and validate arguments or get the
// user's home directory.
if err := op.ParseFrom(args); err != nil {
return nil, err
}
if err := validate(op.Extra); err != nil {
return nil, err
}
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", gitmirrorName, err)
return &App{ExitValue: exitFailure}
return nil, err
}
return &App{
CmdName: gitmirrorName,
Usage: gitmirrorUsage,
ExitValue: exitSuccess,
HomeDir: homeDir,

if app.config == "" {
app.config = filepath.Join(home, config)
}
app.storage = filepath.Join(home, storage)
app.subCmd = op.Extra[0]

return app, nil
}

// Repo stores information about a git repository.
type Repo struct {
URL string
Name string
func (app *appEnv) noOp() bool {
return app.exitVal != exitSuccess
}

// 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
func (app *appEnv) prettyPath(s string) string {
return strings.Replace(s, app.home, "~", 1)
}

// 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
func validate(extra []string) error {
if len(extra) != 1 {
return errors.New("one (and only one) subcommand is required")
}
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

// The only recognized subcommands are clone, up(date), and sync.
recognized := map[string]struct{}{
"clone": {},
"update": {},
"up": {},
"sync": {},
}
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
if _, ok := recognized[extra[0]]; !ok {
return fmt.Errorf("unrecognized subcommand: %q", extra[0])
}
// Every repository must have a URL and a directory name.
return slices.DeleteFunc(repos, func(repo Repo) bool {
return repo.URL == "" || repo.Name == ""
})

return nil
}

// 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)
// Quick and dirty, but why be fancy in this case?
func version() {
fmt.Printf("%s %s\n", cmd, cmdVersion)
os.Exit(exitSuccess)
}
61 changes: 30 additions & 31 deletions internal/cli/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,69 +7,68 @@ import (
"path/filepath"
)

// Clone runs git repote update on a group of repositories.
func (app *App) Clone(repos []Repo) {
if app.NoOp() {
func subCmdClone(app *appEnv) {
rs := app.repos()
app.clone(rs)
}

func (app *appEnv) clone(rs []Repo) {
if app.noOp() {
return
}
err := os.MkdirAll(filepath.Join(app.HomeDir, defaultStorage), os.ModePerm)

err := os.MkdirAll(app.storage, os.ModePerm)
if err != nil {
app.ExitValue = exitFailure
fmt.Fprintf(os.Stderr, "%s %s: %s\n", app.cmd, app.subCmd, err)
app.exitVal = exitFailure
return
}

ch := make(chan result)
for _, repo := range repos {
go app.clone(repo, ch)
for _, r := range rs {
go app.cloneOne(r, ch)
}
for range repos {
for range rs {
res := <-ch
res.publish(app.QuietWanted)
res.publish(app.quiet)
}
}

func (app *App) clone(repo Repo, ch chan<- result) {
func (app *appEnv) cloneOne(r 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)
// If a directory with the repo's name exists, I simply want to skip
// that repo.
rPath := filepath.Join(app.storage, r.Name)
if _, err := os.Stat(rPath); err == nil {
ch <- result{
isErr: false,
msg: fmt.Sprintf("%s: already present in %s", repo.Name, prettyPath),
msg: fmt.Sprintf("%s: already present in %s", r.Name, app.prettyPath(app.storage)),
}
return
}
args := []string{"clone", "--mirror", repo.URL, repo.Name}

args := []string{"clone", "--mirror", r.URL, r.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)
cmd.Dir = app.storage

err := cmd.Run()
if err != nil {
app.ExitValue = exitFailure
app.exitVal = exitFailure
ch <- result{
isErr: true,
msg: fmt.Sprintf("%s: %s", repo.Name, err),
msg: fmt.Sprintf("%s %s: %s: %s", app.cmd, app.subCmd, r.Name, err),
}
return
}

ch <- result{
isErr: false,
msg: fmt.Sprintf("%s: successfully cloned", repo.Name),
msg: fmt.Sprintf("%s: cloned", r.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
}
Loading

0 comments on commit 34d4edd

Please sign in to comment.