Skip to content
Open
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
142 changes: 142 additions & 0 deletions cmd/release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package cmd

import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/spf13/cobra"

"github.com/MicroClub-USTHB/mctf-cli/internal/release"
)

var releaseCmd = &cobra.Command{
Use: "release <version>",
Short: "Create a versioned release and push its tag",
Long: `Prepare a release using conventional commit messages.
The command requires a clean working tree and the main or develop branch.
Use --dry-run to preview the release without creating or pushing a tag.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runRelease(cmd, args[0], releaseDryRun)
},
}

var releaseDryRun bool

func init() {
releaseCmd.Flags().BoolVar(
&releaseDryRun,
"dry-run",
false,
"preview release notes without creating or pushing a tag",
)
rootCmd.AddCommand(releaseCmd)
}

func runRelease(cmd *cobra.Command, version string, dryRun bool) error {
if err := release.ValidateVersion(version); err != nil {
return err
}

dir := flagDir

branch, err := gitCommand(dir, "branch", "--show-current")
if err != nil {
return fmt.Errorf("check current branch: %w", err)
}
branch = strings.TrimSpace(branch)

if branch != "main" && branch != "develop" {
return fmt.Errorf(
"release must be run from main or develop; current branch is %q",
branch,
)
}

status, err := gitCommand(dir, "status", "--porcelain")
if err != nil {
return fmt.Errorf("check working tree: %w", err)
}
if strings.TrimSpace(status) != "" {
return fmt.Errorf(
"working tree is not clean; commit or stash your changes first",
)
}

tags, err := gitCommand(dir, "tag", "--list", version)
if err != nil {
return fmt.Errorf("check existing release tag: %w", err)
}
for _, tag := range strings.Fields(tags) {
if tag == version {
return fmt.Errorf("tag %q already exists; refusing to overwrite it", version)
}
}

subjects, previousTag, err := release.ReadCommitSubjects(dir)
if err != nil {
return fmt.Errorf("generate release notes: %w", err)
}

notes := release.FormatNotes(version, subjects)
out := cmd.OutOrStdout()

if previousTag == "" {
fmt.Fprintln(out, "No previous version tag found; using the full Git history.")
} else {
fmt.Fprintf(out, "Previous release: %s\n", previousTag)
}

fmt.Fprintf(out, "\n%s\n", notes)

if dryRun {
fmt.Fprintf(out, "\nDry run: planned tag is %s\n", version)
fmt.Fprintln(out, "No tag was created and nothing was pushed.")
return nil
}

if !confirmYes(
os.Stdin,
cmd.ErrOrStderr(),
fmt.Sprintf("Create annotated tag %s and push it to origin?", version),
) {
fmt.Fprintln(out, "Release canceled. No tag was created or pushed.")
return nil
}

if _, err := gitCommand(
dir,
"tag", "-a", version, "-m", "Release "+version,
); err != nil {
return fmt.Errorf("create annotated tag %s: %w", version, err)
}

if _, err := gitCommand(dir, "push", "origin", version); err != nil {
return fmt.Errorf(
"tag %s was created locally, but pushing it failed: %w",
version,
err,
)
}

fmt.Fprintf(out, "Release tag %s was created and pushed to origin.\n", version)
return nil
}

func gitCommand(dir string, args ...string) (string, error) {
gitArgs := append([]string{"-C", dir}, args...)
gitCmd := exec.Command("git", gitArgs...)

output, err := gitCmd.CombinedOutput()
if err != nil {
details := strings.TrimSpace(string(output))
if details != "" {
return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), details, err)
}
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}

return string(output), nil
}
52 changes: 52 additions & 0 deletions internal/release/commits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package release

import (
"fmt"
"os/exec"
"strings"
)

// ReadCommitSubjects returns commit subjects since the latest reachable
// version tag. If no version tag exists, it reads the full history.
// It also returns the previous tag, or an empty string if there isn't one.
func ReadCommitSubjects(dir string) ([]string, string, error) {
tagCmd := exec.Command(
"git", "-C", dir,
"tag", "--merged", "HEAD",
"--sort=-version:refname",
)

tagOutput, err := tagCmd.Output()
if err != nil {
return nil, "", fmt.Errorf("list Git tags: %w", err)
}

var previousTag string
for _, tag := range strings.Fields(string(tagOutput)) {
if ValidateVersion(tag) == nil {
previousTag = tag
break
}
}

args := []string{"-C", dir, "log", "--format=%s"}

if previousTag != "" {
args = append(args, previousTag+"..HEAD")
} else {
args = append(args, "HEAD")
}

logCmd := exec.Command("git", args...)
logOutput, err := logCmd.Output()
if err != nil {
return nil, previousTag, fmt.Errorf("read commit subjects: %w", err)
}

output := strings.TrimSpace(string(logOutput))
if output == "" {
return []string{}, previousTag, nil
}

return strings.Split(output, "\n"), previousTag, nil
}
99 changes: 99 additions & 0 deletions internal/release/commits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package release

import (
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
)

func TestReadCommitSubjectsWithoutTags(t *testing.T) {
dir := setupTestRepository(t)

createCommit(t, dir, "first.txt", "feat: first feature")
createCommit(t, dir, "second.txt", "fix: second feature")

subjects, previousTag, err := ReadCommitSubjects(dir)
if err != nil {
t.Fatalf("ReadCommitSubjects returned error: %v", err)
}

if previousTag != "" {
t.Errorf("previous tag = %q, want empty string", previousTag)
}

expected := []string{
"fix: second feature",
"feat: first feature",
}
if !reflect.DeepEqual(subjects, expected) {
t.Errorf("subjects = %v, want %v", subjects, expected)
}
}

func TestReadCommitSubjectsSincePreviousTag(t *testing.T) {
dir := setupTestRepository(t)

createCommit(t, dir, "first.txt", "feat: initial feature")
runGit(t, dir, "tag", "-a", "v0.1.0", "-m", "Release v0.1.0")

createCommit(t, dir, "second.txt", "fix: repair login")
createCommit(t, dir, "third.txt", "docs: explain login")

subjects, previousTag, err := ReadCommitSubjects(dir)
if err != nil {
t.Fatalf("ReadCommitSubjects returned error: %v", err)
}

if previousTag != "v0.1.0" {
t.Errorf("previous tag = %q, want %q", previousTag, "v0.1.0")
}

expected := []string{
"docs: explain login",
"fix: repair login",
}
if !reflect.DeepEqual(subjects, expected) {
t.Errorf("subjects = %v, want %v", subjects, expected)
}
}

func setupTestRepository(t *testing.T) string {
t.Helper()

dir := t.TempDir()
runGit(t, dir, "init")
runGit(t, dir, "config", "user.name", "Release Test")
runGit(t, dir, "config", "user.email", "release-test@example.com")

return dir
}

func createCommit(t *testing.T, dir, filename, subject string) {
t.Helper()

path := filepath.Join(dir, filename)
if err := os.WriteFile(path, []byte(subject), 0644); err != nil {
t.Fatalf("write test file: %v", err)
}

runGit(t, dir, "add", filename)
runGit(t, dir, "commit", "-m", subject)
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()

gitArgs := append([]string{"-C", dir}, args...)
cmd := exec.Command("git", gitArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf(
"git %v failed: %v\n%s",
args,
err,
output,
)
}
}
Loading
Loading