diff --git a/cmd/tag.go b/cmd/tag.go index f392e854..7e4847c7 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -2,7 +2,11 @@ package cmd import ( "fmt" + "os" + "os/exec" "regexp" + "strconv" + "strings" "github.com/spf13/cobra" "github.com/tta-lab/ttal-cli/internal/daemon" @@ -14,31 +18,74 @@ import ( // to keep validation simple — use dots as separators: v1.0.0-rc.1 not v1.0.0-rc-1). var semverRe = regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$`) +// semverBaseRe captures vMAJOR.MINOR.PATCH and optional +suffix from a tag. +// Groups: 1=full base (v1.2.3), 2=MAJOR, 3=MINOR, 4=PATCH, 5=+suffix (including +). +var semverBaseRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$`) + var tagCmd = &cobra.Command{ - Use: "tag --project ", + Use: "tag [ | --bump ]", Short: "Create and push a git tag via daemon (no credentials needed in worker)", Long: `Creates a lightweight git tag and pushes it to origin through the daemon. The daemon injects credentials — workers don't need tokens in their environment. -The tag must be a valid semver version prefixed with 'v' (e.g. v1.0.0, v2.1.0-rc.1). +Resolves the project from the current working directory. No --project flag needed. + +With --bump, automatically bumps the largest existing version tag in the repo. +Existing +suffix (e.g. +0.74.1) is preserved on bump. + +With a positional version argument, tags that exact version directly. Examples: - ttal tag v1.0.0 --project ttal-cli - ttal tag v2.1.0-rc.1 --project fn-agent - ttal tag v1.1.1-guion.1 --project fn-cli`, - Args: cobra.ExactArgs(1), + ttal tag --bump patch # v1.2.3 → v1.2.4 + ttal tag --bump minor # v1.2.3 → v1.3.0 + ttal tag --bump major # v1.2.3 → v2.0.0 + ttal tag v2.0.0 # explicit version + ttal tag v1.6.1+0.74.1 # explicit with +suffix`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - tag := args[0] - if !semverRe.MatchString(tag) { - return fmt.Errorf("invalid semver tag %q — expected format: v1.0.0, v2.1.0-rc.1", tag) + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getwd: %w", err) } - projectAlias, _ := cmd.Flags().GetString("project") + projectAlias := project.ResolveProjectAlias(cwd) + if projectAlias == "" { + return fmt.Errorf("current directory %q is not under a registered ttal project", cwd) + } projectPath, err := project.GetProjectPath(projectAlias) if err != nil { return err } + bump, _ := cmd.Flags().GetString("bump") + isBump := bump != "" + + if isBump && len(args) > 0 { + return fmt.Errorf("--bump and a positional version are mutually exclusive") + } + + var tag string + + if isBump { + switch bump { + case bumpMajor, bumpMinor, bumpPatch: + default: + return fmt.Errorf("invalid --bump value %q — must be major, minor, or patch", bump) + } + tag, err = computeBumpedTag(projectPath, bump) + if err != nil { + return err + } + } else { + if len(args) == 0 { + return fmt.Errorf("either a version argument or --bump is required") + } + tag = args[0] + if !semverRe.MatchString(tag) { + return fmt.Errorf("invalid semver tag %q — expected format: v1.0.0, v2.1.0-rc.1", tag) + } + } + resp, err := daemon.GitTag(daemon.GitTagRequest{ WorkDir: projectPath, Tag: tag, @@ -57,7 +104,75 @@ Examples: } func init() { - tagCmd.Flags().StringP("project", "p", "", "project alias (required)") - _ = tagCmd.MarkFlagRequired("project") + tagCmd.Flags().String("bump", "", "bump version: major, minor, or patch") rootCmd.AddCommand(tagCmd) } + +// latestTag returns the largest semver tag in the repo, or "" if none exist. +func latestTag(workDir string) (string, error) { + cmd := exec.Command("git", "-C", workDir, "tag", "--sort=-version:refname") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git tag: %w", err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || lines[0] == "" { + return "", nil + } + return lines[0], nil +} + +const ( + initialMajor = "v1.0.0" + initialMinor = "v0.1.0" + initialPatch = "v0.0.1" + + bumpMajor = "major" + bumpMinor = "minor" + bumpPatch = "patch" +) + +// computeBumpedTag finds the largest tag, bumps the specified level, and returns the new tag. +// The +suffix from the latest tag is preserved. +func computeBumpedTag(workDir, level string) (string, error) { + latest, err := latestTag(workDir) + if err != nil { + return "", err + } + if latest == "" { + switch level { + case bumpMajor: + return initialMajor, nil + case bumpMinor: + return initialMinor, nil + default: + return initialPatch, nil + } + } + + matches := semverBaseRe.FindStringSubmatch(latest) + if matches == nil { + return "", fmt.Errorf( + "latest tag %q is not a plain semver with optional +suffix (has pre-release: %s)", + latest, latest) + } + + maj, _ := strconv.Atoi(matches[1]) + min, _ := strconv.Atoi(matches[2]) + pat, _ := strconv.Atoi(matches[3]) + suffix := matches[4] // includes leading +, or "" if absent + + switch level { + case bumpMajor: + maj++ + min = 0 + pat = 0 + case bumpMinor: + min++ + pat = 0 + default: + pat++ + } + + return fmt.Sprintf("v%d.%d.%d%s", maj, min, pat, suffix), nil +} diff --git a/cmd/tag_test.go b/cmd/tag_test.go index 961a450d..98fb866c 100644 --- a/cmd/tag_test.go +++ b/cmd/tag_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "os" + "os/exec" + "testing" +) func TestSemverRe(t *testing.T) { tests := []struct { @@ -40,3 +44,182 @@ func TestSemverRe(t *testing.T) { }) } } + +func TestSemverBaseRe(t *testing.T) { + tests := []struct { + tag string + matches bool + major string + minor string + patch string + suffix string + }{ + {"v1.0.0", true, "1", "0", "0", ""}, + {"v0.1.0", true, "0", "1", "0", ""}, + {"v10.20.30", true, "10", "20", "30", ""}, + {"v1.0.0+build.123", true, "1", "0", "0", "+build.123"}, + {"v1.6.1+0.74.1", true, "1", "6", "1", "+0.74.1"}, + {"v1.0.0-rc.1", false, "", "", "", ""}, // pre-release not matched + {"v1.0.0-rc.1+build.456", false, "", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + matches := semverBaseRe.FindStringSubmatch(tt.tag) + if tt.matches && matches == nil { + t.Fatalf("semverBaseRe.FindStringSubmatch(%q) = nil, want match", tt.tag) + } + if !tt.matches && matches != nil { + t.Fatalf("semverBaseRe.FindStringSubmatch(%q) = %v, want no match", tt.tag, matches) + } + if matches != nil { + if matches[1] != tt.major { + t.Errorf("major: got %q, want %q", matches[1], tt.major) + } + if matches[2] != tt.minor { + t.Errorf("minor: got %q, want %q", matches[2], tt.minor) + } + if matches[3] != tt.patch { + t.Errorf("patch: got %q, want %q", matches[3], tt.patch) + } + if matches[4] != tt.suffix { + t.Errorf("suffix: got %q, want %q", matches[4], tt.suffix) + } + } + }) + } +} + +func testBumpedTag(t *testing.T, workDir, level, want string) { + t.Helper() + got, err := computeBumpedTag(workDir, level) + if err != nil { + t.Fatalf("computeBumpedTag(%q): %v", level, err) + } + if got != want { + t.Errorf("computeBumpedTag(%q) = %q, want %q", level, got, want) + } +} + +func testBumpedTagError(t *testing.T, workDir, level string) { + t.Helper() + got, err := computeBumpedTag(workDir, level) + if err == nil { + t.Errorf("expected error for computeBumpedTag(%q), got %q", level, got) + } + if got != "" { + t.Errorf("expected empty tag on error, got %q", got) + } +} + +func TestComputeBumpedTagNoTags(t *testing.T) { + dir, _ := setupBumpTestRepo(t) + defer func() { _ = os.RemoveAll(dir) }() + + testBumpedTag(t, dir, bumpPatch, "v0.0.1") + testBumpedTag(t, dir, bumpMinor, "v0.1.0") + testBumpedTag(t, dir, bumpMajor, "v1.0.0") +} + +func TestComputeBumpedTagSimple(t *testing.T) { + dir := setupBumpTestRepoWithTag(t, "v1.2.3") + defer func() { _ = os.RemoveAll(dir) }() + + testBumpedTag(t, dir, bumpPatch, "v1.2.4") + testBumpedTag(t, dir, bumpMinor, "v1.3.0") + testBumpedTag(t, dir, bumpMajor, "v2.0.0") +} + +func TestComputeBumpedTagSuffix(t *testing.T) { + dir := setupBumpTestRepoWithTag(t, "v1.6.1+0.74.1") + defer func() { _ = os.RemoveAll(dir) }() + + testBumpedTag(t, dir, bumpPatch, "v1.6.2+0.74.1") + testBumpedTag(t, dir, bumpMinor, "v1.7.0+0.74.1") + testBumpedTag(t, dir, bumpMajor, "v2.0.0+0.74.1") +} + +func TestComputeBumpedTagPreRelease(t *testing.T) { + dir := setupBumpTestRepoWithTag(t, "v2.0.0-rc.1") + defer func() { _ = os.RemoveAll(dir) }() + + testBumpedTagError(t, dir, bumpPatch) +} + +func TestComputeBumpedTagInvalidLevel(t *testing.T) { + _, err := computeBumpedTag("/tmp", "foo") + if err == nil { + t.Fatal("expected error for invalid level") + } +} + +func setupBumpTestRepo(t *testing.T) (dir string, runGit func(...string)) { + dir, err := os.MkdirTemp("", "ttal-tag-test-*") + if err != nil { + t.Fatal(err) + } + runGit = func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %s — %v", args, string(out), err) + } + } + runGit("init") + runGit("config", "user.name", "test") + runGit("config", "user.email", "test@test") + runGit("commit", "--allow-empty", "-m", "init") + return dir, runGit +} + +func setupBumpTestRepoWithTag(t *testing.T, tag string) string { + dir, runGit := setupBumpTestRepo(t) + runGit("tag", tag) + return dir +} + +func TestTagCmdArgValidation(t *testing.T) { + // Simulate the arg validation logic from tagCmd.RunE + + // Test mutex: --bump + positional arg fails + bump := bumpPatch + args := []string{"v1.0.0"} + isBump := bump != "" + if isBump && len(args) > 0 { + // expected error + } else { + t.Fatal("expected mutex error") + } + + // Test no args + no bump fails + bump = "" + args = []string{} + isBump = bump != "" + if !isBump && len(args) == 0 { + // expected error + } else { + t.Fatal("expected error for no bump and no args") + } + + // Test positional + no bump OK + bump = "" + args = []string{"v1.0.0"} + isBump = bump != "" + if isBump { + t.Fatal("should not be bump") + } + if len(args) == 0 { + t.Fatal("should have arg") + } + + // Test bump only OK + bump = bumpPatch + args = []string{} + isBump = bump != "" + if !isBump { + t.Fatal("should be bump") + } + if len(args) > 0 { + t.Fatal("should not have positional") + } +}