Skip to content

Commit ae0f7b4

Browse files
authored
Merge pull request #36 from github/vdye/derived-route
Make `route` optional in `git-bundle-server init`
2 parents 7ea9882 + 088f4ac commit ae0f7b4

File tree

10 files changed

+220
-22
lines changed

10 files changed

+220
-22
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ being managed by the bundle server.
104104
server route to find the data for this repository. Otherwise, the route is
105105
inferred from `<url>` by removing the domain name. For example,
106106
`https://github.com/git-for-windows/git` is assigned the route
107-
`/git-for-windows/git`. Run `git-bundle-server update` to initialize bundle
107+
`git-for-windows/git`. Run `git-bundle-server update` to initialize bundle
108108
information. Configure the web server to recognize this repository at that
109109
route. Configure scheduler to run `git-bundle-server update-all` as
110110
necessary.

cmd/git-bundle-server/delete.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ data.`
3434

3535
func (d *deleteCmd) Run(ctx context.Context, args []string) error {
3636
parser := argparse.NewArgParser(d.logger, "git-bundle-server delete <route>")
37-
route := parser.PositionalString("route", "the route to delete")
37+
route := parser.PositionalString("route", "the route to delete", true)
3838
parser.Parse(ctx, args)
3939

4040
repoProvider := utils.GetDependency[core.RepositoryProvider](ctx, d.container)

cmd/git-bundle-server/init.go

+12-4
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,20 @@ should be hosted at '<route>'.`
3535
}
3636

