Skip to content

Commit feee6b7

Browse files
Rachel Dowavicamitsaha
authored andcommitted
refactor CLI to use urfave/cli v2 for subcommand routing
Replace flag.NewFlagSet + manual os.Args dispatch with a cli.App definition. This gives proper subcommand routing for init/validate, structured help output, and a clean foundation for future CLI expansion. - options.go: replace initConfig() with appFlags() + buildConfig(cCtx), using cCtx.IsSet() for config-file-then-CLI-flag precedence - main.go: define cli.App with Commands for init/validate and Action for the backup flow - config_file_test.go: add buildTestConfig() helper using urfave/cli - cli_test.go: read help from stdout (urfave/cli writes help to stdout) - Regenerate golden files for new help format
1 parent 9635339 commit feee6b7

8 files changed

Lines changed: 336 additions & 246 deletions

File tree

cli_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestCliUsage(t *testing.T) {
4545
t.Fatal(err)
4646
}
4747

48-
gotUsage := stderr.Bytes()
48+
gotUsage := stdout.Bytes()
4949

5050
expectedUsage, err := os.ReadFile(goldenFilepath)
5151
if err != nil {

config_file_test.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
11
package main
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"testing"
78

9+
"github.com/urfave/cli/v2"
810
"gopkg.in/yaml.v3"
911
)
1012

