From ba1d5e005afbb8644829296ccf57db8319721c17 Mon Sep 17 00:00:00 2001 From: Masayoshi Wada Date: Wed, 2 Sep 2026 16:57:41 +0900 Subject: [PATCH 1/2] add a version subcommand backed by a tagpr-maintained constant --- README.md | 1 + main.go | 22 ++++++++++++++++++++++ main_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/README.md b/README.md index 119ce63..4749b3c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ $ eda tree # show where the worktrees diverged from each other $ eda remove ... # remove worktrees and their branches as pairs $ eda root # move back to the primary checkout $ eda status # print current repository, worktree, and branch +$ eda version # print the version of eda ``` `eda switch` resolves in order: an existing worktree, an existing local branch, a remote branch on `origin` (a tracking branch is created), and finally a new branch based on the HEAD of the directory you run it in — so switching from inside a worktree stacks the new branch on top of it, like `git switch -c`. diff --git a/main.go b/main.go index 99b7533..43e4843 100644 --- a/main.go +++ b/main.go @@ -23,8 +23,15 @@ commands: init - print the shell integration script (zsh, bash) hook worktree-create Claude Code WorktreeCreate hook entrypoint (stdin JSON) hook worktree-remove Claude Code WorktreeRemove hook entrypoint (stdin JSON) + version print the version of eda ` +// version is the current release version, maintained by tagpr. It is the +// single source of truth: tagpr bumps it in the release pull request and +// tags what it reads back, so plain `go build`, `go install` and the +// released binaries all report the same value. +var version = "0.0.0" + func main() { cwd, err := os.Getwd() if err != nil { @@ -61,6 +68,8 @@ func run(stdin io.Reader, stdout, stderr io.Writer, args []string, cwd string) i err = cmdInit(stdout, rest) case "hook": err = cmdHook(stdin, stdout, stderr, rest, cwd) + case "version": + err = cmdVersion(stdout, rest) default: _, _ = fmt.Fprintf(stderr, "eda: unknown command %q\n%s", cmd, usage) return 2 @@ -228,6 +237,19 @@ func cmdStatus(stdout io.Writer, args []string, cwd string) error { return err } +// cmdVersion reports the version. It deliberately does not load a repository: +// asking a binary what it is must work from anywhere, including outside a +// git checkout. +func cmdVersion(stdout io.Writer, args []string) error { + if len(args) != 0 { + return fmt.Errorf("usage: eda version") + } + if _, err := fmt.Fprintf(stdout, "eda version %s\n", version); err != nil { + return fmt.Errorf("write version: %w", err) + } + return nil +} + // hookInput is the JSON Claude Code writes to worktree hooks. The cwd field // is the session's current directory; it decides the base of new branches so // a subagent spawned inside a worktree stacks on that worktree. The path diff --git a/main_test.go b/main_test.go index 2a146b5..a16267a 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -307,6 +308,41 @@ func TestRunHookWorktreeRemoveKeepsUnsafe(t *testing.T) { assertKept(t, repo, wt, "agent-abc") } +func TestRunVersion(t *testing.T) { + code, stdout, stderr := runEda(t, t.TempDir(), "", "version") + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr = %q", code, stderr) + } + if want := "eda version " + version + "\n"; stdout != want { + t.Errorf("stdout = %q, want %q", stdout, want) + } +} + +func TestRunVersionRejectsArguments(t *testing.T) { + code, stdout, stderr := runEda(t, t.TempDir(), "", "version", "extra") + if code == 0 { + t.Error("version with an argument must fail") + } + if stdout != "" { + t.Errorf("stdout must stay empty on error, got %q", stdout) + } + if !strings.Contains(stderr, "usage") { + t.Errorf("stderr must include usage, got %q", stderr) + } +} + +// tagpr rewrites the version constant in the release pull request and reads +// it back at merge time to decide the tag. It writes the bare x.y.z form, so +// a leading "v" would desynchronise file and tag. Leading zeros do the same +// more quietly: tagpr normalises "01.2.3" to the tag v1.2.3 while the binary +// keeps reporting 01.2.3. +func TestVersionIsBareSemver(t *testing.T) { + bareSemver := regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`) + if !bareSemver.MatchString(version) { + t.Errorf("version = %q, want bare semver without a leading v or leading zeros", version) + } +} + func TestRunUnknownCommand(t *testing.T) { repo := newTestRepo(t) code, _, stderr := runEda(t, repo, "", "bogus") @@ -363,6 +399,13 @@ func TestRunInitFailsOnBrokenStdout(t *testing.T) { } } +func TestRunVersionFailsOnBrokenStdout(t *testing.T) { + var stderr strings.Builder + if code := run(strings.NewReader(""), failWriter{}, &stderr, []string{"version"}, t.TempDir()); code == 0 { + t.Error("a failed version write must fail the command, not report success") + } +} + func TestRunHookWorktreeRemoveKeptReportFails(t *testing.T) { repo := newTestRepo(t) ctx, _ := loadRepoWithRoot(t, repo) From 137562f3f49746730ebf172245244f9cd16f80a8 Mon Sep 17 00:00:00 2001 From: Masayoshi Wada Date: Wed, 2 Sep 2026 16:57:41 +0900 Subject: [PATCH 2/2] add a tagpr and goreleaser release flow --- .github/release.yml | 4 +++ .github/workflows/release.yml | 56 +++++++++++++++++++++++++++++++++++ .gitignore | 1 + .goreleaser.yml | 45 ++++++++++++++++++++++++++++ .tagpr | 5 ++++ 5 files changed, 111 insertions(+) create mode 100644 .github/release.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 .tagpr diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..b0c5175 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + labels: + - tagpr diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4160404 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + tagpr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: read + outputs: + tag: ${{ steps.tagpr.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - id: tagpr + uses: Songmu/tagpr@d1b8138b7a31075141b6cd64103de9485ced7ac9 # v1.20.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + goreleaser: + needs: tagpr + if: needs.tagpr.outputs.tag != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + # tagpr creates the release pull request with GITHUB_TOKEN, which by + # design does not trigger workflows, so CI never runs on it. Test the + # tagged commit here instead of publishing an unverified build. + - run: go test ./... + + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + distribution: goreleaser + # The action is SHA-pinned but the CLI it downloads is not; keep it + # on the v2 line so a future major cannot change release behaviour. + version: "~> v2" + args: --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 75a2e15..01bd5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /eda +/dist diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..948da07 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,45 @@ +version: 2 + +builds: + - main: . + binary: eda + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + # No -X injection: the version lives in main.go and is maintained by + # tagpr, so a released binary reports the same value as `go install`. + ldflags: + - -s -w + +archives: + - formats: + - tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + +checksum: + name_template: "checksums.txt" + +# tagpr owns the release notes. It writes them into the draft release from +# the same pull request titles that go into CHANGELOG.md; goreleaser only +# attaches the artifacts to that draft and publishes it. +changelog: + disable: true + +release: + github: + owner: masawada + name: eda + use_existing_draft: true + mode: keep-existing + # A rerun after a partially uploaded release must not fail on assets that + # are already there. + replace_existing_artifacts: true diff --git a/.tagpr b/.tagpr new file mode 100644 index 0000000..ef30eaa --- /dev/null +++ b/.tagpr @@ -0,0 +1,5 @@ +[tagpr] + vPrefix = true + releaseBranch = main + release = draft + versionFile = main.go