Skip to content

Commit 4441f15

Browse files
committed
feat: add next-version command
1 parent cb7a503 commit 4441f15

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed

cmd/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type CLI struct {
5050

5151
// Commands:
5252
Lint LintCommand `kong:"cmd,default='',help='lint the commit messages in a git repository'"`
53+
NextVersion NextVersionCommand `kong:"cmd,help='get next version (lint is recommended prior to running this)'"`
5354
Version VersionCommand `kong:"cmd,help='show program version'"`
5455
}
5556

cmd/nextversion.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025 Tobias Dahlberg
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package cmd
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"io"
23+
"log/slog"
24+
"os"
25+
26+
"github.com/go-git/go-git/v5"
27+
"github.com/go-git/go-git/v5/plumbing"
28+
29+
"github.com/somebadcode/commit-tool/nextversion"
30+
)
31+
32+
type NextVersionCommand struct {
33+
Repository *git.Repository `kong:"arg,placeholder='path',default='.',help='repository to lint'"`
34+
Revision plumbing.Revision `kong:"arg,name='revision',aliases='rev',optional,default='HEAD',placeholder='REVISION',help='revision to start at'"`
35+
Output string `kong:"arg,type='path',default='-',help='where to output the next version'"`
36+
VSuffix bool `kong:"default='true',negatable,help='output with v-suffix, i.e. v1.3.2'"`
37+
WithPrerelease string `kong:"optional,help='add prerelease information to tag'"`
38+
WithMetadata string `kong:"optional,help='add metadata to tag'"`
39+
}
40+
41+
func (cmd *NextVersionCommand) Run(ctx context.Context, l *slog.Logger) error {
42+
var f io.WriteCloser
43+
if cmd.Output == "-" {
44+
f = os.Stdout
45+
} else {
46+
var err error
47+
f, err = os.OpenFile(cmd.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
48+
if err != nil {
49+
return fmt.Errorf("opening output file: %w", err)
50+
}
51+
52+
defer func() {
53+
_ = f.Close()
54+
}()
55+
}
56+
57+
next := nextversion.NextVersion{
58+
Repository: cmd.Repository,
59+
Revision: cmd.Revision,
60+
VSuffix: cmd.VSuffix,
61+
Prerelease: cmd.WithPrerelease,
62+
Metadata: cmd.WithMetadata,
63+
Writer: f,
64+
Logger: l,
65+
}
66+
67+
return next.Run(ctx)
68+
}

nextversion/nextversion.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright 2025 Tobias Dahlberg
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextversion
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"log/slog"
25+
"os"
26+
27+
"github.com/Masterminds/semver/v3"
28+
"github.com/go-git/go-git/v5"
29+
"github.com/go-git/go-git/v5/plumbing"
30+
"github.com/go-git/go-git/v5/plumbing/object"
31+
"github.com/go-git/go-git/v5/plumbing/storer"
32+
33+
"github.com/somebadcode/commit-tool/commitparser"
34+
)
35+
36+
var (
37+
ErrRepositoryRequired = errors.New("repository is required")
38+
)
39+
40+
type NextVersion struct {
41+
Repository *git.Repository
42+
Revision plumbing.Revision
43+
Writer io.Writer
44+
Logger *slog.Logger
45+
Prerelease string
46+
Metadata string
47+
VSuffix bool
48+
}
49+
50+
func (nv *NextVersion) Validate() error {
51+
if nv.Repository == nil {
52+
return ErrRepositoryRequired
53+
}
54+
55+
if nv.Revision == "" {
56+
nv.Revision = plumbing.Revision(plumbing.HEAD)
57+
}
58+
59+
if nv.Logger == nil {
60+
nv.Logger = slog.New(slog.DiscardHandler)
61+
}
62+
63+
if nv.Writer == nil {
64+
nv.Writer = os.Stdout
65+
}
66+
67+
return nil
68+
}
69+
70+
func (nv *NextVersion) Run(ctx context.Context) error {
71+
if err := nv.Validate(); err != nil {
72+
return err
73+
}
74+
75+
hash, err := nv.Repository.ResolveRevision(nv.Revision)
76+
if err != nil {
77+
return fmt.Errorf("failed to resolve revision %q: %w", nv.Revision, err)
78+
}
79+
80+
var iter object.CommitIter
81+
82+
iter, err = nv.Repository.Log(&git.LogOptions{
83+
From: *hash,
84+
Order: git.LogOrderBSF,
85+
})
86+
87+
if err != nil {
88+
return fmt.Errorf("could not iterate over commits: %w", err)
89+
}
90+
91+
var tags map[plumbing.Hash]*semver.Version
92+
93+
tags, err = findTags(ctx, nv.Repository)
94+
if err != nil {
95+
return fmt.Errorf("could not find tags: %w", err)
96+
}
97+
98+
if len(tags) > 0 && nv.Logger.Enabled(ctx, slog.LevelDebug) {
99+
attrs := make([]slog.Attr, len(tags))
100+
101+
for h, v := range tags {
102+
attrs = append(attrs, slog.String(v.String(), h.String()))
103+
}
104+
105+
nv.Logger.LogAttrs(ctx, slog.LevelDebug, "discovered tags",
106+
slog.Any("tags", slog.GroupValue(attrs...)),
107+
)
108+
}
109+
110+
var major, minor, patch bool
111+
112+
var version *semver.Version
113+
114+
err = iter.ForEach(func(commit *object.Commit) error {
115+
if ctx.Err() != nil {
116+
if nv.Logger.Enabled(ctx, slog.LevelDebug) {
117+
nv.Logger.LogAttrs(ctx, slog.LevelDebug, "cancelling finding tags",
118+
slog.String("hash", commit.Hash.String()),
119+
slog.String("cause", ctx.Err().Error()),
120+
)
121+
}
122+
123+
return ctx.Err()
124+
}
125+
126+
if version = tags[commit.Hash]; version != nil {
127+
return storer.ErrStop
128+
}
129+
130+
var msg commitparser.CommitMessage
131+
132+
msg, err = commitparser.Parse(commit.Message)
133+
if err != nil {
134+
return fmt.Errorf("could not parse commit message: %w", err)
135+
}
136+
137+
if msg.Breaking {
138+
major = true
139+
140+
return nil
141+
}
142+
143+
switch msg.Type {
144+
case "feat":
145+
minor = true
146+
case "fix", "sec":
147+
patch = true
148+
}
149+
150+
return nil
151+
})
152+
153+
if err != nil {
154+
return fmt.Errorf("failed to calculate next version: %w", err)
155+
}
156+
157+
if version == nil {
158+
version = semver.MustParse("0.0.0")
159+
}
160+
161+
switch {
162+
case major && version.Major() > 0:
163+
*version = version.IncMajor()
164+
case minor || (major && version.Major() > 0):
165+
*version = version.IncMinor()
166+
case patch:
167+
*version = version.IncPatch()
168+
}
169+
170+
*version, err = version.SetPrerelease(nv.Prerelease)
171+
if err != nil {
172+
return fmt.Errorf("could not set prerelease version: %w", err)
173+
}
174+
175+
*version, err = version.SetMetadata(nv.Metadata)
176+
if err != nil {
177+
return fmt.Errorf("could not set metadata version: %w", err)
178+
}
179+
180+
if nv.VSuffix {
181+
_, err = nv.Writer.Write([]byte{'v'})
182+
if err != nil {
183+
return fmt.Errorf("could not write version suffix: %w", err)
184+
}
185+
}
186+
187+
_, err = nv.Writer.Write([]byte(version.String()))
188+
if err != nil {
189+
return fmt.Errorf("could not write next version: %w", err)
190+
}
191+
192+
return nil
193+
}
194+
195+
func findTags(ctx context.Context, repo *git.Repository) (map[plumbing.Hash]*semver.Version, error) {
196+
iter, err := repo.Tags()
197+
if err != nil {
198+
return nil, fmt.Errorf("could not get tags: %w", err)
199+
}
200+
201+
defer iter.Close()
202+
203+
tags := make(map[plumbing.Hash]*semver.Version)
204+
205+
err = iter.ForEach(func(ref *plumbing.Reference) error {
206+
if ctx.Err() != nil {
207+
return ctx.Err()
208+
}
209+
210+
var v *semver.Version
211+
v, err = semver.NewVersion(ref.Name().Short())
212+
if err != nil {
213+
return nil
214+
}
215+
216+
tags[ref.Hash()] = v
217+
218+
return nil
219+
})
220+
221+
if err != nil {
222+
return nil, err
223+
}
224+
225+
return tags, nil
226+
}

0 commit comments

Comments
 (0)