1
0
Fork 0
mirror of https://github.com/Luzifer/git-changerelease.git synced 2024-10-18 06:04:20 +00:00
git-changerelease/main.go
Knut Ahlers 1996e4589f
Add support for pre-commit-commands
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-12 13:12:04 +01:00

309 lines
8.5 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"regexp"
"strings"
"text/template"
"time"
"github.com/mitchellh/go-homedir"
"github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/env"
"github.com/Luzifer/rconfig/v2"
)
const (
fileModeChangelog = 0o644
fileModeConfig = 0o600
)
var (
cfg = struct {
ChangelogFile string `flag:"changelog" default:"History.md" description:"File to write the changelog to"`
ConfigFile string `flag:"config" default:"~/.git_changerelease.yaml" description:"Location of the configuration file"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
MkConfig bool `flag:"create-config" default:"false" description:"Copy an example configuration file to the location of --config"`
NoEdit bool `flag:"no-edit" default:"false" description:"Do not open the $EDITOR to modify the changelog"`
PreRelease string `flag:"pre-release" default:"" description:"Pre-Release information to append to the version (i.e. 'beta' or 'alpha.1')"`
ReleaseMeta string `flag:"release-meta" default:"" description:"Release metadata to append to the version (i.e. 'exp.sha.5114f85' or '20130313144700')"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
config *configFile
version = "dev"
matchers = make(map[*regexp.Regexp]semVerBump)
errExitZero = errors.New("should exit zero now")
)
func initApp() (err error) {
rconfig.AutoEnv(true)
if err = rconfig.Parse(&cfg); err != nil {
return fmt.Errorf("parsing cli options: %w", err)
}
if cfg.VersionAndExit {
fmt.Printf("git-changerelease %s\n", version) //nolint:forbidigo // Fine in this case
return errExitZero
}
cfg.ConfigFile, err = homedir.Expand(cfg.ConfigFile)
if err != nil {
return fmt.Errorf("expanding file path: %w", err)
}
var l logrus.Level
if l, err = logrus.ParseLevel(cfg.LogLevel); err != nil {
return fmt.Errorf("parsing log-level: %w", err)
}
logrus.SetLevel(l)
if cfg.MkConfig {
if err = os.WriteFile(cfg.ConfigFile, mustAsset("assets/git_changerelease.yaml"), fileModeConfig); err != nil {
return fmt.Errorf("writing example config to %q: %w", cfg.ConfigFile, err)
}
logrus.Infof("wrote an example configuration to %q", cfg.ConfigFile)
return errExitZero
}
if !cfg.NoEdit && os.Getenv("EDITOR") == "" {
return errors.New("tried to open the changelog in the editor but there is no $EDITOR in your env")
}
projectConfig, err := filenameInGitRoot(".git_changerelease.yaml")
if err != nil {
return fmt.Errorf("building filename for project config: %w", err)
}
if config, err = loadConfig(cfg.ConfigFile, projectConfig); err != nil {
return fmt.Errorf("loading config file(s): %w", err)
}
// Collect matchers
if err = loadMatcherRegex(config.MatchPatch, semVerBumpPatch); err != nil {
return fmt.Errorf("loading patch matcher: %w", err)
}
if err = loadMatcherRegex(config.MatchMajor, semVerBumpMajor); err != nil {
return fmt.Errorf("loading major matcher: %w", err)
}
if cfg.ChangelogFile, err = filenameInGitRoot(cfg.ChangelogFile); err != nil {
return fmt.Errorf("getting absolute path to changelog file: %w", err)
}
return nil
}
func loadMatcherRegex(matches []string, bump semVerBump) error {
for _, match := range matches {
r, err := regexp.Compile(match)
if err != nil {
return fmt.Errorf("parsing regex %q: %w", match, err)
}
matchers[r] = bump
}
return nil
}
func main() {
var err error
if err = initApp(); err != nil {
if errors.Is(err, errExitZero) {
os.Exit(0)
}
logrus.WithError(err).Fatal("initializing app")
}
// Get last tag
lastTag, err := gitSilent("describe", "--tags", "--abbrev=0", `--match=v[0-9]*\.[0-9]*\.[0-9]*`)
if err != nil {
lastTag = "0.0.0"
}
logs, err := fetchGitLogs(lastTag, err != nil)
if err != nil {
logrus.WithError(err).Fatal("fetching git logs")
}
if len(logs) == 0 {
logrus.Info("found no changes since last tag, stopping now.")
return
}
// Generate new version
newVersion, err := newVersionFromLogs(lastTag, logs)
if err != nil {
logrus.WithError(err).Fatal("bumping version")
}
// Render log
if newVersion, err = renderLogAndGetVersion(newVersion, logs); err != nil {
logrus.WithError(err).Fatal("writing changelog")
}
for _, pc := range config.PreCommitCommands {
if err = runUserCommand(pc, map[string]string{"TAG_VERSION": newVersion.String()}); err != nil {
logrus.WithError(err).WithField("cmd", pc).Fatal("executing pre-commit-commands")
}
}
// Write the tag
if err = applyTag("v" + newVersion.String()); err != nil {
logrus.WithError(err).Fatal("applying tag")
}
}
func newVersionFromLogs(lastTag string, logs []commit) (*semVer, error) {
// Tetermine increase type
semVerBumpType, err := selectBumpType(logs)
if err != nil {
return nil, fmt.Errorf("determining bump type: %w", err)
}
// Generate new version
newVersion, err := parseSemVer(lastTag)
if err != nil {
return nil, fmt.Errorf("parsing previous version: %w", err)
}
newVersion.Bump(semVerBumpType)
if err = newVersion.SetPrerelease(cfg.PreRelease); err != nil {
return newVersion, fmt.Errorf("setting prerelease: %w", err)
}
if err = newVersion.SetMetadata(cfg.ReleaseMeta); err != nil {
return newVersion, fmt.Errorf("setting metadata: %w", err)
}
return newVersion, nil
}
func readChangelog() (string, error) {
if _, err := os.Stat(cfg.ChangelogFile); err != nil {
if errors.Is(err, fs.ErrNotExist) {
logrus.Warn("changelog file does not yet exist, creating one")
return "", nil
}
return "", fmt.Errorf("getting file stat: %w", err)
}
d, err := os.ReadFile(cfg.ChangelogFile)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(d), nil
}
func renderLogAndGetVersion(newVersion *semVer, logs []commit) (*semVer, error) {
oldLog, err := readChangelog()
if err != nil {
return nil, fmt.Errorf("reading old changelog: %w", err)
}
c, err := renderTemplate("log_template", mustAsset("assets/log_template.md"), struct {
NextVersion *semVer
Now time.Time
LogLines []commit
OldLog string
}{
NextVersion: newVersion,
Now: time.Now(),
LogLines: logs,
OldLog: strings.TrimSpace(oldLog),
})
if err != nil {
return nil, fmt.Errorf("rendering log: %w", err)
}
if err = os.WriteFile(cfg.ChangelogFile, c, fileModeChangelog); err != nil {
return nil, fmt.Errorf("writing changelog: %w", err)
}
// Spawning editor
if !cfg.NoEdit {
editor := exec.Command(os.Getenv("EDITOR"), cfg.ChangelogFile) //#nosec:G204 // This is intended to use OS editor with configured changelog file
editor.Stdin = os.Stdin
editor.Stdout = os.Stdout
editor.Stderr = os.Stderr
if err = editor.Run(); err != nil {
return nil, fmt.Errorf("editor process caused error: %w", err)
}
}
// Read back version from changelog file
changelog := strings.Split(string(c), "\n")
if len(changelog) < 1 {
return nil, errors.New("changelog is empty, no way to read back the version")
}
newVersion, err = parseSemVer(strings.Split(changelog[0], " ")[1])
if err != nil {
return nil, fmt.Errorf("parsing new version from log: %w", err)
}
return newVersion, nil
}
func renderTemplate(name string, tplSrc []byte, values any) ([]byte, error) {
tpl, err := template.New(name).Parse(string(tplSrc))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
buf := new(bytes.Buffer)
if err := tpl.Execute(buf, values); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}
func runUserCommand(command string, extraEnv map[string]string) error {
if len(command) == 0 {
return errors.New("empty command specified")
}
// Check whether command is prefixed with a minus: If so the error
// will only be logged and is not fatal to the changerelease run
fatal := command[0] != '-'
if !fatal {
command = command[1:]
}
logrus.WithField("fatal", fatal).WithField("command", command).Trace("running pre_commit_commands")
envVars := env.ListToMap(os.Environ())
for k, v := range extraEnv {
envVars[k] = v
}
wd, err := filenameInGitRoot(".")
if err != nil {
return fmt.Errorf("determining workdir for commands: %w", err)
}
cmd := exec.Command("/usr/bin/env", "bash", "-ec", command)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
cmd.Env = env.MapToList(envVars)
cmd.Dir = wd
if err = cmd.Run(); err != nil {
if fatal {
return fmt.Errorf("command had error: %w", err)
}
logrus.WithError(err).WithField("cmd", command).Warn("command had error, marked as non-fatal")
}
return nil
}