Fix arguments
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-10 20:45:23 +03:00
parent 88c0eb17fb
commit 79cfc127d3
2 changed files with 264 additions and 42 deletions

View File

@@ -1,14 +1,17 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log" "log"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
git "github.com/go-git/go-git/v5"
"github.com/go-ini/ini" "github.com/go-ini/ini"
) )
@@ -23,6 +26,18 @@ type BumpConfig struct {
FilePaths []string FilePaths []string
} }
type resolvedFlag struct {
value bool
source string
}
type fileStatus struct {
path string
exists bool
contains bool
err error
}
// getBumpConfig загружает конфигурацию из указанного файла // getBumpConfig загружает конфигурацию из указанного файла
func getBumpConfig(cfg_name string) (*BumpConfig, error) { func getBumpConfig(cfg_name string) (*BumpConfig, error) {
cfg, err := ini.Load(cfg_name) cfg, err := ini.Load(cfg_name)
@@ -186,25 +201,55 @@ func versionString() string {
return fmt.Sprintf("%s (%s)", AppVersion, AppCommit) return fmt.Sprintf("%s (%s)", AppVersion, AppCommit)
} }
func normalizeArgs(args []string) ([]string, error) { func extractPart(args []string) (string, []string, error) {
if len(args) == 0 { var part string
return args, nil flagArgs := make([]string, 0, len(args))
for _, arg := range args {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "-") {
flagArgs = append(flagArgs, arg)
continue
}
value := strings.ToLower(trimmed)
switch value {
case "major", "minor", "patch":
if part != "" {
return "", nil, fmt.Errorf("multiple positional arguments provided (expected only one major/minor/patch)")
}
part = value
default:
return "", nil, fmt.Errorf("unknown positional argument %q (expected major/minor/patch or flags)", trimmed)
}
} }
first := strings.TrimSpace(args[0])
if first == "" || strings.HasPrefix(first, "-") { if part == "" {
return args, nil return "", nil, fmt.Errorf("version part argument required (major|minor|patch)")
} }
switch strings.ToLower(first) {
case "major", "minor", "patch": return part, flagArgs, nil
return append([]string{"-part=" + strings.ToLower(first)}, args[1:]...), nil }
default:
return nil, fmt.Errorf("unknown positional argument %q (expected major/minor/patch or flags)", first) func hasHelpFlag(args []string) bool {
for _, arg := range args {
switch arg {
case "-h", "--help", "-help":
return true
}
} }
return false
} }
func main() { func main() {
const cfg_name = ".bumpversion.cfg" const cfg_name = ".bumpversion.cfg"
args := os.Args[1:] args := os.Args[1:]
flag.Usage = usage(cfg_name)
// Проверяем аргументы командной строки // Проверяем аргументы командной строки
if len(args) > 0 && strings.ToLower(args[0]) == "--version" { if len(args) > 0 && strings.ToLower(args[0]) == "--version" {
fmt.Printf("%s version %s\n", AppName, versionString()) fmt.Printf("%s version %s\n", AppName, versionString())
@@ -214,26 +259,33 @@ func main() {
// Печатаем название и версию при старте // Печатаем название и версию при старте
fmt.Printf("Starting %s version %s\n", AppName, versionString()) fmt.Printf("Starting %s version %s\n", AppName, versionString())
normalizedArgs, err := normalizeArgs(args)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(2)
}
bc, err := getBumpConfig(cfg_name) bc, err := getBumpConfig(cfg_name)
if err != nil { if err != nil {
log.Fatalf("Error reading bumpversion configuration: %v", err) log.Fatalf("Error reading bumpversion configuration: %v", err)
} }
if hasHelpFlag(args) {
flag.Usage()
return
}
partValue, flagArgs, err := extractPart(args)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(2)
}
// Парсинг аргументов командной строки // Парсинг аргументов командной строки
part := flag.String("part", "patch", "Part of the version to bump (major/minor/patch). Positional form is also supported: bumpversion.run major")
commit := flag.Bool("commit", false, "Create a commit") commit := flag.Bool("commit", false, "Create a commit")
noCommit := flag.Bool("no-commit", false, "Do not create a commit") noCommit := flag.Bool("no-commit", false, "Do not create a commit")
tag := flag.Bool("tag", false, "Add a git tag") tag := flag.Bool("tag", false, "Add a git tag")
noTag := flag.Bool("no-tag", false, "Do not add a git tag") noTag := flag.Bool("no-tag", false, "Do not add a git tag")
noFatal := flag.Bool("no-fatal", false, "Do not fail if a configured file does not contain the current version") noFatal := flag.Bool("no-fatal", false, "Do not fail if a configured file does not contain the current version")
push := flag.Bool("push", false, "Force push to repository") push := flag.Bool("push", false, "Force push to repository")
if err := flag.CommandLine.Parse(normalizedArgs); err != nil { dryRun := flag.Bool("dry-run", false, "Plan only: show what will be changed without touching files or git")
verbose := flag.Bool("verbose", false, "Print detailed output")
if err := flag.CommandLine.Parse(flagArgs); err != nil {
log.Fatalf("Error parsing flags: %v", err) log.Fatalf("Error parsing flags: %v", err)
} }
@@ -243,22 +295,21 @@ func main() {
os.Exit(2) os.Exit(2)
} }
partValue := strings.ToLower(strings.TrimSpace(*part))
if partValue != "major" && partValue != "minor" && partValue != "patch" {
fmt.Printf("Error: part must be one of 'major', 'minor', or 'patch'. Got '%s'\n", *part)
os.Exit(1)
}
// Разрешаем флаги: // Разрешаем флаги:
shouldCommit, err := resolveFlag(commit, noCommit, bc.Commit) shouldCommit, err := resolveFlagVerbose("commit", commit, noCommit, false, "default (--no-commit)")
if err != nil { if err != nil {
log.Fatalf("Error resolving commit flags: %v", err) log.Fatalf("Error resolving commit flags: %v", err)
} }
shouldTag, err := resolveFlag(tag, noTag, bc.Tag) shouldTag, err := resolveFlagVerbose("tag", tag, noTag, false, "default (--no-tag)")
if err != nil { if err != nil {
log.Fatalf("Error resolving tag flags: %v", err) log.Fatalf("Error resolving tag flags: %v", err)
} }
if *verbose {
printConfigSummary(cfg_name, bc)
printEffectiveParameters(partValue, bc, shouldCommit, shouldTag, *push, !*noFatal, *dryRun)
}
newVersion, err := bumpVersion(bc, partValue) newVersion, err := bumpVersion(bc, partValue)
if err != nil { if err != nil {
log.Fatalf("Error bumping version: %v", err) log.Fatalf("Error bumping version: %v", err)
@@ -266,6 +317,15 @@ func main() {
fmt.Printf("Current version: %s\n", bc.CurrentVersion) fmt.Printf("Current version: %s\n", bc.CurrentVersion)
fmt.Printf("New version: %s\n", newVersion) fmt.Printf("New version: %s\n", newVersion)
if *verbose {
printFileStatuses(bc.FilePaths, bc.CurrentVersion)
}
if *dryRun {
printPlanOnly(partValue, newVersion, bc, shouldCommit, shouldTag, *push, !*noFatal)
return
}
// Обновляем файлы, указанные в конфигурации // Обновляем файлы, указанные в конфигурации
if err := updateFiles(bc.FilePaths, bc.CurrentVersion, newVersion, !*noFatal); err != nil { if err := updateFiles(bc.FilePaths, bc.CurrentVersion, newVersion, !*noFatal); err != nil {
log.Fatalf("Error updating files: %v", err) log.Fatalf("Error updating files: %v", err)
@@ -279,12 +339,12 @@ func main() {
} }
// Выполняем git commit и tag, если требуется // Выполняем git commit и tag, если требуется
if shouldCommit { if shouldCommit.value {
gitCommit(bc, newVersion, cfg_name) gitCommit(bc, newVersion, cfg_name)
} }
// Выполняем git commit и tag, если требуется // Выполняем git commit и tag, если требуется
if shouldTag { if shouldTag.value {
gitTag(bc, newVersion) gitTag(bc, newVersion)
} }
@@ -292,3 +352,162 @@ func main() {
gitPush(bc, newVersion) gitPush(bc, newVersion)
} }
} }
func printConfigSummary(cfgPath string, bc *BumpConfig) {
fmt.Println("Configuration:")
fmt.Printf(" config_file: %s\n", cfgPath)
fmt.Printf(" current_version: %s\n", bc.CurrentVersion)
fmt.Printf(" parse: %s\n", bc.Parse)
fmt.Printf(" serialize: %s\n", bc.Serialize)
fmt.Printf(" commit (config): %t\n", bc.Commit)
fmt.Printf(" tag (config): %t\n", bc.Tag)
fmt.Printf(" tag_name template: %s\n", dashIfEmpty(bc.TagName))
fmt.Printf(" message template: %s\n", dashIfEmpty(bc.Message))
fmt.Printf(" files: %s\n", strings.Join(bc.FilePaths, ", "))
fmt.Println()
}
func printEffectiveParameters(part string, bc *BumpConfig, commit resolvedFlag, tag resolvedFlag, push bool, fatal bool, dryRun bool) {
fmt.Println("Effective parameters:")
fmt.Printf(" part: %s\n", part)
fmt.Printf(" commit: %t (source: %s)\n", commit.value, commit.source)
fmt.Printf(" tag: %t (source: %s)\n", tag.value, tag.source)
fmt.Printf(" push: %t\n", push)
fmt.Printf(" fatal_on_missing: %t\n", fatal)
fmt.Printf(" dry_run: %t\n", dryRun)
fmt.Printf(" git: %s\n", gitHint())
fmt.Println()
}
func printFileStatuses(paths []string, needle string) {
fmt.Println("Target files status:")
statuses := collectFileStatuses(paths, needle)
for _, st := range statuses {
switch {
case st.err != nil:
fmt.Printf(" %s: error: %v\n", st.path, st.err)
case !st.exists:
fmt.Printf(" %s: missing\n", st.path)
case st.contains:
fmt.Printf(" %s: present, contains current version\n", st.path)
default:
fmt.Printf(" %s: present, current version not found\n", st.path)
}
}
if len(statuses) == 0 {
fmt.Println(" (no files configured)")
}
fmt.Println()
}
func collectFileStatuses(paths []string, needle string) []fileStatus {
result := make([]fileStatus, 0, len(paths))
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
st := fileStatus{path: path}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
st.exists = false
} else {
st.err = err
}
result = append(result, st)
continue
}
st.exists = true
st.contains = strings.Contains(string(data), needle)
result = append(result, st)
}
return result
}
func printPlanOnly(part, newVersion string, bc *BumpConfig, commit resolvedFlag, tag resolvedFlag, push bool, fatal bool) {
fmt.Println("Plan (dry-run):")
fmt.Printf(" bump %s -> %s using parse=%q serialize=%q\n", bc.CurrentVersion, newVersion, bc.Parse, bc.Serialize)
for _, path := range bc.FilePaths {
fmt.Printf(" would update file: %s (fatal_if_missing=%t)\n", path, fatal)
}
fmt.Printf(" would update config: %s -> current_version=%s\n", ".bumpversion.cfg", newVersion)
if commit.value {
fmt.Printf(" would commit with message template: %s\n", bc.Message)
}
if tag.value {
fmt.Printf(" would tag: %s\n", strings.ReplaceAll(bc.TagName, "{new_version}", newVersion))
}
if push {
fmt.Println(" would push branch and tag to origin")
}
fmt.Println(" no changes were made (dry-run)")
}
func dashIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
func usage(cfgPath string) func() {
return func() {
fmt.Println("Usage:")
fmt.Println(" bumpversion.run <major|minor|patch> [--commit|--no-commit] [--tag|--no-tag] [--push] [--no-fatal] [--dry-run]")
fmt.Println()
fmt.Println("Flags:")
fmt.Println(" major|minor|patch Required positional argument selecting which part to bump")
fmt.Println(" --commit / --no-commit Toggle commit creation (default: --no-commit)")
fmt.Println(" --tag / --no-tag Toggle tag creation (default: --no-tag)")
fmt.Println(" --push Push branch+tag to origin after bump")
fmt.Println(" --no-fatal Do not fail if old version not found in target files")
fmt.Println(" --dry-run Show plan and detected files, do not touch anything")
fmt.Println(" --verbose Print detailed configuration and status")
fmt.Printf("Config: reads %s for defaults (current_version, parse/serialize, files, tag/message).\n", cfgPath)
}
}
func resolveFlagVerbose(name string, positive, negative *bool, defaultValue bool, defaultSource string) (resolvedFlag, error) {
if positive == nil || negative == nil {
return resolvedFlag{}, fmt.Errorf("flag pointers must not be nil for %s", name)
}
if *positive && *negative {
return resolvedFlag{}, fmt.Errorf("conflicting flags: both --%s and --no-%s are set", name, name)
}
if *positive {
return resolvedFlag{value: true, source: "--" + name}, nil
}
if *negative {
return resolvedFlag{value: false, source: "--no-" + name}, nil
}
return resolvedFlag{value: defaultValue, source: defaultSource}, nil
}
func gitHint() string {
repo, err := git.PlainOpen(".")
if err != nil {
return "repo not found"
}
head, err := repo.Head()
if err != nil {
return "cannot read HEAD"
}
branch := head.Name().Short()
w, err := repo.Worktree()
if err != nil {
return fmt.Sprintf("branch %s (worktree error)", branch)
}
status, err := w.Status()
if err != nil {
return fmt.Sprintf("branch %s (status error)", branch)
}
state := "dirty"
if status.IsClean() {
state = "clean"
}
remote := "(origin not set)"
if r, err := repo.Remote("origin"); err == nil && len(r.Config().URLs) > 0 {
remote = r.Config().URLs[0]
}
return fmt.Sprintf("branch %s, %s, remote=%s", branch, state, remote)
}

