Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 127 additions & 12 deletions cmd/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <version> --project <alias>",
Use: "tag [<version> | --bump <major|minor|patch>]",
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,
Expand All @@ -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
}
185 changes: 184 additions & 1 deletion cmd/tag_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package cmd

import "testing"
import (
"os"
"os/exec"
"testing"
)

func TestSemverRe(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -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")
}
}
Loading