From 79cfc127d34b570b2c27112f51b68720284c9d5e Mon Sep 17 00:00:00 2001 From: Ruslan Popov Date: Tue, 10 Feb 2026 20:45:23 +0300 Subject: [PATCH] Fix arguments --- src/main.go | 277 ++++++++++++++++++++++++++++++++++++++++++----- src/main_test.go | 29 ++--- 2 files changed, 264 insertions(+), 42 deletions(-) diff --git a/src/main.go b/src/main.go index 790f4ef..741a92b 100644 --- a/src/main.go +++ b/src/main.go @@ -1,14 +1,17 @@ package main import ( + "errors" "flag" "fmt" + "io/fs" "log" "os" "regexp" "strconv" "strings" + git "github.com/go-git/go-git/v5" "github.com/go-ini/ini" ) @@ -23,6 +26,18 @@ type BumpConfig struct { FilePaths []string } +type resolvedFlag struct { + value bool + source string +} + +type fileStatus struct { + path string + exists bool + contains bool + err error +} + // getBumpConfig загружает конфигурацию из указанного файла func getBumpConfig(cfg_name string) (*BumpConfig, error) { cfg, err := ini.Load(cfg_name) @@ -186,25 +201,55 @@ func versionString() string { return fmt.Sprintf("%s (%s)", AppVersion, AppCommit) } -func normalizeArgs(args []string) ([]string, error) { - if len(args) == 0 { - return args, nil +func extractPart(args []string) (string, []string, error) { + var part string + 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, "-") { - return args, nil + + if part == "" { + return "", nil, fmt.Errorf("version part argument required (major|minor|patch)") } - switch strings.ToLower(first) { - case "major", "minor", "patch": - 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) + + return part, flagArgs, nil +} + +func hasHelpFlag(args []string) bool { + for _, arg := range args { + switch arg { + case "-h", "--help", "-help": + return true + } } + return false } func main() { const cfg_name = ".bumpversion.cfg" args := os.Args[1:] + + flag.Usage = usage(cfg_name) + // Проверяем аргументы командной строки if len(args) > 0 && strings.ToLower(args[0]) == "--version" { fmt.Printf("%s version %s\n", AppName, versionString()) @@ -214,26 +259,33 @@ func main() { // Печатаем название и версию при старте 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) if err != nil { 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") noCommit := flag.Bool("no-commit", false, "Do not create a commit") tag := flag.Bool("tag", false, "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") 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) } @@ -243,22 +295,21 @@ func main() { 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 { 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 { 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) if err != nil { log.Fatalf("Error bumping version: %v", err) @@ -266,6 +317,15 @@ func main() { fmt.Printf("Current version: %s\n", bc.CurrentVersion) 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 { log.Fatalf("Error updating files: %v", err) @@ -279,12 +339,12 @@ func main() { } // Выполняем git commit и tag, если требуется - if shouldCommit { + if shouldCommit.value { gitCommit(bc, newVersion, cfg_name) } // Выполняем git commit и tag, если требуется - if shouldTag { + if shouldTag.value { gitTag(bc, newVersion) } @@ -292,3 +352,162 @@ func main() { 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 [--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) +} diff --git a/src/main_test.go b/src/main_test.go index 23fb040..9615d98 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -123,30 +123,33 @@ func TestBumpVersion(t *testing.T) { func TestNormalizeArgs(t *testing.T) { tests := []struct { - name string - in []string - want []string - wantErr bool + name string + in []string + wantPart string + wantFlagLen int + wantErr bool }{ - {name: "empty", in: nil, want: nil}, - {name: "flags_only", in: []string{"--tag"}, want: []string{"--tag"}}, - {name: "positional_major", in: []string{"major"}, want: []string{"-part=major"}}, - {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: "major_only", in: []string{"major"}, wantPart: "major", wantFlagLen: 0}, + {name: "minor_with_flags", in: []string{"minor", "--tag"}, wantPart: "minor", wantFlagLen: 1}, + {name: "multiple_parts", in: []string{"major", "patch"}, wantErr: true}, {name: "unknown_positional", in: []string{"wat"}, wantErr: true}, + {name: "missing_part", in: []string{"--tag"}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := normalizeArgs(tt.in) + part, flags, err := extractPart(tt.in) 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 { return } - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("normalizeArgs = %#v, want %#v", got, tt.want) + if part != tt.wantPart { + 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) } }) }