13+
// buildTestConfig creates a minimal cli.App with appFlags(), runs it with the
14+
// given args, and returns the appConfig produced by buildConfig.
15+
func buildTestConfig(args []string) (*appConfig, error) {
16+
var result *appConfig
17+
var buildErr error
18+
19+
app := &cli.App{
20+
Name: "gitbackup",
21+
Flags: appFlags(),
22+
Action: func(cCtx *cli.Context) error {
23+
result, buildErr = buildConfig(cCtx)
24+
return buildErr
25+
},
26+
}
27+
28+
// urfave/cli expects the program name as args[0]
29+
fullArgs := append([]string{"gitbackup"}, args...)
30+
if err := app.Run(fullArgs); err != nil {
31+
return nil, fmt.Errorf("app.Run: %w", err)
32+
}
33+
return result, buildErr
34+
}
35+
1136
func TestHandleInitConfig(t *testing.T) {
1237
tmpDir := t.TempDir()
1338
configPath := filepath.Join(tmpDir, defaultConfigFile)
@@ -127,8 +152,8 @@ func TestInitConfigWithConfigFile(t *testing.T) {
127152
os.Setenv("GITLAB_TOKEN", "testtoken")
128153
defer os.Unsetenv("GITLAB_TOKEN")
129154

130-
// initConfig with --config flag should use config file values
131-
c, err := initConfig([]string{"-config", configPath})
155+
// buildTestConfig with --config flag should use config file values
156+
c, err := buildTestConfig([]string{"-config", configPath})
132157
if err != nil {
133158
t.Fatalf("Expected no error, got: %v", err)
134159
}
@@ -160,7 +185,7 @@ func TestInitConfigCLIOverridesConfigFile(t *testing.T) {
160185
defer os.Unsetenv("GITLAB_TOKEN")
161186

162187
// CLI flag overrides service to github
163-
c, err := initConfig([]string{"-config", configPath, "-service", "github"})
188+
c, err := buildTestConfig([]string{"-config", configPath, "-service", "github"})
164189
if err != nil {
165190
t.Fatalf("Expected no error, got: %v", err)
166191
}
@@ -177,7 +202,7 @@ func TestInitConfigCLIOverridesConfigFile(t *testing.T) {
177202

178203
func TestInitConfigNoConfigFile(t *testing.T) {
179204
// No config file — should behave exactly as before
180-
c, err := initConfig([]string{"-service", "github"})
205+
c, err := buildTestConfig([]string{"-service", "github"})
181206
if err != nil {
182207
t.Fatalf("Expected no error, got: %v", err)
183208
}

go.mod

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@ require (
1515
golang.org/x/text v0.27.0 // indirect
1616
)
1717

18-
require codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
18+
require (
19+
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
20+
github.com/urfave/cli/v2 v2.27.7
21+
gopkg.in/yaml.v3 v3.0.1
22+
)
1923

2024
require (
2125
github.com/42wim/httpsig v1.2.3 // indirect
2226
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
27+
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
2328
github.com/danieljoos/wincred v1.2.2 // indirect
2429
github.com/davidmz/go-pageant v1.0.2 // indirect
2530
github.com/dvsekhvalnov/jose2go v1.8.0 // indirect
@@ -35,10 +40,11 @@ require (
3540
github.com/mattn/go-colorable v0.1.14 // indirect
3641
github.com/mitchellh/mapstructure v1.5.0 // indirect
3742
github.com/mtibben/percent v0.2.1 // indirect
43+
github.com/russross/blackfriday/v2 v2.1.0 // indirect
44+
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
3845
golang.org/x/crypto v0.40.0 // indirect
3946
golang.org/x/net v0.42.0 // indirect
4047
golang.org/x/sys v0.34.0 // indirect
4148
golang.org/x/term v0.33.0 // indirect
4249
golang.org/x/time v0.12.0 // indirect
43-
gopkg.in/yaml.v3 v3.0.1 // indirect
4450
)

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDH
1111
github.com/cli/oauth v1.2.2 h1:/qG/wok8jzu66tx7q+duGOIp4DT5P/ACXrdc33UoNUQ=
1212
github.com/cli/oauth v1.2.2/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
1313
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
14+
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
15+
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1416
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
1517
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
1618
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -74,14 +76,20 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
7476
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7577
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
7678
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
79+
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
80+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
7781
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
7882
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
7983
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
8084
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
8185
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
8286
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
87+
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
88+
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
8389
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
8490
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
91+
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
92+
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
8593
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
8694
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
8795
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=

main.go

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package main
22

33
import (
4-
"flag"
54
"fmt"
65
"log"
76
"os"
7+
8+
"github.com/urfave/cli/v2"
89
)
910

1011
// MaxConcurrentClones is the upper limit of the maximum number of
@@ -26,62 +27,66 @@ var knownServices = map[string]string{
2627
"forgejo": "codeberg.org",
2728
}
2829

29-
// parseSubcommandFlags parses --config and --help flags for a subcommand.
30-
func parseSubcommandFlags(name, description string, args []string) string {
31-
var configPath string
32-
fs := flag.NewFlagSet("gitbackup "+name, flag.ExitOnError)
33-
fs.StringVar(&configPath, "config", "", "Path to config file (default: OS config directory)")
34-
fs.Usage = func() {
35-
fmt.Fprintf(os.Stderr, "Usage: gitbackup %s [--config path]\n\n", name)
36-
fmt.Fprintf(os.Stderr, "%s\n\n", description)
37-
fs.PrintDefaults()
38-
}
39-
fs.Parse(args)
40-
return configPath
41-
}
42-
4330
func main() {
44-
45-
// Handle subcommands before flag parsing
46-
if len(os.Args) > 1 {
47-
switch os.Args[1] {
48-
case "init":
49-
configPath := parseSubcommandFlags("init", "Create a default gitbackup.yml configuration file.", os.Args[2:])
50-
if err := handleInitConfig(configPath); err != nil {
51-
log.Fatal(err)
31+
app := &cli.App{
32+
Name: "gitbackup",
33+
Usage: "Backup your Git repositories from GitHub, GitLab, Bitbucket, or Forgejo",
34+
Flags: appFlags(),
35+
Action: func(cCtx *cli.Context) error {
36+
c, err := buildConfig(cCtx)
37+
if err != nil {
38+
return err
5239
}
53-
return
54-
case "validate":
55-
configPath := parseSubcommandFlags("validate", "Validate the gitbackup.yml configuration file.", os.Args[2:])
56-
if err := handleValidateConfig(configPath); err != nil {
57-
log.Fatal(err)
40+
err = validateConfig(c)
41+
if err != nil {
42+
return err
5843
}
59-
return
60-
}
61-
}
62-
63-
c, err := initConfig(os.Args[1:])
64-
if err != nil {
65-
log.Fatal(err)
66-
}
67-
err = validateConfig(c)
68-
if err != nil {
69-
log.Fatal(err)
70-
}
7144

72-
client := newClient(c.service, c.gitHostURL)
73-
var executionErr error
45+
client := newClient(c.service, c.gitHostURL)
7446

75-
// TODO implement validation of options so that we don't
76-
// allow multiple operations at one go
77-
if c.githubListUserMigrations {
78-
handleGithubListUserMigrations(client, c)
79-
} else if c.githubCreateUserMigration {
80-
handleGithubCreateUserMigration(client, c)
81-
} else {
82-
executionErr = handleGitRepositoryClone(client, c)
47+
if c.githubListUserMigrations {
48+
handleGithubListUserMigrations(client, c)
49+
} else if c.githubCreateUserMigration {
50+
handleGithubCreateUserMigration(client, c)
51+
} else {
52+
if err := handleGitRepositoryClone(client, c); err != nil {
53+
return err
54+
}
55+
}
56+
return nil
57+
},
58+
Commands: []*cli.Command{
59+
{
60+
Name: "init",
61+
Usage: "Create a default gitbackup.yml configuration file",
62+
Flags: []cli.Flag{
63+
&cli.StringFlag{
64+
Name: "config",
65+
Usage: "Path to config file (default: OS config directory)",
66+
},
67+
},
68+
Action: func(cCtx *cli.Context) error {
69+
return handleInitConfig(cCtx.String("config"))
70+
},
71+
},
72+
{
73+
Name: "validate",
74+
Usage: "Validate the gitbackup.yml configuration file",
75+
Flags: []cli.Flag{
76+
&cli.StringFlag{
77+
Name: "config",
78+
Usage: "Path to config file (default: OS config directory)",
79+
},
80+
},
81+
Action: func(cCtx *cli.Context) error {
82+
return handleValidateConfig(cCtx.String("config"))
83+
},
84+
},
85+
},
8386
}
84-
if executionErr != nil {
85-
log.Fatal(executionErr)
87+
88+
if err := app.Run(os.Args); err != nil {
89+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
90+
log.Fatal(err)
8691
}
8792
}

0 commit comments

Comments
 (0)