3737
func (i *initCmd) Run(ctx context.Context, args []string) error {
38-
parser := argparse.NewArgParser(i.logger, "git-bundle-server init <url> <route>")
39-
url := parser.PositionalString("url", "the URL of a repository to clone")
40-
// TODO: allow parsing <route> out of <url>
41-
route := parser.PositionalString("route", "the route to host the specified repo")
38+
parser := argparse.NewArgParser(i.logger, "git-bundle-server init <url> [<route>]")
39+
url := parser.PositionalString("url", "the URL of a repository to clone", true)
40+
route := parser.PositionalString("route", "the route to host the specified repo", false)
4241
parser.Parse(ctx, args)
4342

43+
// Set route value, if needed
44+
if *route == "" {
45+
var ok bool
46+
*route, ok = core.GetRouteFromUrl(*url)
47+
if !ok {
48+
parser.Usage(ctx, "Cannot parse route from url '%s'; please specify an explicit route.", *url)
49+
}
50+
}
51+
4452
repoProvider := utils.GetDependency[core.RepositoryProvider](ctx, i.container)
4553
bundleProvider := utils.GetDependency[bundles.BundleProvider](ctx, i.container)
4654
gitHelper := utils.GetDependency[git.GitHelper](ctx, i.container)

cmd/git-bundle-server/start.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ specified '<route>'.`
3434

3535
func (s *startCmd) Run(ctx context.Context, args []string) error {
3636
parser := argparse.NewArgParser(s.logger, "git-bundle-server start <route>")
37-
route := parser.PositionalString("route", "the route for which bundles should be generated")
37+
route := parser.PositionalString("route", "the route for which bundles should be generated", true)
3838
parser.Parse(ctx, args)
3939

4040
repoProvider := utils.GetDependency[core.RepositoryProvider](ctx, s.container)

cmd/git-bundle-server/stop.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ specified '<route>'.`
3333

3434
func (s *stopCmd) Run(ctx context.Context, args []string) error {
3535
parser := argparse.NewArgParser(s.logger, "git-bundle-server stop <route>")
36-
route := parser.PositionalString("route", "the route for which bundles should stop being generated")
36+
route := parser.PositionalString("route", "the route for which bundles should stop being generated", true)
3737
parser.Parse(ctx, args)
3838

3939
repoProvider := utils.GetDependency[core.RepositoryProvider](ctx, s.container)

cmd/git-bundle-server/update.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ bundles, and update the bundle list.`
3636

3737
func (u *updateCmd) Run(ctx context.Context, args []string) error {
3838
parser := argparse.NewArgParser(u.logger, "git-bundle-server update <route>")
39-
route := parser.PositionalString("route", "the route to update")
39+
route := parser.PositionalString("route", "the route to update", true)
4040
parser.Parse(ctx, args)
4141

4242
repoProvider := utils.GetDependency[core.RepositoryProvider](ctx, u.container)

docs/man/git-bundle-server.adoc

+8-6
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,17 @@ the server.
5353

5454
== COMMANDS
5555

56-
*init* _url_ _route_::
56+
*init* _url_ [_route_]::
5757
Initialize a repository for which bundles should be served. The repository is
5858
cloned into a bare repo from _url_. A base bundle is created for the
59-
repository, served from _route_, and the man:cron[8] global bundle update
60-
schedule is started.
59+
repository and used to initialize the bundle list. If _route_ is specified,
60+
the bundle list will be served from that route; otherwise, the route is
61+
derived from the _url_. Finally, the man:cron[8] global bundle update schedule
62+
is started.
6163
+
62-
It is recommended that users specify an SSH (rather than HTTP) URL for the
63-
_url_ argument to avoid potentially error-causing authentication prompts
64-
while fetching during scheduled bundle updates.
64+
It is recommended that users specify an SSH (rather than HTTP) URL for the _url_
65+
argument to avoid potentially error-causing authentication prompts while
66+
fetching during scheduled bundle updates.
6567

6668
*start* _route_::
6769
Start computing bundles for the repository identified by _route_. If the

internal/argparse/argparse.go

+44-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const usageExitCode int = 2
1515
type positionalArg struct {
1616
name string
1717
description string
18+
required bool
1819
value interface{}
1920
}
2021

@@ -60,7 +61,7 @@ func NewArgParser(logger log.TraceLogger, usageString string) *argParser {
6061
fmt.Fprint(out, "\n")
6162
}
6263

63-
// Print subcommands (if any)
64+
// Print subcommands or positional args (if any)
6465
if len(a.subcommands) > 0 {
6566
if a.isTopLevel {
6667
fmt.Fprintln(out, "Commands:")
@@ -69,6 +70,10 @@ func NewArgParser(logger log.TraceLogger, usageString string) *argParser {
6970
}
7071
a.printSubcommands()
7172
fmt.Fprint(out, "\n")
73+
} else if len(a.positionalArgs) > 0 {
74+
fmt.Fprintln(out, "Positional arguments:")
75+
a.printPositionalArgs()
76+
fmt.Fprint(out, "\n")
7277
}
7378
}
7479

@@ -93,31 +98,48 @@ func (a *argParser) Subcommand(subcommand Subcommand) {
9398
a.subcommands[subcommand.Name()] = subcommand
9499
}
95100

96-
func (a *argParser) PositionalStringVar(name string, description string, arg *string) {
101+
func (a *argParser) printPositionalArgs() {
102+
out := a.FlagSet.Output()
103+
for _, arg := range a.positionalArgs {
104+
optionalStr := ""
105+
if !arg.required {
106+
optionalStr = "(optional) "
107+
}
108+
fmt.Fprintf(out, " %s\n \t%s%s\n",
109+
arg.name,
110+
optionalStr,
111+
strings.ReplaceAll(strings.TrimSpace(arg.description), "\n", "\n \t"),
112+
)
113+
}
114+
}
115+
116+
func (a *argParser) PositionalStringVar(name string, description string, arg *string, required bool) {
97117
a.positionalArgs = append(a.positionalArgs, &positionalArg{
98118
name: name,
99119
description: description,
120+
required: required,
100121
value: arg,
101122
})
102123
}
103124

104-
func (a *argParser) PositionalString(name string, description string) *string {
125+
func (a *argParser) PositionalString(name string, description string, required bool) *string {
105126
arg := new(string)
106-
a.PositionalStringVar(name, description, arg)
127+
a.PositionalStringVar(name, description, arg, required)
107128
return arg
108129
}
109130

110-
func (a *argParser) PositionalListVar(name string, description string, arg *[]string) {
131+
func (a *argParser) PositionalListVar(name string, description string, arg *[]string, required bool) {
111132
a.positionalArgs = append(a.positionalArgs, &positionalArg{
112133
name: name,
113134
description: description,
135+
required: required,
114136
value: arg,
115137
})
116138
}
117139

118-
func (a *argParser) PositionalList(name string, description string) *[]string {
140+
func (a *argParser) PositionalList(name string, description string, required bool) *[]string {
119141
arg := &[]string{}
120-
a.PositionalListVar(name, description, arg)
142+
a.PositionalListVar(name, description, arg, required)
121143
return arg
122144
}
123145

@@ -131,7 +153,14 @@ func (a *argParser) Parse(ctx context.Context, args []string) {
131153
if len(a.subcommands) > 0 && len(a.positionalArgs) > 0 {
132154
panic("cannot mix subcommands and positional args")
133155
}
156+
157+
mustBeOptional := false
134158
for i, positionalArg := range a.positionalArgs {
159+
mustBeOptional = mustBeOptional || !positionalArg.required
160+
if mustBeOptional && positionalArg.required {
161+
panic("cannot have required args after optional args")
162+
}
163+
135164
if i < len(a.positionalArgs)-1 {
136165
// Only the last positional arg can be a list
137166
_, isList := positionalArg.value.(*[]string)
@@ -165,6 +194,14 @@ func (a *argParser) Parse(ctx context.Context, args []string) {
165194
} else {
166195
// Handle positional args
167196
for _, arg := range a.positionalArgs {
197+
if a.NArg() == 0 {
198+
if arg.required {
199+
a.Usage(ctx, "No value specified for required argument '%s'", arg.name)
200+
} else {
201+
break
202+
}
203+
}
204+
168205
// First, try single string case
169206
sPtr, isStr := arg.value.(*string)
170207
if isStr {

internal/core/funcs.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package core
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// Standalone helper functions for core (repo, cron, etc.) functionality.
9+
10+
func GetRouteFromUrl(url string) (string, bool) {
11+
matchers := []*regexp.Regexp{
12+
// SSH, matches <username>@<domain>:<route>[.git]
13+
regexp.MustCompile(`^[\w-]+@[\w\.-]+:([\w\.-]+/[\w\.-]+)/*$`),
14+
15+
// HTTP(S), matches http[s]://<domain>/<route>[.git]
16+
regexp.MustCompile(`^(?i:http[s]?)://[\w\.-]+/([\w\.-]+/[\w\.-]+)/*$`),
17+
18+
// Filesystem, matches file://[<path>/]<route>[.git]
19+
regexp.MustCompile(`^(?i:file)://[\w\.-/ ]*/([\w\.-]+/[\w\.-]+)/*$`),
20+
}
21+
22+
for _, matcher := range matchers {
23+
if groups := matcher.FindStringSubmatch(url); groups != nil {
24+
return strings.TrimSuffix(groups[1], ".git"), true
25+
}
26+
}
27+
28+
return "", false
29+
}

internal/core/funcs_test.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package core_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/github/git-bundle-server/internal/core"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
var urlToRouteTests = []struct {
12+
url string
13+
expectedRoute string
14+
expectedMatch bool
15+
}{
16+
// SSH tests
17+
{
18+
"[email protected]:git/git.git",
19+
"git/git",
20+
true,
21+
},
22+
{
23+
"[email protected]:BUNDLE_SERVER.io/git-bundle-server",
24+
"BUNDLE_SERVER.io/git-bundle-server",
25+
true,
26+
},
27+
{
28+
"[email protected]:imFineWith/trailingSlashes//",
29+
"imFineWith/trailingSlashes",
30+
true,
31+
},
32+
{
33+
"test@mydomain:deeper/toodeep/cannotmatch",
34+
"",
35+
false,
36+
},
37+
{
38+
"[email protected]:imFineWith/trailingSlashes/",
39+
"imFineWith/trailingSlashes",
40+
true,
41+
},
42+
{
43+
"[email protected]:tooshallow",
44+
"",
45+
false,
46+
},
47+
48+
// HTTP(S) tests
49+
{
50+
"hTTp://www.mysite.net/org/repo.git/",
51+
"org/repo",
52+
true,
53+
},
54+
{
55+
"https://domain.test/clone/me",
56+
"clone/me",
57+
true,
58+
},
59+
{
60+
"https://all.my.repos/having-some_fun/with.valid_ch4racters",
61+
"having-some_fun/with.valid_ch4racters",
62+
true,
63+
},
64+
{
65+
"http://completely.normal.site/with/invalid/repo",
66+
"",
67+
false,
68+
},
69+
{
70+
"HTTPS://SCREAM/INTOTHEVOID",
71+
"",
72+
false,
73+
},
74+
75+
// Filesystem tests
76+
{
77+
"file:///root/path/to/a/repo.git",
78+
"a/repo",
79+
true,
80+
},
81+
{
82+
"FILE://RELATIVE/to/me",
83+
"to/me",
84+
true,
85+
},
86+
{
87+
"fIlE://spaces are allowed/in/path/to/repo",
88+
"to/repo",
89+
true,
90+
},
91+
{
92+
"fIlE:///butspaces/are/NOT/allowed in route",
93+
"",
94+
false,
95+
},
96+
{
97+
"file://somepathsaretooshort",
98+
"",
99+
false,
100+
},
101+
}
102+
103+
func TestGetRouteFromUrl(t *testing.T) {
104+
for _, tt := range urlToRouteTests {
105+
var title string
106+
if tt.expectedMatch {
107+
title = fmt.Sprintf("%s => %s", tt.url, tt.expectedRoute)
108+
} else {
109+
title = fmt.Sprintf("%s (no match)", tt.url)
110+
}
111+
112+
t.Run(title, func(t *testing.T) {
113+
route, isMatched := core.GetRouteFromUrl(tt.url)
114+
if tt.expectedMatch {
115+
assert.True(t, isMatched)
116+
assert.Equal(t, tt.expectedRoute, route)
117+
} else {
118+
assert.False(t, isMatched, "Expected no match, got route %s", route)
119+
}
120+
})
121+
}
122+
}

0 commit comments

Comments
 (0)