diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..4448818 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,29 @@ +# Changesets + +This directory contains changeset files that describe changes to the codebase. + +## What is a changeset? + +A changeset is a file that describes which packages should be released and how +(major, minor, or patch). When it's time to release, these changesets are +consumed to determine version bumps. + +## Creating a changeset + +Run: +``` +changeset add +``` + +This will interactively create a changeset file. + +## File format + +```markdown +--- +"package-name": minor +"other-package": patch +--- + +Description of the changes. +``` diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..fcc9db5 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,7 @@ +{ + "root": "github.com/gojekfarm/xtools", + "baseBranch": "main", + "ignore": null, + "ignorePaths": null, + "dependentBump": "patch" +} \ No newline at end of file diff --git a/.changeset/proud-koala-glide.md b/.changeset/proud-koala-glide.md new file mode 100644 index 0000000..3ad57c4 --- /dev/null +++ b/.changeset/proud-koala-glide.md @@ -0,0 +1,6 @@ +--- +"cmd/changeset": minor +--- + +**Changeset** is a CLI tool for managing versioning and releases in multi-module Go repositories. It is inspired by [changesets](https://github.com/changesets/changesets), the excellent versioning and changelog management tool for JavaScript monorepos. + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca2f367..a622e4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,3 +35,17 @@ jobs: files: ./coverage.xml fail_ci_if_error: true verbose: true + + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.x + cache: true + - name: E2E Tests + run: go test -tags=e2e -v ./e2e/... + working-directory: cmd/changeset diff --git a/.gitignore b/.gitignore index 83e0428..d498eae 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ go.work go.work.sum .envrc -.claude/ \ No newline at end of file +.claude/ +.cursor/ +.tasks/ diff --git a/cmd/changeset/README.md b/cmd/changeset/README.md new file mode 100644 index 0000000..9d5536d --- /dev/null +++ b/cmd/changeset/README.md @@ -0,0 +1,453 @@ +# Changeset + +**Changeset** is a CLI tool for managing versioning and releases in multi-module Go repositories. It is inspired by [changesets](https://github.com/changesets/changesets), the excellent versioning and changelog management tool for JavaScript monorepos. + +## Installation + +```bash +go install github.com/gojekfarm/xtools/cmd/changeset@latest +``` + +## Commands + +### `changeset init` + +Sets up the `.changeset` folder with config and README. + +```bash +$ changeset init +``` + +### `changeset` or `changeset add` + +Create a changeset to document your changes. + +```bash +$ changeset + +? Which modules would you like to include? + ◉ xkafka + ◉ xkafka/middleware + ○ xload + +? Which modules should have a major bump? + +? Which modules should have a minor bump? + ◉ xkafka + +? Summary: +> Added retry configuration to consumer + +✓ Created .changeset/hungry-tiger-jump.md +``` + +**Flags:** + +- `--empty` - Create an empty changeset (for changes that don't need releases) +- `--open` - Open the created changeset in your editor + +### `changeset status` + +Show pending changesets and computed version bumps. + +```bash +$ changeset status + +🦋 Changesets + xkafka + minor: hungry-tiger-jump + patch: brave-lion-roar +``` + +**Flags:** + +- `--verbose` - Show full changeset contents and release plan +- `--output=FILE` - Write JSON output for CI tools +- `--since=REF` - Only show changesets since a branch/tag + +### `changeset version` + +Consume changesets, update go.mod files, and generate changelogs. + +```bash +$ changeset version + +🦋 Consuming changesets +🦋 All files have been updated. Review changes and commit. +``` + +**Flags:** + +- `--ignore=MODULE` - Skip specific modules from versioning +- `--snapshot` - Create snapshot versions for testing + +### `changeset publish` + +Create git tags and push to origin. + +```bash +$ changeset publish + +🦋 Publishing xkafka@v0.11.0 +🦋 Publishing xkafka/middleware@v0.10.1 +``` + +**Flags:** + +- `--no-push` - Create tags locally without pushing + +### `changeset tag` + +Create git tags without pushing. + +```bash +$ changeset tag + +🦋 Creating tags + xkafka/v0.11.0 + xkafka/middleware/v0.10.1 +``` + +## Workflow + +### Development (Feature Branch) + +``` +feature-branch + │ + ├── Write code + ├── changeset add → creates .changeset/abc.md + ├── git add -A + ├── git commit -m "feat: ..." → code + changeset committed together + └── git push → Open PR +``` + +### Code Review (Pull Request) + +``` +PR #123: feature-branch → main + │ + ├── Reviewers see code changes + ├── Reviewers see .changeset/abc.md (bump types + summary) + ├── CI runs tests + └── Merge to main +``` + +### Accumulation (Main Branch) + +``` +main + │ + ├── PR #123 merged → .changeset/abc.md + ├── PR #124 merged → .changeset/def.md + ├── PR #125 merged → .changeset/ghi.md + │ + └── Changesets accumulate until release +``` + +### Release (Main Branch) + +``` +main + │ + ├── changeset version + │ ├── Reads all .changeset/*.md + │ ├── Computes: xkafka v0.10.0 → v0.11.0 + │ ├── Updates go.mod files + │ ├── Updates CHANGELOG.md + │ ├── Deletes .changeset/*.md + │ └── Writes .changeset/release-manifest.json + │ + ├── git add -A + ├── git commit -m "chore: version packages" + ├── git push + │ + └── changeset publish + ├── Reads release-manifest.json + ├── Creates tags: xkafka/v0.11.0, ... + ├── Pushes tags to origin + └── Deletes release-manifest.json +``` + +### Result + +``` +Users can now: + go get github.com/gojekfarm/xtools/xkafka@v0.11.0 +``` + +## Configuration + +`.changeset/config.json`: + +```json +{ + "root": "github.com/gojekfarm/xtools", + "baseBranch": "main", + "ignore": ["cmd/*", "examples/*"], + "dependentBump": "patch" +} +``` + +## Changeset File Format + +```markdown +--- +"xkafka": minor +"xkafka/middleware": patch +--- + +Added retry configuration to consumer. +``` + +## Release Manifest + +The `version` and `publish` commands communicate via a manifest file. + +### How It Works + +``` +changeset version + │ + ├── Reads .changeset/*.md (changesets) + ├── Computes version bumps + ├── Updates go.mod files + ├── Updates CHANGELOG.md + ├── Deletes consumed changesets + └── Writes .changeset/release-manifest.json ← created + +changeset publish + │ + ├── Reads .changeset/release-manifest.json + ├── Creates git tags (xkafka/v0.11.0, etc.) + ├── Pushes tags to origin + └── Deletes .changeset/release-manifest.json ← cleaned up +``` + +### Manifest Format + +`.changeset/release-manifest.json`: + +```json +{ + "releases": [ + { + "module": "xkafka", + "version": "v0.11.0", + "previousVersion": "v0.10.0", + "bump": "minor" + }, + { + "module": "xkafka/middleware", + "version": "v0.10.1", + "previousVersion": "v0.10.0", + "bump": "patch" + }, + { + "module": "xprom/xpromkafka", + "version": "v0.10.1", + "previousVersion": "v0.10.0", + "bump": "patch", + "reason": "dependency" + } + ] +} +``` + +| Field | Description | +| ----------------- | ------------------------------------------------------ | +| `module` | Module short name (relative to root) | +| `version` | New version to be tagged | +| `previousVersion` | Version before this release | +| `bump` | Bump type: `major`, `minor`, or `patch` | +| `reason` | `"dependency"` if auto-bumped due to dependency change | + +### Why a Manifest? + +1. **Decouples version from publish** - You can review changes between steps +2. **Supports CI workflows** - Version in one job, publish in another +3. **Enables dry-run** - `version` can run without side effects to git +4. **Tracks intent** - Knows exactly what to tag without re-computing + +## CI Automation + +### 1. Require Changesets on PRs + +Block PRs that modify code but don't include a changeset. + +`.github/workflows/changeset-check.yml`: + +```yaml +name: Changeset Check + +on: + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Install changeset + run: go install github.com/gojekfarm/xtools/cmd/changeset@latest + + - name: Check for changeset + run: changeset status --since=origin/main +``` + +### 2. Automated Release PR + +When changesets accumulate, automatically create a "Version Packages" PR. + +`.github/workflows/release.yml`: + +```yaml +name: Release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Install changeset + run: go install github.com/gojekfarm/xtools/cmd/changeset@latest + + - name: Check for changesets + id: check + run: | + if compgen -G ".changeset/*.md" > /dev/null; then + echo "has_changesets=true" >> $GITHUB_OUTPUT + else + echo "has_changesets=false" >> $GITHUB_OUTPUT + fi + + - name: Create Release PR + if: steps.check.outputs.has_changesets == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create release branch + BRANCH="changeset-release/main" + git checkout -B $BRANCH + + # Version packages + changeset version + + # Commit changes + git add -A + git commit -m "chore: version packages" + git push -f origin $BRANCH + + # Create or update PR + gh pr create --base main --head $BRANCH \ + --title "chore: version packages" \ + --body "This PR was auto-generated by the release workflow." \ + || gh pr edit $BRANCH --title "chore: version packages" +``` + +### 3. Publish on PR Merge + +When the release PR is merged, publish tags. + +`.github/workflows/publish.yml`: + +```yaml +name: Publish + +on: + push: + branches: [main] + paths: + - ".changeset/release-manifest.json" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Install changeset + run: go install github.com/gojekfarm/xtools/cmd/changeset@latest + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Publish + run: changeset publish +``` + +### CI Flow Diagram + +``` +PR opened (feature branch) + │ + └── changeset-check.yml + │ + ├── Has .changeset/*.md? → ✓ Pass + └── No changeset? → ✗ Fail + +PR merged to main + │ + └── release.yml + │ + ├── Has changesets? → Create "Version Packages" PR + └── No changesets? → Skip + +"Version Packages" PR merged + │ + └── publish.yml + │ + ├── Has release-manifest.json? → Create & push tags + └── No manifest? → Skip +``` + +### Skipping Changesets + +For PRs that don't need releases (docs, CI config, etc.): + +```bash +# Create an empty changeset +$ changeset add --empty +``` + +Or configure paths to ignore in `.changeset/config.json`: + +```json +{ + "ignorePaths": ["*.md", ".github/**", "docs/**"] +} +``` diff --git a/cmd/changeset/app/add.go b/cmd/changeset/app/add.go new file mode 100644 index 0000000..8fa5b61 --- /dev/null +++ b/cmd/changeset/app/add.go @@ -0,0 +1,229 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "sort" + + "github.com/charmbracelet/huh" + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/module" +) + +// Add creates a new changeset interactively or with flags. +func Add(ctx context.Context, cmd *cli.Command) error { + dir := "." + empty := cmd.Bool("empty") + openEditor := cmd.Bool("open") + modules := cmd.StringSlice("module") + summary := cmd.String("summary") + + // Check if initialized + if !changeset.ChangesetDirExists(dir) { + return cli.Exit("Changeset not initialized. Run 'changeset init' first.", 1) + } + + var cs *changeset.Changeset + + if empty { + // Create empty changeset + cs = &changeset.Changeset{ + Modules: make(map[string]changeset.Bump), + Summary: "Empty changeset for changes that don't require a release.", + } + } else if len(modules) > 0 { + // Non-interactive mode + var err error + cs, err = createChangesetNonInteractive(modules, summary) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to create changeset: %v", err), 1) + } + } else { + // Interactive flow + var err error + cs, err = createChangesetInteractive(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to create changeset: %v", err), 1) + } + } + + // Write changeset + if err := changeset.WriteChangeset(dir, cs); err != nil { + return cli.Exit(fmt.Sprintf("Failed to write changeset: %v", err), 1) + } + + slog.Info("Created changeset", "id", cs.ID, "file", cs.FilePath) + + // Open in editor if requested + if openEditor && cs.FilePath != "" { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" + } + editorCmd := exec.Command(editor, cs.FilePath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + if err := editorCmd.Run(); err != nil { + slog.Warn("Failed to open editor", "error", err) + } + } + + return nil +} + +func createChangesetNonInteractive(modules []string, summary string) (*changeset.Changeset, error) { + if len(modules) == 0 { + return nil, fmt.Errorf("no modules specified") + } + + moduleBumps := make(map[string]changeset.Bump) + for _, m := range modules { + parts := splitModuleBump(m) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid module:bump format: %q (expected 'module:bump')", m) + } + modName := parts[0] + bumpType := parts[1] + + if !isValidBump(bumpType) { + return nil, fmt.Errorf("invalid bump type %q for module %q (must be patch, minor, or major)", bumpType, modName) + } + + moduleBumps[modName] = changeset.Bump(bumpType) + } + + if summary == "" { + summary = "No summary provided." + } + + return &changeset.Changeset{ + Modules: moduleBumps, + Summary: summary, + }, nil +} + +func splitModuleBump(s string) []string { + idx := lastIndex(s, ':') + if idx == -1 { + return []string{s} + } + return []string{s[:idx], s[idx+1:]} +} + +func lastIndex(s string, c byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == c { + return i + } + } + return -1 +} + +func isValidBump(bump string) bool { + return bump == "patch" || bump == "minor" || bump == "major" +} + +func createChangesetInteractive(dir string) (*changeset.Changeset, error) { + // Discover modules + graph, err := module.Discover(dir) + if err != nil { + return nil, fmt.Errorf("discovering modules: %w", err) + } + + // Get list of modules + modules := graph.AllModules() + if len(modules) == 0 { + return nil, fmt.Errorf("no modules found") + } + + // Build options for multi-select + var options []huh.Option[string] + for _, mod := range modules { + label := mod.ShortName + if label == "" { + label = "(root) " + mod.Name + } + options = append(options, huh.NewOption(label, mod.ShortName)) + } + + // Sort options by label + sort.Slice(options, func(i, j int) bool { + return options[i].Key < options[j].Key + }) + + // Select modules + var selectedModules []string + selectForm := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Which packages have changes?"). + Description("Select all packages that should be released"). + Options(options...). + Value(&selectedModules), + ), + ) + + if err := selectForm.Run(); err != nil { + return nil, fmt.Errorf("module selection: %w", err) + } + + if len(selectedModules) == 0 { + return nil, fmt.Errorf("no modules selected") + } + + // For each selected module, ask for bump type + moduleBumps := make(map[string]changeset.Bump) + for _, mod := range selectedModules { + displayName := mod + if mod == "" { + displayName = "(root)" + } + + var bumpType string + bumpForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("What type of change is this for %s?", displayName)). + Options( + huh.NewOption("patch - Bug fixes, minor changes", "patch"), + huh.NewOption("minor - New features, backwards compatible", "minor"), + huh.NewOption("major - Breaking changes", "major"), + ). + Value(&bumpType), + ), + ) + + if err := bumpForm.Run(); err != nil { + return nil, fmt.Errorf("bump selection for %s: %w", displayName, err) + } + + moduleBumps[mod] = changeset.Bump(bumpType) + } + + // Get summary + var summary string + summaryForm := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Describe the changes"). + Description("This will appear in the changelog"). + CharLimit(1000). + Value(&summary), + ), + ) + + if err := summaryForm.Run(); err != nil { + return nil, fmt.Errorf("summary input: %w", err) + } + + return &changeset.Changeset{ + Modules: moduleBumps, + Summary: summary, + }, nil +} diff --git a/cmd/changeset/app/cli.go b/cmd/changeset/app/cli.go new file mode 100644 index 0000000..2765717 --- /dev/null +++ b/cmd/changeset/app/cli.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" + "github.com/mattn/go-isatty" + "github.com/urfave/cli/v3" +) + +func setupLogger(verbose bool) { + w := os.Stderr + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug + } + + handler := tint.NewHandler(w, &tint.Options{ + Level: level, + TimeFormat: time.Kitchen, + NoColor: !isatty.IsTerminal(w.Fd()), + }) + + slog.SetDefault(slog.New(handler)) +} + +// BuildCLI constructs the CLI command tree. +func BuildCLI(version string) *cli.Command { + var verbose bool + + return &cli.Command{ + Name: "changeset", + Usage: "Manage versioning and releases for multi-module Go projects", + Version: version, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Enable verbose/debug output", + Destination: &verbose, + }, + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + setupLogger(verbose) + return ctx, nil + }, + Commands: []*cli.Command{ + BuildInitCommand(), + BuildAddCommand(), + BuildStatusCommand(), + BuildVersionCommand(), + BuildPublishCommand(), + BuildTagCommand(), + }, + } +} + +// BuildInitCommand creates the init command. +func BuildInitCommand() *cli.Command { + return &cli.Command{ + Name: "init", + Usage: "Initialize changeset configuration", + Description: "Creates the .changeset directory with config.json and README.md", + Action: Init, + } +} + +// BuildAddCommand creates the add command. +func BuildAddCommand() *cli.Command { + return &cli.Command{ + Name: "add", + Usage: "Create a new changeset", + Description: "Interactively create a changeset file documenting your changes.\n\n" + + "Examples:\n" + + " changeset add\n" + + " changeset add --empty\n" + + " changeset add --open\n" + + " changeset add --module libA:minor --module libB:patch --summary \"Add feature\"", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "empty", + Usage: "Create an empty changeset (for changes that don't need releases)", + }, + &cli.BoolFlag{ + Name: "open", + Usage: "Open the created changeset in your editor", + }, + &cli.StringSliceFlag{ + Name: "module", + Usage: "Module:bump pair for non-interactive mode (e.g., 'libA:minor'). Repeatable.", + }, + &cli.StringFlag{ + Name: "summary", + Aliases: []string{"m"}, + Usage: "Changeset summary (required with --module)", + }, + }, + Action: Add, + } +} + +// BuildStatusCommand creates the status command. +func BuildStatusCommand() *cli.Command { + return &cli.Command{ + Name: "status", + Usage: "Show pending changesets and version bumps", + Description: "Display information about current changesets and computed releases.\n\n" + + "Examples:\n" + + " changeset status\n" + + " changeset status --verbose\n" + + " changeset status --since=main", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Usage: "Show full changeset contents and release plan", + }, + &cli.StringFlag{ + Name: "output", + Usage: "Write JSON output to file for CI tools", + }, + &cli.StringFlag{ + Name: "since", + Usage: "Only show changesets since branch/tag", + }, + }, + Action: Status, + } +} + +// BuildVersionCommand creates the version command. +func BuildVersionCommand() *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "Update versions and changelogs from changesets", + Description: "Consumes all changesets, updates go.mod files, generates changelogs,\n" + + "and writes a release manifest for the publish step.\n\n" + + "Examples:\n" + + " changeset version\n" + + " changeset version --ignore=examples/xkafka", + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "ignore", + Usage: "Skip specific modules from versioning", + }, + &cli.BoolFlag{ + Name: "snapshot", + Usage: "Create snapshot versions for testing", + }, + }, + Action: Version, + } +} + +// BuildPublishCommand creates the publish command. +func BuildPublishCommand() *cli.Command { + return &cli.Command{ + Name: "publish", + Usage: "Create and push git tags", + Description: "Reads the release manifest and creates git tags for each module,\n" + + "then pushes them to the remote.\n\n" + + "Examples:\n" + + " changeset publish\n" + + " changeset publish --no-push", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "no-push", + Usage: "Create tags locally without pushing", + }, + }, + Action: Publish, + } +} + +// BuildTagCommand creates the tag command. +func BuildTagCommand() *cli.Command { + return &cli.Command{ + Name: "tag", + Usage: "Create git tags without pushing", + Description: "Creates git tags from the release manifest without pushing.\nUse 'changeset publish' to also push tags.", + Action: Tag, + } +} diff --git a/cmd/changeset/app/helpers.go b/cmd/changeset/app/helpers.go new file mode 100644 index 0000000..bc1f271 --- /dev/null +++ b/cmd/changeset/app/helpers.go @@ -0,0 +1,10 @@ +package app + +// displayModule returns a display-friendly module name. +// Empty string (root module) is shown as "(root)". +func displayModule(mod string) string { + if mod == "" { + return "(root)" + } + return mod +} diff --git a/cmd/changeset/app/init.go b/cmd/changeset/app/init.go new file mode 100644 index 0000000..5b8efaa --- /dev/null +++ b/cmd/changeset/app/init.go @@ -0,0 +1,48 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/module" +) + +// Init initializes the .changeset directory with config and README. +func Init(ctx context.Context, cmd *cli.Command) error { + dir := "." + + // Check if already initialized + if changeset.ChangesetDirExists(dir) { + return cli.Exit("Changeset already initialized. Run 'changeset status' to see pending changesets.", 1) + } + + // Discover root module to populate config + graph, err := module.Discover(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to discover modules: %v", err), 1) + } + + // Create config with root module path + cfg := changeset.DefaultConfig() + cfg.Root = graph.Root.Name + + // Initialize changeset directory + if err := changeset.InitChangeset(dir, cfg); err != nil { + return cli.Exit(fmt.Sprintf("Failed to initialize: %v", err), 1) + } + + slog.Info("Changeset initialized successfully", + "root_module", cfg.Root, + "config", ".changeset/config.json", + ) + slog.Info("Next steps", + "1", "Run 'changeset add' to create your first changeset", + "2", "Run 'changeset status' to see pending releases", + ) + + return nil +} diff --git a/cmd/changeset/app/publish.go b/cmd/changeset/app/publish.go new file mode 100644 index 0000000..c589295 --- /dev/null +++ b/cmd/changeset/app/publish.go @@ -0,0 +1,81 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/git" +) + +// Publish creates git tags and pushes them. +func Publish(ctx context.Context, cmd *cli.Command) error { + dir := "." + noPush := cmd.Bool("no-push") + + // Read release manifest + manifest, err := changeset.ReadManifest(dir) + if err != nil { + if err == changeset.ErrNoManifest { + return cli.Exit("No release manifest found. Run 'changeset version' first.", 1) + } + return cli.Exit(fmt.Sprintf("Failed to read manifest: %v", err), 1) + } + + if len(manifest.Releases) == 0 { + slog.Info("No releases in manifest") + return nil + } + + // Build tags list + var tags []git.Tag + for _, r := range manifest.Releases { + tags = append(tags, git.Tag{ + Name: git.FormatTag(r.Module, r.Version), + Module: r.Module, + Version: r.Version, + }) + } + + // Create tags + slog.Info("Creating git tags") + for _, tag := range tags { + if err := git.CreateTag(dir, tag.Module, tag.Version); err != nil { + // Tag might already exist from previous 'changeset tag' run + slog.Warn("Tag may already exist", "tag", tag.Name, "error", err) + } else { + slog.Info("Created tag", "tag", tag.Name, "module", displayModule(tag.Module), "version", tag.Version) + } + } + + // Push tags + if !noPush { + slog.Info("Pushing tags to origin") + if err := git.PushTags(dir, tags, "origin"); err != nil { + return cli.Exit(fmt.Sprintf("Failed to push tags: %v", err), 1) + } + slog.Info("Tags pushed successfully") + + // Delete manifest after successful push + if err := changeset.DeleteManifest(dir); err != nil { + slog.Warn("Failed to delete manifest", "error", err) + } else { + slog.Info("Release manifest cleaned up") + } + } else { + slog.Info("Tags created locally", + "hint", "To push manually: git push origin --tags", + ) + } + + // Print summary + slog.Info("Published releases", "count", len(tags)) + for _, r := range manifest.Releases { + slog.Info("Published", "module", displayModule(r.Module), "version", r.Version) + } + + return nil +} diff --git a/cmd/changeset/app/status.go b/cmd/changeset/app/status.go new file mode 100644 index 0000000..8c478e4 --- /dev/null +++ b/cmd/changeset/app/status.go @@ -0,0 +1,302 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/git" + "github.com/gojekfarm/xtools/cmd/changeset/module" +) + +// Status shows pending changesets and computed version bumps. +func Status(ctx context.Context, cmd *cli.Command) error { + dir := "." + verbose := cmd.Bool("verbose") + outputFile := cmd.String("output") + sinceRef := cmd.String("since") + + // Check if initialized + if !changeset.ChangesetDirExists(dir) { + return cli.Exit("Changeset not initialized. Run 'changeset init' first.", 1) + } + + // Read config + cfg, err := changeset.ReadConfig(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read config: %v", err), 1) + } + + // Read changesets + changesets, err := changeset.ReadChangesets(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read changesets: %v", err), 1) + } + + // If --since is provided, check for missing changesets + if sinceRef != "" { + return statusSince(dir, sinceRef, changesets, cfg, outputFile) + } + + if len(changesets) == 0 { + slog.Info("No pending changesets") + return nil + } + + // Discover modules + graph, err := module.Discover(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to discover modules: %v", err), 1) + } + + // Get current versions from git tags + tags, err := git.GetLatestVersions(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to get git tags: %v", err), 1) + } + + // Compute releases + releases, err := changeset.ComputeReleases(changesets, graph, tags, cfg) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to compute releases: %v", err), 1) + } + + // Filter ignored modules + releases = changeset.FilterIgnored(releases, cfg.Ignore) + + // Output JSON if requested + if outputFile != "" { + output := struct { + Changesets []*changeset.Changeset `json:"changesets"` + Releases []changeset.Release `json:"releases"` + }{ + Changesets: changesets, + Releases: releases, + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to marshal JSON: %v", err), 1) + } + + if err := os.WriteFile(outputFile, data, 0644); err != nil { + return cli.Exit(fmt.Sprintf("Failed to write output file: %v", err), 1) + } + + slog.Info("Output written", "file", outputFile) + return nil + } + + // Print changesets + slog.Info("Found changesets", "count", len(changesets)) + for _, cs := range changesets { + if verbose { + attrs := []any{"id", cs.ID} + for mod, bump := range cs.Modules { + attrs = append(attrs, displayModule(mod), string(bump)) + } + if cs.Summary != "" { + attrs = append(attrs, "summary", cs.Summary) + } + slog.Info("Changeset", attrs...) + } else { + slog.Info("Changeset", "id", cs.ID) + } + } + + // Print releases + if len(releases) > 0 { + slog.Info("Planned releases", "count", len(releases)) + for _, r := range releases { + slog.Info("Release", + "module", displayModule(r.Module), + "from", r.PreviousVersion, + "to", r.Version, + "bump", string(r.Bump), + "reason", r.Reason, + ) + } + } else { + slog.Info("No releases planned") + } + + return nil +} + +// statusSince checks if modules with changes since a ref have corresponding changesets. +// This is used in CI to ensure PRs include changesets for changed code. +func statusSince(dir, sinceRef string, changesets []*changeset.Changeset, cfg *changeset.Config, outputFile string) error { + // Discover modules + graph, err := module.Discover(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to discover modules: %v", err), 1) + } + + // Get changed files since ref + changedFiles, err := git.GetChangedFiles(dir, sinceRef) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to get changed files: %v", err), 1) + } + + // Map files to modules + changedModules := mapFilesToModules(changedFiles, graph, cfg) + + // Get modules covered by changesets + coveredModules := make(map[string]bool) + for _, cs := range changesets { + for mod := range cs.Modules { + coveredModules[mod] = true + } + } + + // Find modules with changes but no changeset + var missingChangesets []string + for mod := range changedModules { + if !coveredModules[mod] { + missingChangesets = append(missingChangesets, mod) + } + } + + // Output JSON if requested + if outputFile != "" { + output := struct { + ChangedModules []string `json:"changedModules"` + CoveredModules []string `json:"coveredModules"` + MissingChangesets []string `json:"missingChangesets"` + }{ + ChangedModules: mapKeys(changedModules), + CoveredModules: mapKeys(coveredModules), + MissingChangesets: missingChangesets, + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to marshal JSON: %v", err), 1) + } + + if err := os.WriteFile(outputFile, data, 0644); err != nil { + return cli.Exit(fmt.Sprintf("Failed to write output file: %v", err), 1) + } + + slog.Info("Output written", "file", outputFile) + if len(missingChangesets) > 0 { + return cli.Exit("Missing changesets for changed modules", 1) + } + return nil + } + + // Print results + slog.Info("Changes since ref", "ref", sinceRef) + + if len(changedModules) == 0 { + slog.Info("No module changes detected") + return nil + } + + slog.Info("Changed modules", "count", len(changedModules)) + for mod := range changedModules { + status := "has changeset" + if !coveredModules[mod] { + status = "missing changeset" + } + slog.Info("Module status", "module", displayModule(mod), "status", status) + } + + if len(missingChangesets) > 0 { + slog.Error("Modules have changes but no changeset", + "count", len(missingChangesets), + "hint", "Run 'changeset add' to create a changeset for your changes", + ) + return cli.Exit("Missing changesets", 1) + } + + slog.Info("All changed modules have changesets") + return nil +} + +// mapFilesToModules determines which modules are affected by the changed files. +func mapFilesToModules(files []string, graph *module.Graph, cfg *changeset.Config) map[string]bool { + modules := make(map[string]bool) + + for _, file := range files { + // Skip ignored paths + if shouldIgnorePath(file, cfg.IgnorePaths) { + continue + } + + // Find which module this file belongs to + mod := findModuleForFile(file, graph) + if mod != nil { + modules[mod.ShortName] = true + } + } + + // Remove ignored modules + for _, ignored := range cfg.Ignore { + delete(modules, ignored) + } + + return modules +} + +// findModuleForFile finds the module that contains the given file path. +func findModuleForFile(file string, graph *module.Graph) *module.Module { + // Find the most specific module (longest path match) + var bestMatch *module.Module + bestMatchLen := -1 + + for _, mod := range graph.AllModules() { + // Get relative path from repo root to module + modRelPath := mod.ShortName + if modRelPath == "" { + // Root module - matches everything not in a submodule + if bestMatch == nil { + bestMatch = mod + bestMatchLen = 0 + } + continue + } + + // Check if file is under this module's path + if strings.HasPrefix(file, modRelPath+"/") || file == modRelPath { + if len(modRelPath) > bestMatchLen { + bestMatch = mod + bestMatchLen = len(modRelPath) + } + } + } + + return bestMatch +} + +// shouldIgnorePath checks if a file path matches any ignore patterns. +func shouldIgnorePath(file string, patterns []string) bool { + for _, pattern := range patterns { + matched, err := filepath.Match(pattern, file) + if err == nil && matched { + return true + } + // Also check if any path component matches + matched, err = filepath.Match(pattern, filepath.Base(file)) + if err == nil && matched { + return true + } + } + return false +} + +// mapKeys returns the keys of a map as a sorted slice. +func mapKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/cmd/changeset/app/tag.go b/cmd/changeset/app/tag.go new file mode 100644 index 0000000..1b005ba --- /dev/null +++ b/cmd/changeset/app/tag.go @@ -0,0 +1,57 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/git" +) + +// Tag creates git tags without pushing. +func Tag(ctx context.Context, cmd *cli.Command) error { + dir := "." + + // Read release manifest + manifest, err := changeset.ReadManifest(dir) + if err != nil { + if err == changeset.ErrNoManifest { + return cli.Exit("No release manifest found. Run 'changeset version' first.", 1) + } + return cli.Exit(fmt.Sprintf("Failed to read manifest: %v", err), 1) + } + + if len(manifest.Releases) == 0 { + slog.Info("No releases in manifest") + return nil + } + + // Create tags + slog.Info("Creating git tags") + var tags []git.Tag + for _, r := range manifest.Releases { + tag := git.Tag{ + Name: git.FormatTag(r.Module, r.Version), + Module: r.Module, + Version: r.Version, + } + tags = append(tags, tag) + + if err := git.CreateTag(dir, r.Module, r.Version); err != nil { + return cli.Exit(fmt.Sprintf("Failed to create tag %s: %v", tag.Name, err), 1) + } + + slog.Info("Created tag", "tag", tag.Name, "module", displayModule(r.Module), "version", r.Version) + } + + slog.Info("Tags created", "count", len(tags)) + slog.Info("To push tags", + "option1", "changeset publish", + "option2", "git push origin --tags", + ) + + return nil +} diff --git a/cmd/changeset/app/version.go b/cmd/changeset/app/version.go new file mode 100644 index 0000000..ca71749 --- /dev/null +++ b/cmd/changeset/app/version.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "time" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/git" + "github.com/gojekfarm/xtools/cmd/changeset/module" +) + +// Version consumes changesets and updates versions. +func Version(ctx context.Context, cmd *cli.Command) error { + dir := "." + ignoreList := cmd.StringSlice("ignore") + snapshot := cmd.Bool("snapshot") + + // Check if initialized + if !changeset.ChangesetDirExists(dir) { + return cli.Exit("Changeset not initialized. Run 'changeset init' first.", 1) + } + + // Skip uncommitted changes check for snapshot mode + if !snapshot { + hasChanges, err := git.HasUncommittedChanges(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to check git status: %v", err), 1) + } + if hasChanges { + return cli.Exit("You have uncommitted changes. Please commit or stash them first.", 1) + } + } + + // Read config + cfg, err := changeset.ReadConfig(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read config: %v", err), 1) + } + + // Combine ignore lists + allIgnore := append(cfg.Ignore, ignoreList...) + + // Read changesets + changesets, err := changeset.ReadChangesets(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read changesets: %v", err), 1) + } + + if len(changesets) == 0 { + slog.Info("No changesets found, nothing to release") + return nil + } + + // Discover modules + graph, err := module.Discover(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to discover modules: %v", err), 1) + } + + // Get current versions from git tags + tags, err := git.GetLatestVersions(dir) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to get git tags: %v", err), 1) + } + + // Compute releases + releases, err := changeset.ComputeReleases(changesets, graph, tags, cfg) + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to compute releases: %v", err), 1) + } + + // Filter ignored modules + releases = changeset.FilterIgnored(releases, allIgnore) + + if len(releases) == 0 { + slog.Info("No releases to process after filtering") + return nil + } + + // Apply snapshot suffix if requested + if snapshot { + snapshotSuffix := fmt.Sprintf("-snapshot.%s", time.Now().Format("20060102150405")) + for i := range releases { + releases[i].Version = releases[i].Version + snapshotSuffix + } + slog.Info("Processing snapshot releases", "count", len(releases)) + } else { + slog.Info("Processing releases", "count", len(releases)) + } + + // Build version map for go.mod updates + versions := make(map[string]string) + for _, r := range releases { + versions[r.Module] = r.Version + } + + // Update go.mod files for each module that has internal dependencies + slog.Info("Updating go.mod files") + for _, mod := range graph.AllModules() { + if len(mod.Dependencies) == 0 { + continue + } + + gomodPath := filepath.Join(mod.Path, "go.mod") + if err := module.UpdateGoMod(gomodPath, cfg.Root, versions); err != nil { + return cli.Exit(fmt.Sprintf("Failed to update %s: %v", gomodPath, err), 1) + } + slog.Info("Updated go.mod", "path", gomodPath) + } + + // Update changelogs for each released module (skip for snapshots) + if !snapshot { + slog.Info("Updating changelogs") + for _, r := range releases { + mod := graph.FindModule(r.Module) + if mod == nil { + continue + } + + // Generate changelog entry for this module + entry := changeset.GenerateChangelog([]changeset.Release{r}, changesets) + if entry == nil { + continue + } + + changelogPath := filepath.Join(mod.Path, "CHANGELOG.md") + if err := changeset.UpdateChangelog(changelogPath, entry); err != nil { + return cli.Exit(fmt.Sprintf("Failed to update changelog for %s: %v", r.Module, err), 1) + } + + slog.Info("Updated changelog", "path", changelogPath, "module", displayModule(r.Module)) + } + + // Delete consumed changesets (skip for snapshots) + slog.Info("Deleting consumed changesets") + for _, cs := range changesets { + if err := changeset.DeleteChangeset(cs); err != nil { + slog.Warn("Failed to delete changeset", "id", cs.ID, "error", err) + } else { + slog.Info("Deleted changeset", "id", cs.ID) + } + } + } else { + slog.Info("Snapshot mode: Skipping changelog updates and changeset deletion") + } + + // Write release manifest + slog.Info("Writing release manifest") + manifest := &changeset.Manifest{Releases: releases} + if err := changeset.WriteManifest(dir, manifest); err != nil { + return cli.Exit(fmt.Sprintf("Failed to write manifest: %v", err), 1) + } + + // Print summary + summaryTitle := "Release summary" + if snapshot { + summaryTitle = "Snapshot release summary" + } + slog.Info(summaryTitle) + for _, r := range releases { + slog.Info("Release", + "module", displayModule(r.Module), + "from", r.PreviousVersion, + "to", r.Version, + "reason", r.Reason, + ) + } + + if snapshot { + slog.Info("Snapshot next steps", + "1", "Run 'changeset publish --no-push' to create snapshot tags locally", + "2", "Test the snapshot versions", + "3", "Delete snapshot tags when done: git tag -d ", + ) + } else { + slog.Info("Next steps", + "1", "Review the changes", + "2", "Commit the updated files", + "3", "Run 'changeset publish' to create and push git tags", + ) + } + + return nil +} diff --git a/cmd/changeset/changeset/changelog.go b/cmd/changeset/changeset/changelog.go new file mode 100644 index 0000000..098dbda --- /dev/null +++ b/cmd/changeset/changeset/changelog.go @@ -0,0 +1,222 @@ +package changeset + +import ( + "fmt" + "os" + "sort" + "strings" + "time" +) + +// ChangelogEntry represents a single changelog entry. +type ChangelogEntry struct { + Version string + Date time.Time + Changes map[Bump][]string // Bump type -> list of summaries +} + +// GenerateChangelog creates a changelog entry from releases and changesets. +// It groups changes by bump type for better readability. +func GenerateChangelog(releases []Release, changesets []*Changeset) *ChangelogEntry { + if len(releases) == 0 { + return nil + } + + // Use the first release version as the entry version + // (assuming all releases in a batch have the same version pattern) + version := releases[0].Version + + entry := &ChangelogEntry{ + Version: version, + Date: time.Now(), + Changes: make(map[Bump][]string), + } + + // Collect all unique summaries + summarySet := make(map[string]bool) + for _, cs := range changesets { + if cs.Summary != "" && !summarySet[cs.Summary] { + summarySet[cs.Summary] = true + // Find the highest bump in this changeset to categorize it + highestBump := BumpPatch + for _, bump := range cs.Modules { + if bump.Compare(highestBump) > 0 { + highestBump = bump + } + } + entry.Changes[highestBump] = append(entry.Changes[highestBump], cs.Summary) + } + } + + return entry +} + +// FormatChangelogEntry formats a changelog entry as markdown. +func FormatChangelogEntry(entry *ChangelogEntry) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## %s (%s)\n\n", entry.Version, entry.Date.Format("2006-01-02"))) + + // Order: major, minor, patch + bumpOrder := []Bump{BumpMajor, BumpMinor, BumpPatch} + bumpHeaders := map[Bump]string{ + BumpMajor: "### Breaking Changes", + BumpMinor: "### Features", + BumpPatch: "### Bug Fixes", + } + + for _, bump := range bumpOrder { + changes := entry.Changes[bump] + if len(changes) == 0 { + continue + } + + sb.WriteString(bumpHeaders[bump] + "\n\n") + for _, change := range changes { + // Handle multi-line summaries + lines := strings.Split(change, "\n") + for i, line := range lines { + if i == 0 { + sb.WriteString("- " + line + "\n") + } else if strings.TrimSpace(line) != "" { + sb.WriteString(" " + line + "\n") + } + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +// UpdateChangelog prepends a new entry to an existing CHANGELOG.md. +// Creates the file if it doesn't exist. +func UpdateChangelog(path string, entry *ChangelogEntry) error { + newContent := FormatChangelogEntry(entry) + + // Read existing content + existingContent, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading changelog: %w", err) + } + + var finalContent string + if len(existingContent) == 0 { + // New file - add header + finalContent = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + newContent + } else { + // Prepend to existing content after the header + existing := string(existingContent) + // Find where to insert (after the title and intro) + insertPos := 0 + lines := strings.Split(existing, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "## ") { + // Found first version entry + insertPos = strings.Index(existing, line) + break + } + if i == len(lines)-1 { + // No version entries found, append to end + insertPos = len(existing) + } + } + + if insertPos > 0 { + finalContent = existing[:insertPos] + newContent + existing[insertPos:] + } else { + finalContent = existing + "\n" + newContent + } + } + + if err := os.WriteFile(path, []byte(finalContent), 0644); err != nil { + return fmt.Errorf("writing changelog: %w", err) + } + + return nil +} + +// ParseChangelog reads an existing CHANGELOG.md. +// Returns entries sorted by version (newest first). +func ParseChangelog(path string) ([]ChangelogEntry, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading changelog: %w", err) + } + + var entries []ChangelogEntry + var currentEntry *ChangelogEntry + var currentBump Bump + var currentChanges []string + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "## ") { + // Save previous entry + if currentEntry != nil { + if len(currentChanges) > 0 && currentBump != "" { + currentEntry.Changes[currentBump] = currentChanges + } + entries = append(entries, *currentEntry) + } + + // Parse version header: "## v1.0.0 (2024-01-01)" + header := strings.TrimPrefix(line, "## ") + parts := strings.SplitN(header, " ", 2) + version := parts[0] + + var date time.Time + if len(parts) > 1 { + dateStr := strings.Trim(parts[1], "()") + date, _ = time.Parse("2006-01-02", dateStr) + } + + currentEntry = &ChangelogEntry{ + Version: version, + Date: date, + Changes: make(map[Bump][]string), + } + currentBump = "" + currentChanges = nil + } else if strings.HasPrefix(line, "### ") { + // Save changes for previous bump type + if currentEntry != nil && len(currentChanges) > 0 && currentBump != "" { + currentEntry.Changes[currentBump] = currentChanges + currentChanges = nil + } + + // Parse bump type header + header := strings.TrimPrefix(line, "### ") + switch header { + case "Breaking Changes": + currentBump = BumpMajor + case "Features": + currentBump = BumpMinor + case "Bug Fixes": + currentBump = BumpPatch + default: + currentBump = "" + } + } else if strings.HasPrefix(line, "- ") && currentEntry != nil && currentBump != "" { + currentChanges = append(currentChanges, strings.TrimPrefix(line, "- ")) + } + } + + // Save last entry + if currentEntry != nil { + if len(currentChanges) > 0 && currentBump != "" { + currentEntry.Changes[currentBump] = currentChanges + } + entries = append(entries, *currentEntry) + } + + // Sort by version (newest first) + sort.Slice(entries, func(i, j int) bool { + return entries[i].Version > entries[j].Version + }) + + return entries, nil +} diff --git a/cmd/changeset/changeset/changelog_test.go b/cmd/changeset/changeset/changelog_test.go new file mode 100644 index 0000000..81d65bb --- /dev/null +++ b/cmd/changeset/changeset/changelog_test.go @@ -0,0 +1,169 @@ +package changeset + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatChangelogEntry(t *testing.T) { + tests := []struct { + name string + entry *ChangelogEntry + contains []string + excludes []string + }{ + { + name: "patch only", + entry: &ChangelogEntry{ + Version: "v1.0.0", + Date: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC), + Changes: map[Bump][]string{ + BumpPatch: {"Fixed a bug"}, + }, + }, + contains: []string{"## v1.0.0", "2024-01-15", "### Bug Fixes", "Fixed a bug"}, + excludes: []string{"### Features", "### Breaking Changes"}, + }, + { + name: "minor only", + entry: &ChangelogEntry{ + Version: "v1.1.0", + Date: time.Date(2024, 2, 20, 0, 0, 0, 0, time.UTC), + Changes: map[Bump][]string{ + BumpMinor: {"Added new feature"}, + }, + }, + contains: []string{"## v1.1.0", "### Features", "Added new feature"}, + excludes: []string{"### Bug Fixes", "### Breaking Changes"}, + }, + { + name: "major only", + entry: &ChangelogEntry{ + Version: "v2.0.0", + Date: time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC), + Changes: map[Bump][]string{ + BumpMajor: {"Breaking API change"}, + }, + }, + contains: []string{"## v2.0.0", "### Breaking Changes", "Breaking API change"}, + excludes: []string{"### Features", "### Bug Fixes"}, + }, + { + name: "mixed changes", + entry: &ChangelogEntry{ + Version: "v2.1.1", + Date: time.Date(2024, 4, 5, 0, 0, 0, 0, time.UTC), + Changes: map[Bump][]string{ + BumpMajor: {"Breaking change"}, + BumpMinor: {"New feature"}, + BumpPatch: {"Bug fix"}, + }, + }, + contains: []string{ + "### Breaking Changes", + "### Features", + "### Bug Fixes", + "Breaking change", + "New feature", + "Bug fix", + }, + }, + { + name: "multiple changes per type", + entry: &ChangelogEntry{ + Version: "v1.0.1", + Date: time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC), + Changes: map[Bump][]string{ + BumpPatch: {"Fix one", "Fix two", "Fix three"}, + }, + }, + contains: []string{"- Fix one", "- Fix two", "- Fix three"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatChangelogEntry(tt.entry) + + for _, want := range tt.contains { + assert.Contains(t, got, want) + } + + for _, exclude := range tt.excludes { + assert.NotContains(t, got, exclude) + } + }) + } +} + +func TestFormatChangelogEntryOrder(t *testing.T) { + entry := &ChangelogEntry{ + Version: "v1.0.0", + Date: time.Now(), + Changes: map[Bump][]string{ + BumpMajor: {"Major change"}, + BumpMinor: {"Minor change"}, + BumpPatch: {"Patch change"}, + }, + } + + got := FormatChangelogEntry(entry) + + majorIdx := strings.Index(got, "### Breaking Changes") + minorIdx := strings.Index(got, "### Features") + patchIdx := strings.Index(got, "### Bug Fixes") + + require.NotEqual(t, -1, majorIdx, "missing Breaking Changes header") + require.NotEqual(t, -1, minorIdx, "missing Features header") + require.NotEqual(t, -1, patchIdx, "missing Bug Fixes header") + + assert.Less(t, majorIdx, minorIdx, "Breaking Changes should come before Features") + assert.Less(t, minorIdx, patchIdx, "Features should come before Bug Fixes") +} + +func TestGenerateChangelog(t *testing.T) { + releases := []Release{ + {Module: "pkg", Version: "v1.0.0", Bump: BumpMinor}, + } + changesets := []*Changeset{ + { + Modules: map[string]Bump{"pkg": BumpMinor}, + Summary: "Added new feature", + }, + } + + entry := GenerateChangelog(releases, changesets) + + require.NotNil(t, entry) + assert.Equal(t, "v1.0.0", entry.Version) + assert.Len(t, entry.Changes[BumpMinor], 1) +} + +func TestGenerateChangelogEmpty(t *testing.T) { + entry := GenerateChangelog([]Release{}, []*Changeset{}) + assert.Nil(t, entry) +} + +func TestGenerateChangelogDeduplicates(t *testing.T) { + releases := []Release{ + {Module: "pkg", Version: "v1.0.0", Bump: BumpMinor}, + } + changesets := []*Changeset{ + {Modules: map[string]Bump{"pkg": BumpMinor}, Summary: "Same change"}, + {Modules: map[string]Bump{"pkg": BumpMinor}, Summary: "Same change"}, + } + + entry := GenerateChangelog(releases, changesets) + + require.NotNil(t, entry) + + total := 0 + for _, changes := range entry.Changes { + total += len(changes) + } + assert.Equal(t, 1, total, "should deduplicate identical summaries") +} diff --git a/cmd/changeset/changeset/changeset b/cmd/changeset/changeset/changeset new file mode 100755 index 0000000..eee9b8b Binary files /dev/null and b/cmd/changeset/changeset/changeset differ diff --git a/cmd/changeset/changeset/changeset.go b/cmd/changeset/changeset/changeset.go new file mode 100644 index 0000000..53178fb --- /dev/null +++ b/cmd/changeset/changeset/changeset.go @@ -0,0 +1,254 @@ +// Package changeset provides core types and operations for managing changesets. +package changeset + +import ( + "bufio" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Bump represents a semantic version bump type. +type Bump string + +const ( + BumpPatch Bump = "patch" + BumpMinor Bump = "minor" + BumpMajor Bump = "major" +) + +// Compare returns: +// +// -1 if a < b +// 0 if a == b +// 1 if a > b +// +// Ordering: patch < minor < major +func (a Bump) Compare(b Bump) int { + order := map[Bump]int{ + BumpPatch: 0, + BumpMinor: 1, + BumpMajor: 2, + } + aVal, bVal := order[a], order[b] + if aVal < bVal { + return -1 + } + if aVal > bVal { + return 1 + } + return 0 +} + +// String returns the string representation of the bump. +func (a Bump) String() string { + return string(a) +} + +// Changeset represents a single changeset file. +type Changeset struct { + ID string // Filename without extension (e.g., "hungry-tiger-jump") + Modules map[string]Bump // Module short name -> bump type + Summary string // Markdown description + FilePath string // Full path to the file +} + +// ParseChangeset reads and parses a single changeset file. +// Returns error if file format is invalid. +func ParseChangeset(path string) (*Changeset, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening changeset: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + // First line must be --- + if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" { + return nil, fmt.Errorf("%w: missing opening ---", ErrInvalidChangeset) + } + + // Read YAML frontmatter until closing --- + var yamlLines []string + foundClose := false + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + foundClose = true + break + } + yamlLines = append(yamlLines, line) + } + + if !foundClose { + return nil, fmt.Errorf("%w: missing closing ---", ErrInvalidChangeset) + } + + // Parse YAML + yamlContent := strings.Join(yamlLines, "\n") + modules := make(map[string]Bump) + if yamlContent != "" { + var rawModules map[string]string + if err := yaml.Unmarshal([]byte(yamlContent), &rawModules); err != nil { + return nil, fmt.Errorf("%w: invalid YAML: %v", ErrInvalidChangeset, err) + } + + for mod, bump := range rawModules { + switch Bump(bump) { + case BumpPatch, BumpMinor, BumpMajor: + modules[mod] = Bump(bump) + default: + return nil, fmt.Errorf("%w: invalid bump type %q for module %q", ErrInvalidChangeset, bump, mod) + } + } + } + + // Read summary (everything after the closing ---) + var summaryLines []string + for scanner.Scan() { + summaryLines = append(summaryLines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading changeset: %w", err) + } + + summary := strings.TrimSpace(strings.Join(summaryLines, "\n")) + + // Extract ID from filename + id := strings.TrimSuffix(filepath.Base(path), ".md") + + return &Changeset{ + ID: id, + Modules: modules, + Summary: summary, + FilePath: path, + }, nil +} + +// ReadChangesets reads all changeset files from the .changeset directory. +// Skips config.json, README.md, and release-manifest.json. +func ReadChangesets(dir string) ([]*Changeset, error) { + changesetDir := filepath.Join(dir, ".changeset") + entries, err := os.ReadDir(changesetDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading .changeset directory: %w", err) + } + + var changesets []*Changeset + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + // Skip non-changeset files + if !strings.HasSuffix(name, ".md") { + continue + } + if name == "README.md" { + continue + } + + path := filepath.Join(changesetDir, name) + cs, err := ParseChangeset(path) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", name, err) + } + changesets = append(changesets, cs) + } + + return changesets, nil +} + +// WriteChangeset writes a changeset to the .changeset directory. +// Generates a random ID if cs.ID is empty. +func WriteChangeset(dir string, cs *Changeset) error { + changesetDir := filepath.Join(dir, ".changeset") + + // Ensure directory exists + if err := os.MkdirAll(changesetDir, 0755); err != nil { + return fmt.Errorf("creating .changeset directory: %w", err) + } + + // Generate ID if needed + if cs.ID == "" { + cs.ID = GenerateID() + } + + // Build content + var content strings.Builder + content.WriteString("---\n") + + // Write modules as YAML + if len(cs.Modules) > 0 { + for mod, bump := range cs.Modules { + content.WriteString(fmt.Sprintf("%q: %s\n", mod, bump)) + } + } + + content.WriteString("---\n\n") + content.WriteString(cs.Summary) + content.WriteString("\n") + + // Write file + path := filepath.Join(changesetDir, cs.ID+".md") + if err := os.WriteFile(path, []byte(content.String()), 0644); err != nil { + return fmt.Errorf("writing changeset: %w", err) + } + + cs.FilePath = path + return nil +} + +// DeleteChangeset removes a changeset file. +func DeleteChangeset(cs *Changeset) error { + if cs.FilePath == "" { + return fmt.Errorf("changeset has no file path") + } + if err := os.Remove(cs.FilePath); err != nil { + return fmt.Errorf("deleting changeset: %w", err) + } + return nil +} + +// Word lists for ID generation (similar to changesets package) +var ( + adjectives = []string{ + "angry", "brave", "calm", "dark", "eager", "fair", "gentle", "happy", + "icy", "jolly", "keen", "lively", "merry", "nice", "odd", "proud", + "quick", "rare", "shy", "tall", "unique", "vast", "warm", "young", + "bright", "clever", "fancy", "golden", "honest", "lucky", "mighty", + "noble", "orange", "purple", "quiet", "rapid", "silent", "tender", + "violet", "witty", "hungry", "fuzzy", "grumpy", "sleepy", "silly", + } + nouns = []string{ + "ant", "bee", "cat", "dog", "elk", "fox", "goat", "hawk", "ibis", + "jay", "kite", "lion", "mouse", "newt", "owl", "panda", "quail", + "rabbit", "snake", "tiger", "urchin", "viper", "wolf", "yak", "zebra", + "bear", "crane", "dove", "eagle", "frog", "gecko", "horse", "iguana", + "koala", "lemur", "moose", "otter", "parrot", "seal", "turtle", "whale", + } + verbs = []string{ + "jump", "run", "walk", "fly", "swim", "dance", "sing", "play", + "read", "write", "think", "dream", "sleep", "wake", "eat", "drink", + "laugh", "smile", "wave", "spin", "twist", "turn", "leap", "skip", + "hop", "bounce", "glide", "soar", "dive", "climb", "crawl", "roll", + } +) + +// GenerateID returns a random changeset ID (e.g., "hungry-tiger-jump"). +func GenerateID() string { + adj := adjectives[rand.Intn(len(adjectives))] + noun := nouns[rand.Intn(len(nouns))] + verb := verbs[rand.Intn(len(verbs))] + return fmt.Sprintf("%s-%s-%s", adj, noun, verb) +} diff --git a/cmd/changeset/changeset/changeset_test.go b/cmd/changeset/changeset/changeset_test.go new file mode 100644 index 0000000..304b971 --- /dev/null +++ b/cmd/changeset/changeset/changeset_test.go @@ -0,0 +1,130 @@ +package changeset + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestBumpCompare(t *testing.T) { + tests := []struct { + a, b Bump + want int + }{ + {BumpPatch, BumpMinor, -1}, + {BumpMinor, BumpMajor, -1}, + {BumpPatch, BumpMajor, -1}, + {BumpMajor, BumpMinor, 1}, + {BumpMinor, BumpPatch, 1}, + {BumpMajor, BumpPatch, 1}, + {BumpPatch, BumpPatch, 0}, + {BumpMinor, BumpMinor, 0}, + {BumpMajor, BumpMajor, 0}, + } + + for _, tt := range tests { + name := string(tt.a) + "_vs_" + string(tt.b) + t.Run(name, func(t *testing.T) { + got := tt.a.Compare(tt.b) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBumpString(t *testing.T) { + tests := []struct { + bump Bump + want string + }{ + {BumpPatch, "patch"}, + {BumpMinor, "minor"}, + {BumpMajor, "major"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, tt.bump.String()) + }) + } +} + +func TestGenerateID(t *testing.T) { + for range 10 { + id := GenerateID() + parts := strings.Split(id, "-") + assert.Len(t, parts, 3, "GenerateID() = %q should have 3 parts", id) + for _, part := range parts { + assert.NotEmpty(t, part, "GenerateID() = %q has empty part", id) + } + } +} + +func TestGenerateIDUniqueness(t *testing.T) { + seen := make(map[string]bool) + for range 100 { + id := GenerateID() + if seen[id] { + t.Logf("duplicate ID generated: %s (this is probabilistic)", id) + } + seen[id] = true + } +} + +func TestParseChangeset(t *testing.T) { + dir := testutil.SetupTestRepo(t) + path := filepath.Join(dir, ".changeset", "happy-tiger-jump.md") + + cs, err := ParseChangeset(path) + require.NoError(t, err) + + assert.Equal(t, "happy-tiger-jump", cs.ID) + assert.Len(t, cs.Modules, 1) + assert.Equal(t, BumpMinor, cs.Modules["libA"]) + assert.Contains(t, cs.Summary, "greeting") +} + +func TestParseChangesetInvalid(t *testing.T) { + tests := []struct { + name string + content string + }{ + {"missing opening", "some content\n---\n"}, + {"missing closing", "---\nkey: value\n"}, + {"invalid yaml", "---\n: invalid\n---\n"}, + {"invalid bump", "---\n\"mod\": invalid\n---\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.md") + err := os.WriteFile(path, []byte(tt.content), 0644) + require.NoError(t, err) + + _, err = ParseChangeset(path) + assert.Error(t, err) + }) + } +} + +func TestReadChangesets(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + changesets, err := ReadChangesets(dir) + require.NoError(t, err) + + assert.Len(t, changesets, 1) + assert.Equal(t, "happy-tiger-jump", changesets[0].ID) +} + +func TestReadChangesetsNonExistent(t *testing.T) { + changesets, err := ReadChangesets("/nonexistent") + assert.NoError(t, err) + assert.Nil(t, changesets) +} diff --git a/cmd/changeset/changeset/config.go b/cmd/changeset/changeset/config.go new file mode 100644 index 0000000..cc36351 --- /dev/null +++ b/cmd/changeset/changeset/config.go @@ -0,0 +1,134 @@ +package changeset + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Config represents .changeset/config.json. +type Config struct { + Root string `json:"root"` // Root module path + BaseBranch string `json:"baseBranch"` // Default: "main" + Ignore []string `json:"ignore"` // Modules to ignore + IgnorePaths []string `json:"ignorePaths"` // File paths to ignore in CI check + DependentBump Bump `json:"dependentBump"` // How to bump dependents (default: "patch") +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + BaseBranch: "main", + DependentBump: BumpPatch, + } +} + +// ReadConfig reads .changeset/config.json. +// Returns DefaultConfig if file doesn't exist. +func ReadConfig(dir string) (*Config, error) { + configPath := filepath.Join(dir, ".changeset", "config.json") + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + return nil, fmt.Errorf("reading config: %w", err) + } + + cfg := DefaultConfig() + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + return cfg, nil +} + +// WriteConfig writes config to .changeset/config.json. +func WriteConfig(dir string, cfg *Config) error { + changesetDir := filepath.Join(dir, ".changeset") + + // Ensure directory exists + if err := os.MkdirAll(changesetDir, 0755); err != nil { + return fmt.Errorf("creating .changeset directory: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + configPath := filepath.Join(changesetDir, "config.json") + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + return nil +} + +const readmeContent = `# Changesets + +This directory contains changeset files that describe changes to the codebase. + +## What is a changeset? + +A changeset is a file that describes which packages should be released and how +(major, minor, or patch). When it's time to release, these changesets are +consumed to determine version bumps. + +## Creating a changeset + +Run: +` + "```" + ` +changeset add +` + "```" + ` + +This will interactively create a changeset file. + +## File format + +` + "```" + `markdown +--- +"package-name": minor +"other-package": patch +--- + +Description of the changes. +` + "```" + ` +` + +// InitChangeset creates the .changeset directory with config and README. +func InitChangeset(dir string, cfg *Config) error { + changesetDir := filepath.Join(dir, ".changeset") + + // Check if already initialized + if _, err := os.Stat(changesetDir); err == nil { + return fmt.Errorf(".changeset directory already exists") + } + + // Create directory + if err := os.MkdirAll(changesetDir, 0755); err != nil { + return fmt.Errorf("creating .changeset directory: %w", err) + } + + // Write config + if err := WriteConfig(dir, cfg); err != nil { + return err + } + + // Write README + readmePath := filepath.Join(changesetDir, "README.md") + if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil { + return fmt.Errorf("writing README: %w", err) + } + + return nil +} + +// ChangesetDirExists returns true if the .changeset directory exists. +func ChangesetDirExists(dir string) bool { + changesetDir := filepath.Join(dir, ".changeset") + _, err := os.Stat(changesetDir) + return err == nil +} diff --git a/cmd/changeset/changeset/config_test.go b/cmd/changeset/changeset/config_test.go new file mode 100644 index 0000000..3c03ef8 --- /dev/null +++ b/cmd/changeset/changeset/config_test.go @@ -0,0 +1,60 @@ +package changeset + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + assert.Equal(t, "main", cfg.BaseBranch) + assert.Equal(t, BumpPatch, cfg.DependentBump) + assert.Empty(t, cfg.Root) + assert.Empty(t, cfg.Ignore) + assert.Empty(t, cfg.IgnorePaths) +} + +func TestReadConfig(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + cfg, err := ReadConfig(dir) + require.NoError(t, err) + + assert.Equal(t, "github.com/test/fakerepo", cfg.Root) + assert.Equal(t, "main", cfg.BaseBranch) + assert.Equal(t, BumpPatch, cfg.DependentBump) +} + +func TestReadConfigNonExistent(t *testing.T) { + cfg, err := ReadConfig("/nonexistent/path") + require.NoError(t, err, "should return default config for non-existent directory") + + def := DefaultConfig() + assert.Equal(t, def.BaseBranch, cfg.BaseBranch) + assert.Equal(t, def.DependentBump, cfg.DependentBump) +} + +func TestChangesetDirExists(t *testing.T) { + repoDir := testutil.SetupTestRepo(t) + + tests := []struct { + name string + dir string + want bool + }{ + {"existing", repoDir, true}, + {"nonexistent", "/nonexistent/path", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ChangesetDirExists(tt.dir) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/changeset/changeset/errors.go b/cmd/changeset/changeset/errors.go new file mode 100644 index 0000000..e8800a1 --- /dev/null +++ b/cmd/changeset/changeset/errors.go @@ -0,0 +1,12 @@ +package changeset + +import "errors" + +// Sentinel errors for changeset operations. +var ( + ErrNoChangesets = errors.New("no changesets found") + ErrNoManifest = errors.New("release manifest not found") + ErrInvalidChangeset = errors.New("invalid changeset format") + ErrInvalidVersion = errors.New("invalid semantic version") + ErrUncommitted = errors.New("uncommitted changes exist") +) diff --git a/cmd/changeset/changeset/manifest.go b/cmd/changeset/changeset/manifest.go new file mode 100644 index 0000000..3bf9fb2 --- /dev/null +++ b/cmd/changeset/changeset/manifest.go @@ -0,0 +1,86 @@ +package changeset + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Release represents a computed release for a module. +type Release struct { + Module string `json:"module"` + Version string `json:"version"` + PreviousVersion string `json:"previousVersion"` + Bump Bump `json:"bump"` + Reason string `json:"reason,omitempty"` // "dependency" if auto-bumped +} + +// Manifest represents .changeset/release-manifest.json. +type Manifest struct { + Releases []Release `json:"releases"` +} + +const manifestFileName = "release-manifest.json" + +// ReadManifest reads .changeset/release-manifest.json. +// Returns error if file doesn't exist. +func ReadManifest(dir string) (*Manifest, error) { + manifestPath := filepath.Join(dir, ".changeset", manifestFileName) + + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNoManifest + } + return nil, fmt.Errorf("reading manifest: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + + return &manifest, nil +} + +// WriteManifest writes the manifest to .changeset/release-manifest.json. +func WriteManifest(dir string, m *Manifest) error { + changesetDir := filepath.Join(dir, ".changeset") + + // Ensure directory exists + if err := os.MkdirAll(changesetDir, 0755); err != nil { + return fmt.Errorf("creating .changeset directory: %w", err) + } + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + + manifestPath := filepath.Join(changesetDir, manifestFileName) + if err := os.WriteFile(manifestPath, data, 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + return nil +} + +// DeleteManifest removes .changeset/release-manifest.json. +func DeleteManifest(dir string) error { + manifestPath := filepath.Join(dir, ".changeset", manifestFileName) + if err := os.Remove(manifestPath); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("deleting manifest: %w", err) + } + return nil +} + +// ManifestExists returns true if release-manifest.json exists. +func ManifestExists(dir string) bool { + manifestPath := filepath.Join(dir, ".changeset", manifestFileName) + _, err := os.Stat(manifestPath) + return err == nil +} diff --git a/cmd/changeset/changeset/version.go b/cmd/changeset/changeset/version.go new file mode 100644 index 0000000..7016b8e --- /dev/null +++ b/cmd/changeset/changeset/version.go @@ -0,0 +1,143 @@ +package changeset + +import ( + "fmt" + "sort" + + "github.com/Masterminds/semver/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/module" +) + +// ComputeReleases calculates version bumps from changesets. +// +// Algorithm: +// 1. Aggregate bumps per module (highest bump wins) +// 2. Get current versions from git tags +// 3. Cascade bumps to dependent modules +// 4. Compute next versions +// +// Parameters: +// - changesets: All pending changesets +// - graph: Module dependency graph +// - tags: Current git tags (module short name -> version) +// - cfg: Config for dependent bump behavior +// +// Returns releases sorted by module name. +func ComputeReleases( + changesets []*Changeset, + graph *module.Graph, + tags map[string]string, + cfg *Config, +) ([]Release, error) { + // Step 1: Aggregate explicit bumps (highest bump wins) + bumps := make(map[string]Bump) + for _, cs := range changesets { + for mod, bump := range cs.Modules { + existing, ok := bumps[mod] + if !ok || bump.Compare(existing) > 0 { + bumps[mod] = bump + } + } + } + + // Track reasons for bumps + reasons := make(map[string]string) + + // Step 2: Topological sort and cascade to dependents + sorted := graph.TopologicalSort() + + for _, mod := range sorted { + bump, hasBump := bumps[mod.ShortName] + if !hasBump { + continue + } + + // Cascade to modules that depend on this one + for _, dependent := range graph.Dependents(mod.ShortName) { + if _, alreadyBumped := bumps[dependent.ShortName]; !alreadyBumped { + bumps[dependent.ShortName] = cfg.DependentBump + reasons[dependent.ShortName] = "dependency" + } + } + + // Also mark this module as explicitly bumped if needed + if _, hasReason := reasons[mod.ShortName]; !hasReason && bump != "" { + // Explicit bump, no special reason + } + } + + // Step 3: Compute versions + var releases []Release + for shortName, bump := range bumps { + // Skip if module not in graph (might be ignored or invalid) + if graph.FindModule(shortName) == nil { + continue + } + + // Get current version from tags + currentVersion := tags[shortName] + if currentVersion == "" { + currentVersion = "v0.0.0" + } + + // Compute next version + nextVersion, err := IncrementVersion(currentVersion, bump) + if err != nil { + return nil, fmt.Errorf("computing version for %s: %w", shortName, err) + } + + releases = append(releases, Release{ + Module: shortName, + Version: nextVersion, + PreviousVersion: currentVersion, + Bump: bump, + Reason: reasons[shortName], + }) + } + + // Sort by module name + sort.Slice(releases, func(i, j int) bool { + return releases[i].Module < releases[j].Module + }) + + return releases, nil +} + +// IncrementVersion bumps a semantic version by the given bump type. +func IncrementVersion(current string, bump Bump) (string, error) { + v, err := semver.NewVersion(current) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidVersion, err) + } + + var next semver.Version + switch bump { + case BumpMajor: + next = v.IncMajor() + case BumpMinor: + next = v.IncMinor() + case BumpPatch: + next = v.IncPatch() + default: + return "", fmt.Errorf("%w: unknown bump type %q", ErrInvalidVersion, bump) + } + + return "v" + next.String(), nil +} + +// FilterIgnored removes ignored modules from the releases list. +func FilterIgnored(releases []Release, ignore []string) []Release { + ignoreSet := make(map[string]bool) + for _, mod := range ignore { + ignoreSet[mod] = true + } + + var filtered []Release + for _, r := range releases { + if !ignoreSet[r.Module] { + filtered = append(filtered, r) + } + } + return filtered +} diff --git a/cmd/changeset/changeset/version_test.go b/cmd/changeset/changeset/version_test.go new file mode 100644 index 0000000..8172586 --- /dev/null +++ b/cmd/changeset/changeset/version_test.go @@ -0,0 +1,96 @@ +package changeset + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIncrementVersion(t *testing.T) { + tests := []struct { + name string + current string + bump Bump + want string + wantErr bool + }{ + {"patch from zero", "v0.0.0", BumpPatch, "v0.0.1", false}, + {"minor from zero", "v0.0.0", BumpMinor, "v0.1.0", false}, + {"major from zero", "v0.0.0", BumpMajor, "v1.0.0", false}, + {"patch", "v1.2.3", BumpPatch, "v1.2.4", false}, + {"minor", "v1.2.3", BumpMinor, "v1.3.0", false}, + {"major", "v1.2.3", BumpMajor, "v2.0.0", false}, + {"patch double digit", "v0.10.5", BumpPatch, "v0.10.6", false}, + {"minor resets patch", "v1.2.3", BumpMinor, "v1.3.0", false}, + {"major resets all", "v1.2.3", BumpMajor, "v2.0.0", false}, + {"invalid version", "invalid", BumpPatch, "", true}, + // Note: semver library accepts versions without v prefix and normalizes output + {"without v prefix", "1.0.0", BumpPatch, "v1.0.1", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IncrementVersion(tt.current, tt.bump) + if tt.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidVersion) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFilterIgnored(t *testing.T) { + releases := []Release{ + {Module: "a", Version: "v1.0.0"}, + {Module: "b", Version: "v1.0.0"}, + {Module: "c", Version: "v1.0.0"}, + } + + tests := []struct { + name string + ignore []string + wantModules []string + }{ + {"no ignore", []string{}, []string{"a", "b", "c"}}, + {"ignore one", []string{"a"}, []string{"b", "c"}}, + {"ignore two", []string{"a", "c"}, []string{"b"}}, + {"ignore nonexistent", []string{"d"}, []string{"a", "b", "c"}}, + {"ignore all", []string{"a", "b", "c"}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FilterIgnored(releases, tt.ignore) + + var gotModules []string + for _, r := range got { + gotModules = append(gotModules, r.Module) + } + + if tt.wantModules == nil { + assert.Empty(t, gotModules) + } else { + assert.Equal(t, tt.wantModules, gotModules) + } + }) + } +} + +func TestFilterIgnoredPreservesOrder(t *testing.T) { + releases := []Release{ + {Module: "z", Version: "v1.0.0"}, + {Module: "a", Version: "v1.0.0"}, + {Module: "m", Version: "v1.0.0"}, + } + + got := FilterIgnored(releases, []string{}) + + expected := []string{"z", "a", "m"} + for i, r := range got { + assert.Equal(t, expected[i], r.Module, "order should be preserved at index %d", i) + } +} diff --git a/cmd/changeset/e2e/add_test.go b/cmd/changeset/e2e/add_test.go new file mode 100644 index 0000000..f5c1be9 --- /dev/null +++ b/cmd/changeset/e2e/add_test.go @@ -0,0 +1,100 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_Add_Empty(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "add", "--empty") + + require.Equal(t, 0, result.ExitCode, "add --empty should succeed") + require.NoError(t, result.Err) + + // Verify changeset was created (should have 2 now: original + new empty) + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Len(t, changesets, 2) +} + +func TestE2E_Add_NonInteractive(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, + "add", + "--module", "libA:minor", + "--module", "libB:patch", + "--summary", "Test changeset from e2e", + ) + + require.Equal(t, 0, result.ExitCode, "add with flags should succeed") + require.NoError(t, result.Err) + + // Verify content + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + + // Find the new changeset (not the original happy-tiger-jump) + var newCS *changeset.Changeset + for _, cs := range changesets { + if cs.ID != "happy-tiger-jump" { + newCS = cs + break + } + } + + require.NotNil(t, newCS, "should have created a new changeset") + assert.Equal(t, changeset.BumpMinor, newCS.Modules["libA"]) + assert.Equal(t, changeset.BumpPatch, newCS.Modules["libB"]) + assert.Contains(t, newCS.Summary, "Test changeset from e2e") +} + +func TestE2E_Add_NonInteractive_InvalidBump(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, + "add", + "--module", "libA:invalid", + "--summary", "Test", + ) + + assert.Equal(t, 1, result.ExitCode, "add with invalid bump should fail") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "invalid bump type") + } +} + +func TestE2E_Add_NonInteractive_InvalidFormat(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, + "add", + "--module", "libA", // Missing :bump + "--summary", "Test", + ) + + assert.Equal(t, 1, result.ExitCode, "add with invalid format should fail") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "invalid module:bump format") + } +} + +func TestE2E_Add_NotInitialized(t *testing.T) { + dir := setupTestRepoUninitialized(t) + + result := runCLI(t, dir, "add", "--empty") + + assert.Equal(t, 1, result.ExitCode, "add should fail when not initialized") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "not initialized") + } +} diff --git a/cmd/changeset/e2e/helpers_test.go b/cmd/changeset/e2e/helpers_test.go new file mode 100644 index 0000000..3e43e4e --- /dev/null +++ b/cmd/changeset/e2e/helpers_test.go @@ -0,0 +1,91 @@ +//go:build e2e + +package e2e + +import ( + "os" + "path/filepath" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/gojekfarm/xtools/cmd/changeset/app" + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +// buildCLI returns the CLI command tree for testing. +func buildCLI() *cli.Command { + return app.BuildCLI("test") +} + +// runCLI is a convenience wrapper around testutil.RunCLI. +func runCLI(t *testing.T, dir string, args ...string) testutil.CLIResult { + t.Helper() + return testutil.RunCLI(t, dir, buildCLI(), args) +} + +// createChangeset creates a changeset file programmatically. +func createChangeset(t *testing.T, dir string, modules map[string]string, summary string) string { + t.Helper() + + cs := &changeset.Changeset{ + Modules: make(map[string]changeset.Bump), + Summary: summary, + } + for mod, bump := range modules { + cs.Modules[mod] = changeset.Bump(bump) + } + + if err := changeset.WriteChangeset(dir, cs); err != nil { + t.Fatalf("failed to create changeset: %v", err) + } + + return cs.ID +} + +// setupTestRepoUninitialized creates a repo WITHOUT .changeset directory. +func setupTestRepoUninitialized(t *testing.T) string { + t.Helper() + dir := testutil.SetupTestRepo(t) + + // Remove .changeset directory + changesetDir := filepath.Join(dir, ".changeset") + if err := os.RemoveAll(changesetDir); err != nil { + t.Fatalf("failed to remove .changeset: %v", err) + } + + return dir +} + +// setupTestRepoWithManifest creates a repo with a release manifest. +func setupTestRepoWithManifest(t *testing.T, releases []changeset.Release) string { + t.Helper() + dir := testutil.SetupTestRepo(t) + + manifest := &changeset.Manifest{Releases: releases} + if err := changeset.WriteManifest(dir, manifest); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + return dir +} + +// removeChangesets removes all changeset files (except README.md) from the repo. +func removeChangesets(t *testing.T, dir string) { + t.Helper() + + changesetDir := filepath.Join(dir, ".changeset") + files, err := filepath.Glob(filepath.Join(changesetDir, "*.md")) + if err != nil { + t.Fatalf("failed to glob changesets: %v", err) + } + + for _, f := range files { + if filepath.Base(f) != "README.md" { + if err := os.Remove(f); err != nil { + t.Fatalf("failed to remove changeset %s: %v", f, err) + } + } + } +} diff --git a/cmd/changeset/e2e/init_test.go b/cmd/changeset/e2e/init_test.go new file mode 100644 index 0000000..12e8e20 --- /dev/null +++ b/cmd/changeset/e2e/init_test.go @@ -0,0 +1,60 @@ +//go:build e2e + +package e2e + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_Init_Success(t *testing.T) { + // Setup: repo without .changeset + dir := setupTestRepoUninitialized(t) + + // Execute + result := runCLI(t, dir, "init") + + // Verify + require.Equal(t, 0, result.ExitCode, "init should succeed") + require.NoError(t, result.Err) + + // Check files created + testutil.AssertFileExists(t, filepath.Join(dir, ".changeset", "config.json")) + testutil.AssertFileExists(t, filepath.Join(dir, ".changeset", "README.md")) + + // Check config content + testutil.AssertFileContains(t, + filepath.Join(dir, ".changeset", "config.json"), + "github.com/test/fakerepo", + ) +} + +func TestE2E_Init_AlreadyInitialized(t *testing.T) { + // Setup: repo WITH .changeset already + dir := testutil.SetupTestRepo(t) + + // Execute + result := runCLI(t, dir, "init") + + // Verify - should fail because already initialized + assert.Equal(t, 1, result.ExitCode, "init should fail when already initialized") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "already") + } +} + +func TestE2E_Init_NoGoMod(t *testing.T) { + // Setup: empty temp directory without go.mod + dir := t.TempDir() + + // Execute + result := runCLI(t, dir, "init") + + // Verify - should fail without go.mod + assert.Equal(t, 1, result.ExitCode, "init should fail without go.mod") +} diff --git a/cmd/changeset/e2e/status_test.go b/cmd/changeset/e2e/status_test.go new file mode 100644 index 0000000..f1c8463 --- /dev/null +++ b/cmd/changeset/e2e/status_test.go @@ -0,0 +1,63 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_Status_WithChangesets(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "status") + + require.Equal(t, 0, result.ExitCode, "status should succeed") + require.NoError(t, result.Err) + + // Verify changesets exist + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Len(t, changesets, 1) + assert.Equal(t, "happy-tiger-jump", changesets[0].ID) +} + +func TestE2E_Status_NoChangesets(t *testing.T) { + dir := testutil.SetupTestRepo(t) + removeChangesets(t, dir) + + result := runCLI(t, dir, "status") + + require.Equal(t, 0, result.ExitCode, "status should succeed") + require.NoError(t, result.Err) + + // Verify no changesets exist + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Empty(t, changesets) +} + +func TestE2E_Status_Verbose(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "status", "--verbose") + + require.Equal(t, 0, result.ExitCode, "status --verbose should succeed") + require.NoError(t, result.Err) +} + +func TestE2E_Status_NotInitialized(t *testing.T) { + dir := setupTestRepoUninitialized(t) + + result := runCLI(t, dir, "status") + + assert.Equal(t, 1, result.ExitCode, "status should fail when not initialized") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "not initialized") + } +} diff --git a/cmd/changeset/e2e/tag_test.go b/cmd/changeset/e2e/tag_test.go new file mode 100644 index 0000000..bb92167 --- /dev/null +++ b/cmd/changeset/e2e/tag_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_Tag_Success(t *testing.T) { + // Setup with manifest + releases := []changeset.Release{ + { + Module: "libA", + Version: "v0.2.0", + PreviousVersion: "v0.1.0", + Bump: changeset.BumpMinor, + }, + } + dir := setupTestRepoWithManifest(t, releases) + + result := runCLI(t, dir, "tag") + + require.Equal(t, 0, result.ExitCode, "tag should succeed: %s", result.Stdout) + + // Verify tag was created + testutil.AssertGitTag(t, dir, "libA/v0.2.0") +} + +func TestE2E_Tag_NoManifest(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "tag") + + assert.Equal(t, 1, result.ExitCode, "tag should fail without manifest") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "manifest") + } +} + +func TestE2E_Publish_NoPush(t *testing.T) { + // Setup with manifest + releases := []changeset.Release{ + { + Module: "libB", + Version: "v0.2.0", + PreviousVersion: "v0.1.0", + Bump: changeset.BumpMinor, + }, + } + dir := setupTestRepoWithManifest(t, releases) + + result := runCLI(t, dir, "publish", "--no-push") + + require.Equal(t, 0, result.ExitCode, "publish --no-push should succeed: %s", result.Stdout) + + // Verify tag was created + testutil.AssertGitTag(t, dir, "libB/v0.2.0") +} + +func TestE2E_Publish_NoManifest(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "publish") + + assert.Equal(t, 1, result.ExitCode, "publish should fail without manifest") +} diff --git a/cmd/changeset/e2e/version_test.go b/cmd/changeset/e2e/version_test.go new file mode 100644 index 0000000..210b109 --- /dev/null +++ b/cmd/changeset/e2e/version_test.go @@ -0,0 +1,78 @@ +//go:build e2e + +package e2e + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_Version_Success(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + // Everything is already committed by SetupTestRepo + result := runCLI(t, dir, "version") + + require.Equal(t, 0, result.ExitCode, "version should succeed") + require.NoError(t, result.Err) + + // Verify manifest was created + testutil.AssertFileExists(t, filepath.Join(dir, ".changeset", "release-manifest.json")) + + // Verify changesets were consumed + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Empty(t, changesets, "changesets should be consumed") +} + +func TestE2E_Version_NoChangesets(t *testing.T) { + dir := testutil.SetupTestRepo(t) + removeChangesets(t, dir) + testutil.CommitChanges(t, dir, "remove changesets") + + result := runCLI(t, dir, "version") + + // Should succeed but indicate no releases + require.Equal(t, 0, result.ExitCode, "version should succeed") + require.NoError(t, result.Err) + + // No manifest should be created when there are no changesets + _, err := changeset.ReadManifest(dir) + assert.Error(t, err, "no manifest should exist when there are no changesets") +} + +func TestE2E_Version_Snapshot(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + result := runCLI(t, dir, "version", "--snapshot") + + require.Equal(t, 0, result.ExitCode, "version --snapshot should succeed") + require.NoError(t, result.Err) + + // Verify manifest was created with snapshot versions + manifest, err := changeset.ReadManifest(dir) + require.NoError(t, err) + require.NotEmpty(t, manifest.Releases) + + // Snapshot versions should contain timestamp-like suffix + for _, rel := range manifest.Releases { + assert.Contains(t, rel.Version, "-", "snapshot version should have prerelease suffix") + } +} + +func TestE2E_Version_NotInitialized(t *testing.T) { + dir := setupTestRepoUninitialized(t) + + result := runCLI(t, dir, "version") + + assert.Equal(t, 1, result.ExitCode, "version should fail when not initialized") + if assert.NotNil(t, result.Err, "should have an error") { + assert.Contains(t, result.Err.Error(), "not initialized") + } +} diff --git a/cmd/changeset/e2e/workflow_test.go b/cmd/changeset/e2e/workflow_test.go new file mode 100644 index 0000000..3dc854e --- /dev/null +++ b/cmd/changeset/e2e/workflow_test.go @@ -0,0 +1,160 @@ +//go:build e2e + +package e2e + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/changeset" + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestE2E_FullReleaseWorkflow(t *testing.T) { + // Setup: fresh repo with one changeset (everything is already committed) + dir := testutil.SetupTestRepo(t) + + // Step 1: Check status - should show pending changeset + statusResult := runCLI(t, dir, "status") + require.Equal(t, 0, statusResult.ExitCode, "status should succeed") + require.NoError(t, statusResult.Err) + + // Verify changesets exist before version command + changesetsBefore, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + require.Len(t, changesetsBefore, 1, "should have one changeset") + + // Step 2: Run version command + versionResult := runCLI(t, dir, "version") + require.Equal(t, 0, versionResult.ExitCode, "version should succeed") + require.NoError(t, versionResult.Err) + + // Verify manifest was created + testutil.AssertFileExists(t, filepath.Join(dir, ".changeset", "release-manifest.json")) + + // Verify changeset was consumed + changesetsAfter, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Empty(t, changesetsAfter, "changesets should be consumed") + + // Verify CHANGELOG was updated + testutil.AssertFileContains(t, + filepath.Join(dir, "libA", "CHANGELOG.md"), + "0.2.0", + ) + + // Step 3: Create tags (without push since no remote) + tagResult := runCLI(t, dir, "tag") + require.Equal(t, 0, tagResult.ExitCode, "tag should succeed") + require.NoError(t, tagResult.Err) + + // Verify tags created + testutil.AssertGitTag(t, dir, "libA/v0.2.0") +} + +func TestE2E_DependencyCascade(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + // Remove existing changeset + removeChangesets(t, dir) + + // Create changeset for libA only (libB and libC depend on it) + createChangeset(t, dir, + map[string]string{"libA": "minor"}, + "Add new feature to libA", + ) + + // Commit the changeset change + testutil.CommitChanges(t, dir, "add libA changeset") + + // Run version + result := runCLI(t, dir, "version") + require.Equal(t, 0, result.ExitCode, "version should succeed") + require.NoError(t, result.Err) + + // Verify cascade: libB and libC should also be bumped (patch due to dependency) + manifest, err := changeset.ReadManifest(dir) + require.NoError(t, err) + + // Should have releases for libA, libB, and libC + moduleNames := make([]string, len(manifest.Releases)) + for i, rel := range manifest.Releases { + moduleNames[i] = rel.Module + } + + assert.Contains(t, moduleNames, "libA", "libA should be released") + assert.Contains(t, moduleNames, "libB", "libB should be released (depends on libA)") + assert.Contains(t, moduleNames, "libC", "libC should be released (depends on libA)") +} + +func TestE2E_AddThenRelease(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + // Remove existing changesets + removeChangesets(t, dir) + testutil.CommitChanges(t, dir, "remove existing changesets") + + // Add a new changeset using non-interactive mode + addResult := runCLI(t, dir, + "add", + "--module", "libB:patch", + "--summary", "Fix bug in libB", + ) + require.Equal(t, 0, addResult.ExitCode, "add should succeed") + require.NoError(t, addResult.Err) + + // Commit the new changeset + testutil.CommitChanges(t, dir, "add libB changeset") + + // Check status - verify changeset exists + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + require.Len(t, changesets, 1, "should have one changeset") + + // Verify the changeset includes libB + cs := changesets[0] + _, hasLibB := cs.Modules["libB"] + assert.True(t, hasLibB, "changeset should include libB") + + // Run version + versionResult := runCLI(t, dir, "version") + require.Equal(t, 0, versionResult.ExitCode, "version should succeed") + require.NoError(t, versionResult.Err) + + // Verify manifest + manifest, err := changeset.ReadManifest(dir) + require.NoError(t, err) + + // Find libB release + var libBRelease *changeset.Release + for i := range manifest.Releases { + if manifest.Releases[i].Module == "libB" { + libBRelease = &manifest.Releases[i] + break + } + } + + require.NotNil(t, libBRelease, "libB should have a release") + assert.Equal(t, "v0.1.1", libBRelease.Version, "libB should be bumped to patch version") +} + +func TestE2E_InitThenAdd(t *testing.T) { + // Start with uninitialized repo + dir := setupTestRepoUninitialized(t) + + // Initialize + initResult := runCLI(t, dir, "init") + require.Equal(t, 0, initResult.ExitCode, "init should succeed") + + // Add empty changeset + addResult := runCLI(t, dir, "add", "--empty") + require.Equal(t, 0, addResult.ExitCode, "add should succeed after init") + + // Verify changeset exists + changesets, err := changeset.ReadChangesets(dir) + require.NoError(t, err) + assert.Len(t, changesets, 1) +} diff --git a/cmd/changeset/git/git.go b/cmd/changeset/git/git.go new file mode 100644 index 0000000..782eeb9 --- /dev/null +++ b/cmd/changeset/git/git.go @@ -0,0 +1,236 @@ +package git + +import ( + "fmt" + "sort" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "golang.org/x/mod/semver" +) + +// GetTags returns all version tags in the repository. +func GetTags(dir string) ([]Tag, error) { + repo, err := git.PlainOpen(dir) + if err != nil { + return nil, fmt.Errorf("opening repository: %w", err) + } + + refs, err := repo.Tags() + if err != nil { + return nil, fmt.Errorf("listing tags: %w", err) + } + + var tags []Tag + err = refs.ForEach(func(ref *plumbing.Reference) error { + name := ref.Name().Short() + module, version, ok := ParseTag(name) + if !ok { + // Skip non-version tags + return nil + } + tags = append(tags, Tag{ + Name: name, + Module: module, + Version: version, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterating tags: %w", err) + } + + return tags, nil +} + +// GetLatestVersions returns the latest version for each module. +// Returns a map of module short name -> version (e.g., "xkafka" -> "v0.10.0"). +// Root module uses empty string as key. +func GetLatestVersions(dir string) (map[string]string, error) { + tags, err := GetTags(dir) + if err != nil { + return nil, err + } + + // Group versions by module + moduleVersions := make(map[string][]string) + for _, tag := range tags { + moduleVersions[tag.Module] = append(moduleVersions[tag.Module], tag.Version) + } + + // Find the latest version for each module + latest := make(map[string]string) + for module, versions := range moduleVersions { + semver.Sort(versions) + latest[module] = versions[len(versions)-1] + } + + return latest, nil +} + +// CreateTag creates a git tag for a module version. +// For submodules, uses format "module/version". +// For root, uses format "version". +func CreateTag(dir string, module string, version string) error { + repo, err := git.PlainOpen(dir) + if err != nil { + return fmt.Errorf("opening repository: %w", err) + } + + head, err := repo.Head() + if err != nil { + return fmt.Errorf("getting HEAD: %w", err) + } + + tagName := FormatTag(module, version) + _, err = repo.CreateTag(tagName, head.Hash(), nil) + if err != nil { + return fmt.Errorf("creating tag %s: %w", tagName, err) + } + + return nil +} + +// CreateTags creates multiple git tags atomically. +func CreateTags(dir string, tags []Tag) error { + for _, tag := range tags { + if err := CreateTag(dir, tag.Module, tag.Version); err != nil { + return err + } + } + return nil +} + +// PushTags pushes tags to the remote. +func PushTags(dir string, tags []Tag, remote string) error { + repo, err := git.PlainOpen(dir) + if err != nil { + return fmt.Errorf("opening repository: %w", err) + } + + // Build refspecs for the tags + refSpecs := make([]config.RefSpec, len(tags)) + for i, tag := range tags { + refSpecs[i] = config.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tag.Name, tag.Name)) + } + + err = repo.Push(&git.PushOptions{ + RemoteName: remote, + RefSpecs: refSpecs, + }) + if err != nil && err != git.NoErrAlreadyUpToDate { + return fmt.Errorf("pushing tags: %w", err) + } + + return nil +} + +// HasUncommittedChanges returns true if there are uncommitted changes. +func HasUncommittedChanges(dir string) (bool, error) { + repo, err := git.PlainOpen(dir) + if err != nil { + return false, fmt.Errorf("opening repository: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return false, fmt.Errorf("getting worktree: %w", err) + } + + status, err := wt.Status() + if err != nil { + return false, fmt.Errorf("getting status: %w", err) + } + + return !status.IsClean(), nil +} + +// GetAllTags returns all tags grouped by module. +// Returns a map of module short name -> list of versions. +func GetAllTags(dir string) (map[string][]string, error) { + tags, err := GetTags(dir) + if err != nil { + return nil, err + } + + result := make(map[string][]string) + for _, tag := range tags { + result[tag.Module] = append(result[tag.Module], tag.Version) + } + + // Sort versions for each module + for module := range result { + sort.Slice(result[module], func(i, j int) bool { + return semver.Compare(result[module][i], result[module][j]) < 0 + }) + } + + return result, nil +} + +// GetChangedFiles returns files changed since a given ref (branch/tag). +func GetChangedFiles(dir string, sinceRef string) ([]string, error) { + repo, err := git.PlainOpen(dir) + if err != nil { + return nil, fmt.Errorf("opening repository: %w", err) + } + + // Resolve the reference + refHash, err := repo.ResolveRevision(plumbing.Revision(sinceRef)) + if err != nil { + return nil, fmt.Errorf("resolving ref %s: %w", sinceRef, err) + } + + refCommit, err := repo.CommitObject(*refHash) + if err != nil { + return nil, fmt.Errorf("getting commit for ref: %w", err) + } + + // Get HEAD + headRef, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("getting HEAD: %w", err) + } + + headCommit, err := repo.CommitObject(headRef.Hash()) + if err != nil { + return nil, fmt.Errorf("getting HEAD commit: %w", err) + } + + // Get trees for comparison + refTree, err := refCommit.Tree() + if err != nil { + return nil, fmt.Errorf("getting ref tree: %w", err) + } + + headTree, err := headCommit.Tree() + if err != nil { + return nil, fmt.Errorf("getting HEAD tree: %w", err) + } + + // Get diff + changes, err := refTree.Diff(headTree) + if err != nil { + return nil, fmt.Errorf("computing diff: %w", err) + } + + // Collect changed file paths + fileSet := make(map[string]bool) + for _, change := range changes { + if change.From.Name != "" { + fileSet[change.From.Name] = true + } + if change.To.Name != "" { + fileSet[change.To.Name] = true + } + } + + var files []string + for f := range fileSet { + files = append(files, f) + } + sort.Strings(files) + + return files, nil +} diff --git a/cmd/changeset/git/tags.go b/cmd/changeset/git/tags.go new file mode 100644 index 0000000..df7ac11 --- /dev/null +++ b/cmd/changeset/git/tags.go @@ -0,0 +1,55 @@ +// Package git provides git operations for tags and repository state. +package git + +import ( + "regexp" + "strings" +) + +// Tag represents a parsed git tag. +type Tag struct { + Name string // Full tag name (e.g., "xkafka/v0.10.0") + Module string // Module short name (e.g., "xkafka", or "" for root) + Version string // Semantic version (e.g., "v0.10.0") +} + +// semverRegex matches semantic versions like v0.10.0, v1.2.3-beta.1, etc. +var semverRegex = regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$`) + +// ParseTag parses a git tag name into module and version. +// Examples: +// +// "v0.10.0" -> ("", "v0.10.0", true) +// "xkafka/v0.10.0" -> ("xkafka", "v0.10.0", true) +// "xkafka/middleware/v0.10.0" -> ("xkafka/middleware", "v0.10.0", true) +// "not-a-version" -> ("", "", false) +func ParseTag(tagName string) (module string, version string, ok bool) { + // Handle root module tags (just version) + if semverRegex.MatchString(tagName) { + return "", tagName, true + } + + // Find the last path segment that looks like a version + parts := strings.Split(tagName, "/") + if len(parts) < 2 { + return "", "", false + } + + lastPart := parts[len(parts)-1] + if !semverRegex.MatchString(lastPart) { + return "", "", false + } + + modulePath := strings.Join(parts[:len(parts)-1], "/") + return modulePath, lastPart, true +} + +// FormatTag creates a tag name from module and version. +// For root module (empty module name), returns just the version. +// For submodules, returns "module/version". +func FormatTag(module string, version string) string { + if module == "" { + return version + } + return module + "/" + version +} diff --git a/cmd/changeset/git/tags_test.go b/cmd/changeset/git/tags_test.go new file mode 100644 index 0000000..f188df6 --- /dev/null +++ b/cmd/changeset/git/tags_test.go @@ -0,0 +1,104 @@ +package git + +import ( + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestParseTag(t *testing.T) { + tests := []struct { + name string + input string + wantModule string + wantVersion string + wantOK bool + }{ + {"root version tag", "v0.10.0", "", "v0.10.0", true}, + {"simple submodule", "xkafka/v0.10.0", "xkafka", "v0.10.0", true}, + {"nested submodule", "xkafka/middleware/v0.10.0", "xkafka/middleware", "v0.10.0", true}, + {"invalid tag - not a version", "not-a-version", "", "", false}, + {"partial version", "v1.0", "", "", false}, + {"missing v prefix", "1.0.0", "", "", false}, + {"prerelease version", "v1.0.0-beta.1", "", "v1.0.0-beta.1", true}, + {"prerelease with module", "pkg/v2.0.0-rc.1", "pkg", "v2.0.0-rc.1", true}, + {"build metadata", "v1.0.0+build.123", "", "v1.0.0+build.123", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + module, version, ok := ParseTag(tt.input) + assert.Equal(t, tt.wantOK, ok, "ParseTag(%q) ok", tt.input) + if tt.wantOK { + assert.Equal(t, tt.wantModule, module, "ParseTag(%q) module", tt.input) + assert.Equal(t, tt.wantVersion, version, "ParseTag(%q) version", tt.input) + } + }) + } +} + +func TestFormatTag(t *testing.T) { + tests := []struct { + name string + module string + version string + want string + }{ + {"root module", "", "v1.0.0", "v1.0.0"}, + {"simple submodule", "xkafka", "v0.10.0", "xkafka/v0.10.0"}, + {"nested submodule", "a/b", "v1.0.0", "a/b/v1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatTag(tt.module, tt.version) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseTagFormatTagRoundTrip(t *testing.T) { + tests := []struct { + module string + version string + }{ + {"", "v1.0.0"}, + {"pkg", "v0.1.0"}, + {"a/b/c", "v2.0.0-beta.1"}, + } + + for _, tt := range tests { + tag := FormatTag(tt.module, tt.version) + module, version, ok := ParseTag(tag) + assert.True(t, ok, "ParseTag(FormatTag(%q, %q)) should succeed", tt.module, tt.version) + assert.Equal(t, tt.module, module, "round trip module") + assert.Equal(t, tt.version, version, "round trip version") + } +} + +func TestSetupTestRepo(t *testing.T) { + dir := testutil.SetupTestRepo(t) + + // Verify git tags were created + cmd := exec.Command("git", "tag", "-l") + cmd.Dir = dir + out, err := cmd.Output() + assert.NoError(t, err) + + tags := string(out) + assert.Contains(t, tags, "v0.1.0") + assert.Contains(t, tags, "libA/v0.1.0") + assert.Contains(t, tags, "libB/v0.1.0") + assert.Contains(t, tags, "libC/v0.1.0") + + // Verify files exist + assert.FileExists(t, filepath.Join(dir, "go.mod")) + assert.FileExists(t, filepath.Join(dir, "libA/go.mod")) + assert.FileExists(t, filepath.Join(dir, "libB/go.mod")) + assert.FileExists(t, filepath.Join(dir, "libC/go.mod")) + assert.FileExists(t, filepath.Join(dir, ".changeset/config.json")) +} diff --git a/cmd/changeset/go.mod b/cmd/changeset/go.mod new file mode 100644 index 0000000..3cf88c9 --- /dev/null +++ b/cmd/changeset/go.mod @@ -0,0 +1,63 @@ +module github.com/gojekfarm/xtools/cmd/changeset + +go 1.24.0 + +require ( + github.com/Masterminds/semver/v3 v3.4.0 + github.com/charmbracelet/huh v0.8.0 + github.com/go-git/go-git/v5 v5.16.4 + github.com/lmittmann/tint v1.1.2 + github.com/mattn/go-isatty v0.0.20 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli/v3 v3.3.3 + golang.org/x/mod v0.32.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.24.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/cmd/changeset/go.sum b/cmd/changeset/go.sum new file mode 100644 index 0000000..397df5d --- /dev/null +++ b/cmd/changeset/go.sum @@ -0,0 +1,179 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= +github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/changeset/main.go b/cmd/changeset/main.go new file mode 100644 index 0000000..32a2704 --- /dev/null +++ b/cmd/changeset/main.go @@ -0,0 +1,41 @@ +// Package main provides the changeset CLI for managing multi-module Go releases. +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/gojekfarm/xtools/cmd/changeset/app" +) + +var ( + // Build information (set via ldflags). + Version = "dev" + GitCommit = "unknown" + BuildDate = "unknown" +) + +func main() { + cmd := app.BuildCLI(formatVersion()) + + ctx, cancel := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGQUIT, + syscall.SIGTERM, + syscall.SIGHUP, + ) + defer cancel() + + if err := cmd.Run(ctx, os.Args); err != nil { + slog.Error("command failed", "error", err) + os.Exit(1) + } +} + +func formatVersion() string { + return Version + " (commit: " + GitCommit + ", built: " + BuildDate + ")" +} diff --git a/cmd/changeset/module/gomod.go b/cmd/changeset/module/gomod.go new file mode 100644 index 0000000..c275300 --- /dev/null +++ b/cmd/changeset/module/gomod.go @@ -0,0 +1,85 @@ +// Package module provides Go module discovery and dependency operations. +package module + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/mod/modfile" +) + +// ParseGoMod reads and parses a go.mod file. +// Returns the module path and direct dependencies. +func ParseGoMod(path string) (modulePath string, deps []string, err error) { + contentBytes, err := os.ReadFile(path) + if err != nil { + return "", nil, fmt.Errorf("reading go.mod: %w", err) + } + + goMod, err := modfile.Parse(path, contentBytes, nil) + if err != nil { + return "", nil, fmt.Errorf("parsing go.mod: %w", err) + } + + modulePath = goMod.Module.Mod.Path + + for _, r := range goMod.Require { + if r.Indirect { + continue + } + deps = append(deps, r.Mod.Path) + } + + return modulePath, deps, nil +} + +// UpdateGoMod updates internal dependency versions in a go.mod file. +// +// Parameters: +// - path: Path to go.mod file +// - root: Root module path (to identify internal deps) +// - versions: Map of module short name -> new version +func UpdateGoMod(path string, root string, versions map[string]string) error { + contentBytes, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading go.mod: %w", err) + } + + goMod, err := modfile.Parse(path, contentBytes, nil) + if err != nil { + return fmt.Errorf("parsing go.mod: %w", err) + } + + for _, r := range goMod.Require { + // Check if this is an internal dependency + if !strings.HasPrefix(r.Mod.Path, root) { + continue + } + + // Get the short name (relative to root) + shortName := strings.TrimPrefix(r.Mod.Path, root+"/") + if shortName == r.Mod.Path { + // This is the root module itself + shortName = "" + } + + newVersion, ok := versions[shortName] + if !ok { + continue + } + + if err := goMod.AddRequire(r.Mod.Path, newVersion); err != nil { + return fmt.Errorf("updating dependency %s: %w", r.Mod.Path, err) + } + } + + goMod.Cleanup() + + content, err := goMod.Format() + if err != nil { + return fmt.Errorf("formatting go.mod: %w", err) + } + + return os.WriteFile(path, content, 0644) +} diff --git a/cmd/changeset/module/graph.go b/cmd/changeset/module/graph.go new file mode 100644 index 0000000..aff6e93 --- /dev/null +++ b/cmd/changeset/module/graph.go @@ -0,0 +1,107 @@ +package module + +import ( + "sort" + "strings" +) + +// FindModule returns a module by short name. +// Use empty string to get the root module. +func (g *Graph) FindModule(shortName string) *Module { + return g.Modules[shortName] +} + +// Dependents returns modules that depend on the given module. +func (g *Graph) Dependents(shortName string) []*Module { + var dependents []*Module + for _, mod := range g.Modules { + for _, dep := range mod.Dependencies { + if dep == shortName { + dependents = append(dependents, mod) + break + } + } + } + + // Sort for deterministic output + sort.Slice(dependents, func(i, j int) bool { + return dependents[i].ShortName < dependents[j].ShortName + }) + + return dependents +} + +// TopologicalSort returns modules in dependency order (leaves first). +// Modules with no dependencies come first, then modules that only +// depend on those, and so on. This ensures that when processing +// modules in order, all dependencies are processed before dependents. +func (g *Graph) TopologicalSort() []*Module { + // Kahn's algorithm + // Calculate in-degree (number of internal dependencies) for each module + inDegree := make(map[string]int) + for shortName := range g.Modules { + inDegree[shortName] = 0 + } + + // Count internal dependencies + for _, mod := range g.Modules { + for _, dep := range mod.Dependencies { + if _, exists := g.Modules[dep]; exists { + inDegree[mod.ShortName]++ + } + } + } + + // Start with modules that have no internal dependencies + var queue []string + for shortName, degree := range inDegree { + if degree == 0 { + queue = append(queue, shortName) + } + } + + // Sort queue for deterministic output + sort.Strings(queue) + + var result []*Module + for len(queue) > 0 { + // Pop from queue + shortName := queue[0] + queue = queue[1:] + + mod := g.Modules[shortName] + result = append(result, mod) + + // Decrease in-degree for all modules that depend on this one + for _, dependent := range g.Dependents(shortName) { + inDegree[dependent.ShortName]-- + if inDegree[dependent.ShortName] == 0 { + queue = append(queue, dependent.ShortName) + // Keep queue sorted + sort.Strings(queue) + } + } + } + + return result +} + +// IsInternal returns true if the module path is internal to this repo. +func (g *Graph) IsInternal(modulePath string) bool { + if modulePath == g.Root.Name { + return true + } + return strings.HasPrefix(modulePath, g.Root.Name+"/") +} + +// AllModules returns all modules as a slice, sorted by short name. +func (g *Graph) AllModules() []*Module { + var modules []*Module + for _, mod := range g.Modules { + modules = append(modules, mod) + } + sort.Slice(modules, func(i, j int) bool { + return modules[i].ShortName < modules[j].ShortName + }) + return modules +} diff --git a/cmd/changeset/module/graph_test.go b/cmd/changeset/module/graph_test.go new file mode 100644 index 0000000..37d737d --- /dev/null +++ b/cmd/changeset/module/graph_test.go @@ -0,0 +1,178 @@ +package module + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestGraph creates a test graph with the structure: +// +// root (no deps) +// ├── a (no deps) +// ├── b (depends on a) +// └── c (depends on a, b) +func newTestGraph() *Graph { + root := &Module{ + Name: "github.com/foo/bar", + ShortName: "", + Path: "/test/root", + } + a := &Module{ + Name: "github.com/foo/bar/a", + ShortName: "a", + Path: "/test/root/a", + Dependencies: []string{}, + } + b := &Module{ + Name: "github.com/foo/bar/b", + ShortName: "b", + Path: "/test/root/b", + Dependencies: []string{"a"}, + } + c := &Module{ + Name: "github.com/foo/bar/c", + ShortName: "c", + Path: "/test/root/c", + Dependencies: []string{"a", "b"}, + } + + return &Graph{ + Root: root, + Modules: map[string]*Module{ + "": root, + "a": a, + "b": b, + "c": c, + }, + } +} + +func TestFindModule(t *testing.T) { + g := newTestGraph() + + tests := []struct { + name string + shortName string + wantNil bool + wantName string + }{ + {"find root", "", false, "github.com/foo/bar"}, + {"find a", "a", false, "github.com/foo/bar/a"}, + {"find b", "b", false, "github.com/foo/bar/b"}, + {"find c", "c", false, "github.com/foo/bar/c"}, + {"find nonexistent", "nonexistent", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mod := g.FindModule(tt.shortName) + if tt.wantNil { + assert.Nil(t, mod) + } else { + require.NotNil(t, mod) + assert.Equal(t, tt.wantName, mod.Name) + } + }) + } +} + +func TestDependents(t *testing.T) { + g := newTestGraph() + + tests := []struct { + name string + shortName string + wantNames []string + }{ + {"dependents of a", "a", []string{"b", "c"}}, + {"dependents of b", "b", []string{"c"}}, + {"dependents of c", "c", nil}, + {"dependents of root", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := g.Dependents(tt.shortName) + var gotNames []string + for _, d := range deps { + gotNames = append(gotNames, d.ShortName) + } + if tt.wantNames == nil { + assert.Empty(t, gotNames) + } else { + assert.Equal(t, tt.wantNames, gotNames) + } + }) + } +} + +func TestTopologicalSort(t *testing.T) { + g := newTestGraph() + + sorted := g.TopologicalSort() + + // Convert to short names for easier checking + var names []string + for _, m := range sorted { + names = append(names, m.ShortName) + } + + // Verify all modules are present + assert.Len(t, names, 4) + + // Find indices + indexOf := func(name string) int { + for i, n := range names { + if n == name { + return i + } + } + return -1 + } + + // c must come after a and b + assert.Greater(t, indexOf("c"), indexOf("a"), "c should come after a") + assert.Greater(t, indexOf("c"), indexOf("b"), "c should come after b") + + // b must come after a + assert.Greater(t, indexOf("b"), indexOf("a"), "b should come after a") +} + +func TestIsInternal(t *testing.T) { + g := newTestGraph() + + tests := []struct { + name string + modulePath string + want bool + }{ + {"root module", "github.com/foo/bar", true}, + {"submodule a", "github.com/foo/bar/a", true}, + {"nested path", "github.com/foo/bar/deep/nested", true}, + {"external module", "github.com/other/pkg", false}, + {"similar prefix", "github.com/foo/barbaz", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := g.IsInternal(tt.modulePath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestAllModules(t *testing.T) { + g := newTestGraph() + + mods := g.AllModules() + + assert.Len(t, mods, 4) + + // Should be sorted by short name (empty string first) + expectedOrder := []string{"", "a", "b", "c"} + for i, mod := range mods { + assert.Equal(t, expectedOrder[i], mod.ShortName, "module at index %d", i) + } +} diff --git a/cmd/changeset/module/module.go b/cmd/changeset/module/module.go new file mode 100644 index 0000000..01c3293 --- /dev/null +++ b/cmd/changeset/module/module.go @@ -0,0 +1,124 @@ +package module + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Module represents a Go module in the repository. +type Module struct { + Name string // Full module path (e.g., "github.com/gojekfarm/xtools/xkafka") + ShortName string // Relative to root (e.g., "xkafka"), empty for root module + Path string // Filesystem path to module directory + Dependencies []string // Internal module dependencies (short names) +} + +// Graph represents the module dependency graph. +type Graph struct { + Root *Module + Modules map[string]*Module // Short name -> Module (empty string key for root) +} + +// Discover finds all Go modules in a directory tree. +// Returns a Graph containing the root module and all submodules. +func Discover(dir string) (*Graph, error) { + // Find and parse root module + rootModPath := filepath.Join(dir, "go.mod") + if _, err := os.Stat(rootModPath); err != nil { + return nil, fmt.Errorf("no go.mod found in %s: %w", dir, err) + } + + rootName, rootDeps, err := ParseGoMod(rootModPath) + if err != nil { + return nil, fmt.Errorf("parsing root go.mod: %w", err) + } + + root := &Module{ + Name: rootName, + ShortName: "", + Path: dir, + } + + graph := &Graph{ + Root: root, + Modules: make(map[string]*Module), + } + graph.Modules[""] = root + + // Walk directory tree to find submodules + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip non-directories + if !info.IsDir() { + return nil + } + + // Skip the root directory (already processed) + if path == dir { + return nil + } + + // Skip hidden directories and common non-module directories + name := info.Name() + if strings.HasPrefix(name, ".") || name == "vendor" || name == "testdata" { + return filepath.SkipDir + } + + // Check for go.mod + modPath := filepath.Join(path, "go.mod") + if _, err := os.Stat(modPath); os.IsNotExist(err) { + return nil + } + + moduleName, deps, err := ParseGoMod(modPath) + if err != nil { + return fmt.Errorf("parsing go.mod at %s: %w", modPath, err) + } + + // Calculate short name relative to root + shortName := strings.TrimPrefix(moduleName, rootName+"/") + if shortName == moduleName { + // Module path doesn't start with root, unusual but handle it + shortName = moduleName + } + + mod := &Module{ + Name: moduleName, + ShortName: shortName, + Path: path, + } + + // Filter dependencies to only internal ones + for _, dep := range deps { + if strings.HasPrefix(dep, rootName+"/") { + depShortName := strings.TrimPrefix(dep, rootName+"/") + mod.Dependencies = append(mod.Dependencies, depShortName) + } else if dep == rootName { + // Depends on root module + mod.Dependencies = append(mod.Dependencies, "") + } + } + + graph.Modules[shortName] = mod + return nil + }) + + if err != nil { + return nil, err + } + + // Filter root module dependencies to internal ones + for _, dep := range rootDeps { + if strings.HasPrefix(dep, rootName+"/") { + depShortName := strings.TrimPrefix(dep, rootName+"/") + root.Dependencies = append(root.Dependencies, depShortName) + } + } + + return graph, nil +} diff --git a/cmd/changeset/module/module_test.go b/cmd/changeset/module/module_test.go new file mode 100644 index 0000000..badabe6 --- /dev/null +++ b/cmd/changeset/module/module_test.go @@ -0,0 +1,79 @@ +package module + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gojekfarm/xtools/cmd/changeset/testutil" +) + +func TestDiscover(t *testing.T) { + repoPath := testutil.SetupTestRepo(t) + + graph, err := Discover(repoPath) + require.NoError(t, err) + require.NotNil(t, graph.Root) + + // Check root module + assert.Equal(t, "github.com/test/fakerepo", graph.Root.Name) + assert.Empty(t, graph.Root.ShortName) + + // Check we found all modules + assert.Len(t, graph.Modules, 4) + assert.Contains(t, graph.Modules, "") + assert.Contains(t, graph.Modules, "libA") + assert.Contains(t, graph.Modules, "libB") + assert.Contains(t, graph.Modules, "libC") + + // Check libB dependencies + libB := graph.FindModule("libB") + require.NotNil(t, libB) + assert.Contains(t, libB.Dependencies, "libA") + + // Check libC dependencies + libC := graph.FindModule("libC") + require.NotNil(t, libC) + assert.Contains(t, libC.Dependencies, "libA") + assert.Contains(t, libC.Dependencies, "libB") +} + +func TestDiscoverNonExistent(t *testing.T) { + _, err := Discover("/nonexistent/path") + assert.Error(t, err) +} + +func TestDiscoverWithGitRepo(t *testing.T) { + repoPath := testutil.SetupTestRepo(t) + + graph, err := Discover(repoPath) + require.NoError(t, err) + require.NotNil(t, graph.Root) + + // Check root module + assert.Equal(t, "github.com/test/fakerepo", graph.Root.Name) + assert.Empty(t, graph.Root.ShortName) + + // Check we found all modules + assert.Len(t, graph.Modules, 4) + assert.Contains(t, graph.Modules, "") + assert.Contains(t, graph.Modules, "libA") + assert.Contains(t, graph.Modules, "libB") + assert.Contains(t, graph.Modules, "libC") + + // Check libB dependencies + libB := graph.FindModule("libB") + require.NotNil(t, libB) + assert.Contains(t, libB.Dependencies, "libA") + + // Check libC dependencies + libC := graph.FindModule("libC") + require.NotNil(t, libC) + assert.Contains(t, libC.Dependencies, "libA") + assert.Contains(t, libC.Dependencies, "libB") + + // Verify git repo exists + assert.DirExists(t, filepath.Join(repoPath, ".git")) +} diff --git a/cmd/changeset/testutil/cli.go b/cmd/changeset/testutil/cli.go new file mode 100644 index 0000000..e23475b --- /dev/null +++ b/cmd/changeset/testutil/cli.go @@ -0,0 +1,125 @@ +package testutil + +import ( + "context" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +// CLIResult captures the result of running a CLI command. +type CLIResult struct { + ExitCode int + Stdout string + Stderr string + Err error +} + +// RunCLI executes a CLI command in the given directory. +// Note: stdout is not captured to avoid test framework issues. +// Use Err field to check error messages. +func RunCLI(t *testing.T, dir string, cmd *cli.Command, args []string) CLIResult { + t.Helper() + + // Override OsExiter to prevent os.Exit during tests + origExiter := cli.OsExiter + var capturedExitCode int + cli.OsExiter = func(code int) { + capturedExitCode = code + } + defer func() { + cli.OsExiter = origExiter + }() + + // Save and restore working directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change to directory %s: %v", dir, err) + } + defer func() { + _ = os.Chdir(origDir) + }() + + // Run command + ctx := context.Background() + fullArgs := append([]string{"changeset"}, args...) + runErr := cmd.Run(ctx, fullArgs) + + result := CLIResult{ + Err: runErr, + } + + // Extract exit code from error or captured exit + if runErr == nil { + result.ExitCode = capturedExitCode + } else if exitErr, ok := runErr.(cli.ExitCoder); ok { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = 1 + } + + // If we captured an exit code but no error, set a default exit code + if result.ExitCode == 0 && capturedExitCode != 0 { + result.ExitCode = capturedExitCode + } + + return result +} + +// CommitChanges commits all changes in a test repo. +func CommitChanges(t *testing.T, dir, message string) { + t.Helper() + + cmds := [][]string{ + {"add", "."}, + {"commit", "-m", message}, + } + for _, args := range cmds { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } +} + +// AssertFileContains checks that a file contains expected content. +func AssertFileContains(t *testing.T, path string, expected string) { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err, "reading file %s", path) + assert.Contains(t, string(content), expected, "file %s should contain %q", path, expected) +} + +// AssertFileExists checks that a file exists. +func AssertFileExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + assert.NoError(t, err, "file should exist: %s", path) +} + +// AssertFileNotExists checks that a file does not exist. +func AssertFileNotExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "file should not exist: %s", path) +} + +// AssertGitTag checks that a git tag exists. +func AssertGitTag(t *testing.T, dir, tag string) { + t.Helper() + + cmd := exec.Command("git", "tag", "-l", tag) + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + assert.Contains(t, string(out), tag, "tag %s should exist", tag) +} diff --git a/cmd/changeset/testutil/testutil.go b/cmd/changeset/testutil/testutil.go new file mode 100644 index 0000000..20385e4 --- /dev/null +++ b/cmd/changeset/testutil/testutil.go @@ -0,0 +1,124 @@ +package testutil + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// SetupTestRepo creates a test repository in t.TempDir() with: +// - Multi-module Go structure (root + libA, libB, libC) +// - Initialized git repository with initial commit +// - Tags: v0.1.0, libA/v0.1.0, libB/v0.1.0, libC/v0.1.0 +// - .changeset directory with config and sample changeset +func SetupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + // Create directory structure + dirs := []string{ + ".changeset", + "libA", + "libB", + "libC", + "pkg/core", + } + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil { + t.Fatalf("failed to create directory %s: %v", d, err) + } + } + + // File contents matching testdata/fakerepo + files := map[string]string{ + "go.mod": "module github.com/test/fakerepo\n\ngo 1.21\n", + "libA/go.mod": "module github.com/test/fakerepo/libA\n\ngo 1.21\n", + "libA/liba.go": `package libA + +// Hello returns a greeting. +func Hello() string { + return "Hello from libA" +} +`, + "libB/go.mod": `module github.com/test/fakerepo/libB + +go 1.21 + +require github.com/test/fakerepo/libA v0.1.0 +`, + "libB/libb.go": `package libB + +// Greeting returns a greeting using libA. +func Greeting() string { + return "Hello from libB" +} +`, + "libC/go.mod": `module github.com/test/fakerepo/libC + +go 1.21 + +require ( + github.com/test/fakerepo/libA v0.1.0 + github.com/test/fakerepo/libB v0.1.0 +) +`, + "libC/libc.go": `package libC + +// Combined returns a combined greeting. +func Combined() string { + return "Hello from libC" +} +`, + "pkg/core/core.go": `package core + +// Version is the version of the core package. +const Version = "1.0.0" +`, + ".changeset/config.json": `{ + "root": "github.com/test/fakerepo", + "baseBranch": "main", + "ignore": [], + "ignorePaths": [], + "dependentBump": "patch" +} +`, + ".changeset/README.md": "# Changesets\n\nThis directory contains changeset files for testing.\n", + ".changeset/happy-tiger-jump.md": `--- +"libA": minor +--- + +Add new greeting function to libA. +`, + } + + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write file %s: %v", name, err) + } + } + + // Initialize git repository + gitCommands := [][]string{ + {"init"}, + {"config", "user.email", "test@test.com"}, + {"config", "user.name", "Test"}, + {"add", "."}, + {"commit", "-m", "initial"}, + {"tag", "v0.1.0"}, + {"tag", "libA/v0.1.0"}, + {"tag", "libB/v0.1.0"}, + {"tag", "libC/v0.1.0"}, + } + + for _, args := range gitCommands { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + return dir +}