Skip to content

Commit a48ad8b

Browse files
committed
Make it possible to define reference groups via gitconfig
Add a way to define groups of reference names via gitconfig, using include/exclude rules like those supported on the command line. This allows gitconfig like [refgroup "normal"] include = refs/heads include = refs/tags excludeRegexp = refs/tags/release-.* and then `git sizer --refgroup=normal`, to include "normal" branches and tags but not release tags in the analysis.
1 parent 6b1b17d commit a48ad8b

File tree

4 files changed

+256
-22
lines changed

4 files changed

+256
-22
lines changed

git-sizer.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const Usage = `usage: git-sizer [OPTS]
6363
PREFIX (e.g., '--exclude=refs/changes')
6464
--exclude-regexp REGEXP don't process references matching the specified
6565
regular expression
66+
--refgroup=NAME process reference in group defined by gitconfig:
67+
'refgroup.NAME.include',
68+
'refgroup.NAME.includeRegexp',
69+
'refgroup.NAME.exclude', and
70+
'refgroup.NAME.excludeRegexp' as above.
6671
--show-refs show which refs are being included/excluded
6772
6873
Prefixes must match at a boundary; for example 'refs/foo' matches
@@ -178,6 +183,70 @@ func (v *filterValue) Type() string {
178183
}
179184
}
180185

186+
type filterGroupValue struct {
187+
filter *git.IncludeExcludeFilter
188+
repo *git.Repository
189+
}
190+
191+
func (v *filterGroupValue) Set(name string) error {
192+
// At this point, it is not yet certain that the command was run
193+
// inside a Git repository. If not, ignore this option (the
194+
// command will error out anyway).
195+
if v.repo == nil {
196+
fmt.Fprintf(
197+
os.Stderr,
198+
"warning: not in Git repository; ignoring '--refgroup' option.\n",
199+
)
200+
return nil
201+
}
202+
203+
config, err := v.repo.Config(fmt.Sprintf("refgroup.%s", name))
204+
if err != nil {
205+
return err
206+
}
207+
for _, entry := range config.Entries {
208+
switch entry.Key {
209+
case "include":
210+
v.filter.Include(git.PrefixFilter(entry.Value))
211+
case "includeregexp":
212+
filter, err := git.RegexpFilter(entry.Value)
213+
if err != nil {
214+
return fmt.Errorf(
215+
"invalid regular expression for 'refgroup.%s.%s': %w",
216+
name, entry.Key, err,
217+
)
218+
}
219+
v.filter.Include(filter)
220+
case "exclude":
221+
v.filter.Exclude(git.PrefixFilter(entry.Value))
222+
case "excluderegexp":
223+
filter, err := git.RegexpFilter(entry.Value)
224+
if err != nil {
225+
return fmt.Errorf(
226+
"invalid regular expression for 'refgroup.%s.%s': %w",
227+
name, entry.Key, err,
228+
)
229+
}
230+
v.filter.Exclude(filter)
231+
default:
232+
// Ignore unrecognized keys.
233+
}
234+
}
235+
return nil
236+
}
237+
238+
func (v *filterGroupValue) Get() interface{} {
239+
return nil
240+
}
241+
242+
func (v *filterGroupValue) String() string {
243+
return ""
244+
}
245+
246+
func (v *filterGroupValue) Type() string {
247+
return "name"
248+
}
249+
181250
func main() {
182251
err := mainImplementation(os.Args[1:])
183252
if err != nil {
@@ -197,6 +266,13 @@ func mainImplementation(args []string) error {
197266
var filter git.IncludeExcludeFilter
198267
var showRefs bool
199268

269+
// Try to open the repository, but it's not an error yet if this
270+
// fails, because the user might only be asking for `--help`.
271+
repo, repoErr := git.NewRepository(".")
272+
if repoErr == nil {
273+
defer repo.Close()
274+
}
275+
200276
flags := pflag.NewFlagSet("git-sizer", pflag.ContinueOnError)
201277
flags.Usage = func() {
202278
fmt.Print(Usage)
@@ -279,6 +355,11 @@ func mainImplementation(args []string) error {
279355
)
280356
flag.NoOptDefVal = "true"
281357

358+
flag = flags.VarPF(
359+
&filterGroupValue{&filter, repo}, "refgroup", "",
360+
"process references in refgroup defined by gitconfig",
361+
)
362+
282363
flags.VarP(
283364
sizes.NewThresholdFlagValue(&threshold, 0),
284365
"verbose", "v", "report all statistics, whether concerning or not",
@@ -359,11 +440,9 @@ func mainImplementation(args []string) error {
359440
return errors.New("excess arguments")
360441
}
361442

362-
repo, err := git.NewRepository(".")
363-
if err != nil {
364-
return fmt.Errorf("couldn't open Git repository: %s", err)
443+
if repoErr != nil {
444+
return fmt.Errorf("couldn't open Git repository: %s", repoErr)
365445
}
366-
defer repo.Close()
367446

368447
if jsonOutput {
369448
if !flags.Changed("json-version") {

git/gitconfig.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,92 @@ package git
22

33
import (
44
"bytes"
5+
"errors"
56
"fmt"
67
"strconv"
8+
"strings"
79
)
810

11+
type ConfigEntry struct {
12+
Key string
13+
Value string
14+
}
15+
16+
type Config struct {
17+
Entries []ConfigEntry
18+
}
19+
20+
// Config returns the entries from gitconfig. If `prefix` is provided,
21+
// then only include entries in that section, which must match the at
22+
// a component boundary (as defined by `configKeyMatchesPrefix()`),
23+
// and strip off the prefix in the keys that are returned.
24+
func (repo *Repository) Config(prefix string) (*Config, error) {
25+
cmd := repo.gitCommand("config", "--list", "-z")
26+
27+
out, err := cmd.Output()
28+
if err != nil {
29+
return nil, fmt.Errorf("reading git configuration: %w", err)
30+
}
31+
32+
var config Config
33+
34+
for len(out) > 0 {
35+
keyEnd := bytes.IndexByte(out, '\n')
36+
if keyEnd == -1 {
37+
return nil, errors.New("invalid output from 'git config'")
38+
}
39+
key := string(out[:keyEnd])
40+
out = out[keyEnd+1:]
41+
valueEnd := bytes.IndexByte(out, 0)
42+
if valueEnd == -1 {
43+
return nil, errors.New("invalid output from 'git config'")
44+
}
45+
value := string(out[:valueEnd])
46+
out = out[valueEnd+1:]
47+
48+
ok, rest := configKeyMatchesPrefix(key, prefix)
49+
if !ok {
50+
continue
51+
}
52+
53+
entry := ConfigEntry{
54+
Key: rest,
55+
Value: value,
56+
}
57+
config.Entries = append(config.Entries, entry)
58+
}
59+
60+
return &config, nil
61+
}
62+
63+
// configKeyMatchesPrefix checks whether `key` starts with `prefix` at
64+
// a component boundary (i.e., at a '.'). If yes, it returns `true`
65+
// and the part of the key after the prefix; e.g.:
66+
//
67+
// configKeyMatchesPrefix("foo.bar", "foo") → true, "bar"
68+
// configKeyMatchesPrefix("foo.bar", "foo.") → true, "bar"
69+
// configKeyMatchesPrefix("foo.bar", "foo.bar") → true, ""
70+
// configKeyMatchesPrefix("foo.bar", "foo.bar.") → false, ""
71+
func configKeyMatchesPrefix(key, prefix string) (bool, string) {
72+
if prefix == "" {
73+
return true, key
74+
}
75+
if !strings.HasPrefix(key, prefix) {
76+
return false, ""
77+
}
78+
79+
if prefix[len(prefix)-1] == '.' {
80+
return true, key[len(prefix):]
81+
}
82+
if len(key) == len(prefix) {
83+
return true, ""
84+
}
85+
if key[len(prefix)] == '.' {
86+
return true, key[len(prefix)+1:]
87+
}
88+
return false, ""
89+
}
90+
991
func (repo *Repository) ConfigStringDefault(key string, defaultValue string) (string, error) {
1092
cmd := repo.gitCommand(
1193
"config",

git/gitconfig_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestConfigKeyMatchesPrefix(t *testing.T) {
11+
for _, p := range []struct {
12+
key, prefix string
13+
expectedBool bool
14+
expectedString string
15+
}{
16+
{"foo.bar", "", true, "foo.bar"},
17+
{"foo.bar", "foo", true, "bar"},
18+
{"foo.bar", "foo.", true, "bar"},
19+
{"foo.bar", "foo.bar", true, ""},
20+
{"foo.bar", "foo.bar.", false, ""},
21+
{"foo.bar", "foo.bar.baz", false, ""},
22+
{"foo.bar", "foo.barbaz", false, ""},
23+
{"foo.bar.baz", "foo.bar", true, "baz"},
24+
{"foo.barbaz", "foo.bar", false, ""},
25+
{"foo.bar", "bar", false, ""},
26+
} {
27+
t.Run(
28+
fmt.Sprintf("TestConfigKeyMatchesPrefix(%q, %q)", p.key, p.prefix),
29+
func(t *testing.T) {
30+
ok, s := configKeyMatchesPrefix(p.key, p.prefix)
31+
assert.Equal(t, p.expectedBool, ok)
32+
assert.Equal(t, p.expectedString, s)
33+
},
34+
)
35+
}
36+
}

git_sizer_test.go

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,22 +193,22 @@ func TestRefSelections(t *testing.T) {
193193

194194
refname string
195195
}{
196-
// 1111111
197-
//01234567890123456
198-
{"+ + + + + + + +", "refs/barfoo"},
199-
{"+ + + + + + +++ ", "refs/foo"},
200-
{"+ + + + + + + +", "refs/foobar"},
201-
{"++ + + + +++ +", "refs/heads/foo"},
202-
{"++ + + + ++ +", "refs/heads/master"},
203-
{"+ + + ++ + ", "refs/notes/discussion"},
204-
{"+ + ++ + + ", "refs/remotes/origin/master"},
205-
{"+ + ++ + + + +", "refs/remotes/upstream/foo"},
206-
{"+ + ++ + + ", "refs/remotes/upstream/master"},
207-
{"+ + + + ++ ", "refs/stash"},
208-
{"+ ++ + + +++ +", "refs/tags/foolish"},
209-
{"+ ++ + + ++ +", "refs/tags/other"},
210-
{"+ ++ + + ++ + ", "refs/tags/release-1"},
211-
{"+ ++ + + ++ + ", "refs/tags/release-2"},
196+
// 111111111
197+
//0123456789012345678
198+
{"+ + + + + + + + +", "refs/barfoo"},
199+
{"+ + + + + + +++ ", "refs/foo"},
200+
{"+ + + + + + + + +", "refs/foobar"},
201+
{"++ + + + +++ +++", "refs/heads/foo"},
202+
{"++ + + + ++ +++", "refs/heads/master"},
203+
{"+ + + ++ + ", "refs/notes/discussion"},
204+
{"+ + ++ + + ", "refs/remotes/origin/master"},
205+
{"+ + ++ + + + + +", "refs/remotes/upstream/foo"},
206+
{"+ + ++ + + ", "refs/remotes/upstream/master"},
207+
{"+ + + + ++ ", "refs/stash"},
208+
{"+ ++ + + +++ + +", "refs/tags/foolish"},
209+
{"+ ++ + + ++ + +", "refs/tags/other"},
210+
{"+ ++ + + ++ + ", "refs/tags/release-1"},
211+
{"+ ++ + + ++ + ", "refs/tags/release-2"},
212212
}
213213

214214
// computeExpectations assembles and returns the results expected
@@ -269,8 +269,9 @@ func TestRefSelections(t *testing.T) {
269269
require.NoError(t, err)
270270

271271
for i, p := range []struct {
272-
name string
273-
args []string
272+
name string
273+
args []string
274+
config [][2]string
274275
}{
275276
{ // 0
276277
name: "no arguments",
@@ -346,10 +347,46 @@ func TestRefSelections(t *testing.T) {
346347
"--exclude-regexp", "refs/tags/release-.*",
347348
},
348349
},
350+
{ // 17
351+
name: "branches-refgroup",
352+
args: []string{"--refgroup=mygroup"},
353+
config: [][2]string{
354+
{"include", "refs/heads"},
355+
},
356+
},
357+
{ // 18
358+
name: "combination-refgroup",
359+
args: []string{"--refgroup=mygroup"},
360+
config: [][2]string{
361+
{"include", "refs/heads"},
362+
{"include", "refs/tags"},
363+
{"exclude", "refs/heads/foo"},
364+
{"includeRegexp", ".*foo.*"},
365+
{"exclude", "refs/foo"},
366+
{"excludeRegexp", "refs/tags/release-.*"},
367+
},
368+
},
349369
} {
350370
t.Run(
351371
p.name,
352372
func(t *testing.T) {
373+
if len(p.config) != 0 {
374+
for _, c := range p.config {
375+
cmd := gitCommand(
376+
t, path,
377+
"config", "--add", fmt.Sprintf("refgroup.mygroup.%s", c[0]), c[1],
378+
)
379+
err := cmd.Run()
380+
require.NoError(t, err)
381+
}
382+
defer func() {
383+
cmd := gitCommand(
384+
t, path, "config", "--remove-section", "refgroup.mygroup",
385+
)
386+
err := cmd.Run()
387+
require.NoError(t, err)
388+
}()
389+
}
353390
args := []string{"--show-refs", "--no-progress", "--json", "--json-version=2"}
354391
args = append(args, p.args...)
355392
cmd := exec.Command(executable, args...)

0 commit comments

Comments
 (0)