View File

@@ -123,30 +123,33 @@ func TestBumpVersion(t *testing.T) {
func TestNormalizeArgs(t *testing.T) { func TestNormalizeArgs(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
in []string in []string
want []string wantPart string
wantErr bool wantFlagLen int
wantErr bool
}{ }{
{name: "empty", in: nil, want: nil}, {name: "major_only", in: []string{"major"}, wantPart: "major", wantFlagLen: 0},
{name: "flags_only", in: []string{"--tag"}, want: []string{"--tag"}}, {name: "minor_with_flags", in: []string{"minor", "--tag"}, wantPart: "minor", wantFlagLen: 1},
{name: "positional_major", in: []string{"major"}, want: []string{"-part=major"}}, {name: "multiple_parts", in: []string{"major", "patch"}, wantErr: true},
{name: "positional_minor_plus_flags", in: []string{"minor", "--tag"}, want: []string{"-part=minor", "--tag"}},
{name: "positional_patch", in: []string{"patch"}, want: []string{"-part=patch"}},
{name: "unknown_positional", in: []string{"wat"}, wantErr: true}, {name: "unknown_positional", in: []string{"wat"}, wantErr: true},
{name: "missing_part", in: []string{"--tag"}, wantErr: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := normalizeArgs(tt.in) part, flags, err := extractPart(tt.in)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Fatalf("normalizeArgs error = %v, wantErr %v", err, tt.wantErr) t.Fatalf("extractPart error = %v, wantErr %v", err, tt.wantErr)
} }
if err != nil { if err != nil {
return return
} }
if !reflect.DeepEqual(got, tt.want) { if part != tt.wantPart {
t.Fatalf("normalizeArgs = %#v, want %#v", got, tt.want) t.Fatalf("extractPart part = %q, want %q", part, tt.wantPart)
}
if len(flags) != tt.wantFlagLen {
t.Fatalf("extractPart flags len = %d, want %d", len(flags), tt.wantFlagLen)
} }
}) })
} }