From 878d1eb20a1f711e9da1cc03885414fcf250da5e Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 4 Nov 2026 23:09:10 -0600 Subject: [PATCH 1/3] Refactor: Carry the tagged commit in a bump target BumpTag names a worktree path and always tags HEAD, so it cannot yet tag another commit. Gather the directory, the branch that the upstream check compares, and the tagged ref into one bumpTarget, and pass the ref to createVersionTag. Preparing for an @ selector that tags the repository default branch instead of a worktree. --- internal/timber/bump_tag.go | 29 ++++++++++++++++++++--------- internal/timber/runtime.go | 31 ++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/internal/timber/bump_tag.go b/internal/timber/bump_tag.go index ecc6b60..95ebae9 100644 --- a/internal/timber/bump_tag.go +++ b/internal/timber/bump_tag.go @@ -39,6 +39,17 @@ type BumpResult struct { ReleaseURL string } +// bumpTarget is the commit that the next version tag points at. +type bumpTarget struct { + // directory is where the bump runs Git commands. + directory string + // branch is the branch that the behind-upstream guard compares. It is + // empty for the default branch, which the fetch before the bump refreshes. + branch string + // ref names the tagged commit. + ref string +} + // refreshBeforeBump fetches the tags and branch tips that the bump depends on, // then refuses to tag a commit that its upstream has moved past. // @@ -148,10 +159,10 @@ func hasRemote(runtime Runtime, directory, name string) bool { return false } -// createBumpedTag creates the next tag on HEAD and pushes it unless the -// request keeps it local. -func createBumpedTag(runtime Runtime, directory string, request TagBump) (string, error) { - latest, err := latestVersionTag(runtime, directory, request.Pattern) +// createBumpedTag creates the next tag on the target commit and pushes it +// unless the request keeps it local. +func createBumpedTag(runtime Runtime, target bumpTarget, request TagBump) (string, error) { + latest, err := latestVersionTag(runtime, target.directory, request.Pattern) if err != nil { return "", err } @@ -159,11 +170,11 @@ func createBumpedTag(runtime Runtime, directory string, request TagBump) (string if err != nil { return "", err } - if err := createVersionTag(runtime, directory, tag); err != nil { + if err := createVersionTag(runtime, target.directory, target.ref, tag); err != nil { return "", err } if !request.NoPush { - if err := pushVersionTag(runtime, directory, tag); err != nil { + if err := pushVersionTag(runtime, target.directory, tag); err != nil { return "", err } } @@ -215,12 +226,12 @@ func listVersionTags(runtime Runtime, directory string, pattern string) ([]strin return strings.Fields(result.stdout), nil } -// createVersionTag creates an annotated tag on HEAD whose message is the tag. -func createVersionTag(runtime Runtime, directory string, tag string) error { +// createVersionTag creates an annotated tag on ref whose message is the tag. +func createVersionTag(runtime Runtime, directory string, ref string, tag string) error { if _, err := bump.Parse(tag); err != nil { return err } - if _, err := gitOutput(runtime, directory, "tag", "--annotate", tag, "--message", tag); err != nil { + if _, err := gitOutput(runtime, directory, "tag", "--annotate", tag, ref, "--message", tag); err != nil { return fmt.Errorf("create tag %q: %w", tag, err) } return nil diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 53d2243..d29d92a 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -128,8 +128,13 @@ func (runtime Runtime) AddRepo(source, name, alias string) (string, string, erro return repoName, targetPath, nil } -// BumpTag creates the next semantic version tag on the commit of the selected -// worktree and pushes it to origin unless request.NoPush is set. +// BumpTag creates the next semantic version tag on the commit that the +// selector names and pushes it to origin unless request.NoPush is set. +// +// A worktree name tags that worktree's commit. @ without a worktree name +// tags the repository's default branch, so a release names the commit that +// origin serves and works outside a Git directory. Without a selector the bump +// tags the worktree that contains the current directory. // // Tags belong to the repository, not to one worktree, so any worktree of a // repository sees and bumps the same tags. @@ -142,22 +147,21 @@ func (runtime Runtime) AddRepo(source, name, alias string) (string, string, erro // and opens its URL in the browser unless request.NoOpen is set. A missing or // failing browser is a warning on output, not a failed bump. func (runtime Runtime) BumpTag(ctx context.Context, input io.Reader, output io.Writer, request TagBump) (BumpResult, error) { - selection := repoSelection{runtime: runtime} - _, _, worktree, err := selection.resolveQualifiedWorktree(input, request.Raw) + target, err := runtime.resolveBumpTarget(input, request.Raw) if err != nil { return BumpResult{}, err } - if err := refreshBeforeBump(runtime, worktree.Path, worktree.branchName()); err != nil { + if err := refreshBeforeBump(runtime, target.directory, target.branch); err != nil { return BumpResult{}, err } - tag, err := createBumpedTag(runtime, worktree.Path, request) + tag, err := createBumpedTag(runtime, target, request) if err != nil { return BumpResult{}, err } if !request.Release { return BumpResult{Tag: tag}, nil } - url, err := createDraftRelease(ctx, runtime, worktree.Path, tag) + url, err := createDraftRelease(ctx, runtime, target.directory, tag) if err != nil { return BumpResult{}, err } @@ -2045,6 +2049,19 @@ func (x Runtime) reportRenamedCurrentPath(path string) error { return nil } +// resolveBumpTarget turns the [name[@repo]] selector into the tagged commit. +// +// A worktree name tags that worktree's branch tip. Without a selector the bump +// tags the worktree that contains the current directory. +func (runtime Runtime) resolveBumpTarget(input io.Reader, raw string) (bumpTarget, error) { + selection := repoSelection{runtime: runtime} + _, _, worktree, err := selection.resolveQualifiedWorktree(input, raw) + if err != nil { + return bumpTarget{}, err + } + return bumpTarget{directory: worktree.Path, branch: worktree.branchName(), ref: "HEAD"}, nil +} + func (x Runtime) resolveCreateWorktreeName(repoName string, name string) (string, error) { if name != "" { return name, nil From c8558cdb3f222a076c247b573d6c2483a7e7c3f0 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 4 Nov 2026 23:09:31 -0600 Subject: [PATCH 2/3] Tag the default branch for an @ bump selector `timber bump --patch @repo` failed outside a Git directory because the bump always resolved a worktree, and a repository-only selector names none. Run the bump in the bare repository and tag the ref of the repository default branch, so origin/HEAD names the released commit and no checkout is needed. Fall back to origin/master, origin/main, then a local branch when origin/HEAD is missing. --- internal/cli/bump_functional_test.go | 63 ++++++++++++++++++++++++++++ internal/timber/qualified_name.go | 9 ++++ internal/timber/repository.go | 30 +++++++++++++ internal/timber/runtime.go | 26 +++++++++++- 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/internal/cli/bump_functional_test.go b/internal/cli/bump_functional_test.go index 7fe3275..c2842b4 100644 --- a/internal/cli/bump_functional_test.go +++ b/internal/cli/bump_functional_test.go @@ -53,6 +53,69 @@ func TestBumpFromTheCurrentWorktreeWithoutPush(t *testing.T) { assert.Empty(t, strings.TrimSpace(runGitCommand(t, repository.remotePath, "tag", "--list"))) } +// bumpedTagCommit returns the commit that tag points at. +func bumpedTagCommit(t *testing.T, repository testRepository, tag string) string { + t.Helper() + return strings.TrimSpace(runGitCommand(t, repository.barePath, "rev-parse", tag+"^{commit}")) +} + +// defaultBranchCommit returns the commit that origin/HEAD points at. +func defaultBranchCommit(t *testing.T, repository testRepository) string { + t.Helper() + return strings.TrimSpace(runGitCommand(t, repository.barePath, "rev-parse", "origin/HEAD^{commit}")) +} + +func TestBumpRepositorySelectorTagsTheDefaultBranchOutsideAnyWorktree(t *testing.T) { + t.Parallel() + const branchName = "release" + + repository := bumpTestRepository(t) + require.NoError(t, repository.runTimber(t, "create", at(testRepoName, branchName)).err) + // The worktree commits past the default branch, so the tag names one of + // two different commits and the assertion can tell them apart. + repository.commitFileInWorktree(t, branchName, "release.txt", "release\n") + + // A release can run from a scratch directory, outside any Git worktree. + result := repository.runTimberFrom(t, resolvedTempDir(t), "bump", "--patch", "@"+testRepoName) + require.NoError(t, result.err, result.stderr) + assert.Equal(t, "v0.0.1\n", result.stdout) + assert.Equal(t, defaultBranchCommit(t, repository), bumpedTagCommit(t, repository, "v0.0.1")) + assert.Equal(t, "v0.0.1", strings.TrimSpace(runGitCommand(t, repository.remotePath, "tag", "--list", "v0.0.1"))) +} + +func TestBumpRepositorySelectorTagsTheDefaultBranchFromAWorktree(t *testing.T) { + t.Parallel() + const branchName = "release" + + repository := bumpTestRepository(t) + worktreePath := repository.worktreePath(branchName) + require.NoError(t, repository.runTimber(t, "create", at(testRepoName, branchName)).err) + repository.commitFileInWorktree(t, branchName, "release.txt", "release\n") + + // The @repo selector names the repository, so it ignores the worktree that + // contains the cwd. + result := repository.runTimberFrom(t, worktreePath, "bump", "--major", "--no-push", "@"+testRepoName) + require.NoError(t, result.err, result.stderr) + assert.Equal(t, "v1.0.0\n", result.stdout) + assert.Equal(t, defaultBranchCommit(t, repository), bumpedTagCommit(t, repository, "v1.0.0")) +} + +func TestBumpRepositorySelectorFallsBackWithoutOriginHead(t *testing.T) { + t.Parallel() + + repository := bumpTestRepository(t) + require.NoError(t, repository.runTimber(t, "create", at(testRepoName, "release")).err) + // A bare clone that never learned the remote default branch still has the + // remote-tracking branch to tag. + runGitCommand(t, repository.barePath, "symbolic-ref", "--delete", "refs/remotes/origin/HEAD") + + result := repository.runTimberFrom(t, resolvedTempDir(t), "bump", "--patch", "@"+testRepoName) + require.NoError(t, result.err, result.stderr) + assert.Equal(t, "v0.0.1\n", result.stdout) + originMain := strings.TrimSpace(runGitCommand(t, repository.barePath, "rev-parse", "origin/main^{commit}")) + assert.Equal(t, originMain, bumpedTagCommit(t, repository, "v0.0.1")) +} + func TestBumpRequiresExactlyOneKind(t *testing.T) { t.Parallel() result := runTimberCommand(t, "bump", "--major", "--minor") diff --git a/internal/timber/qualified_name.go b/internal/timber/qualified_name.go index e40c86d..bb69426 100644 --- a/internal/timber/qualified_name.go +++ b/internal/timber/qualified_name.go @@ -10,6 +10,15 @@ type qualifiedName struct { Repo string } +// isRepoOnly reports whether the selector names a repository and no worktree, +// which is the form that targets repository state such as a version tag. +func (x qualifiedName) isRepoOnly() bool { + if x.Repo == "" { + return false + } + return x.Name == "" +} + // ValidateWorktreeName rejects names that conflict with repository qualifiers. func ValidateWorktreeName(name string) error { if strings.Contains(name, "@") { diff --git a/internal/timber/repository.go b/internal/timber/repository.go index 5dce5d7..ecd9310 100644 --- a/internal/timber/repository.go +++ b/internal/timber/repository.go @@ -154,6 +154,25 @@ func (x *Repository) createEmptyCommit(branchName string) error { return nil } +// defaultBranchRef names the ref that holds the repository's default branch +// commit, which is the commit that an @ bump tags. +func (x *Repository) defaultBranchRef() (referenceName, error) { + branch, err := x.remoteHeadBranch() + if err != nil { + return "", err + } + for _, ref := range defaultBranchCandidates(branch) { + exists, err := x.branchStillExists(ref) + if err != nil { + return "", err + } + if exists { + return ref, nil + } + } + return "", fmt.Errorf("default branch %q has no ref to tag", branch) +} + func (x *Repository) ensureOriginRemoteTracking() error { hasOrigin, err := x.hasOriginRemote() if err != nil { @@ -465,6 +484,17 @@ func (x *Repository) upstreamRemoteURL(upstream string) (string, error) { return remoteURL.stdout, nil } +// defaultBranchCandidates lists the refs that can hold the default branch +// commit, most authoritative first. remoteHeadBranch names either a +// remote-tracking branch, such as origin/main, or a local branch, such as the +// HEAD branch of a repository without a remote. +func defaultBranchCandidates(branch string) []referenceName { + if localBranch, found := strings.CutPrefix(branch, remoteName+"/"); found { + return []referenceName{referenceName(remoteRefPrefix + branch), branchReference(localBranch)} + } + return []referenceName{referenceName(remoteRefPrefix + remoteName + "/" + branch), branchReference(branch)} +} + func longestRemotePrefix(remotes, ref string) string { var selected string for remote := range strings.SplitSeq(remotes, "\n") { diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index d29d92a..1c6efa7 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -1056,6 +1056,21 @@ func (x Runtime) currentHerdrSpace(ctx context.Context, worktree managedWorktree return parseCurrentHerdrSpace(x, output, absolutePath, qualifiedWorktreeName(worktree.Name, worktree.Repo)) } +// defaultBranchBumpTarget tags the default branch of a repository. The bare +// repository is the common Git directory of every worktree, so the tag lands +// where all of them see it. +func (runtime Runtime) defaultBranchBumpTarget(repoName string) (bumpTarget, error) { + repository, repo, err := runtime.openRegisteredRepository(repoName) + if err != nil { + return bumpTarget{}, err + } + ref, err := repository.defaultBranchRef() + if err != nil { + return bumpTarget{}, fmt.Errorf("repository %q: %w", repoName, err) + } + return bumpTarget{directory: repo.BarePath, ref: string(ref)}, nil +} + func (x Runtime) defineCurrentHerdrSpace(ctx context.Context, worktree managedWorktree) error { space, err := x.currentHerdrSpace(ctx, worktree) if err != nil { @@ -2051,9 +2066,18 @@ func (x Runtime) reportRenamedCurrentPath(path string) error { // resolveBumpTarget turns the [name[@repo]] selector into the tagged commit. // -// A worktree name tags that worktree's branch tip. Without a selector the bump +// A worktree name tags that worktree's branch tip. @ without a worktree +// name tags the repository's default branch, so a release names the commit +// that origin serves and needs no checkout at all. Without a selector the bump // tags the worktree that contains the current directory. func (runtime Runtime) resolveBumpTarget(input io.Reader, raw string) (bumpTarget, error) { + qualified, err := runtime.parseQualifiedName(raw) + if err != nil { + return bumpTarget{}, err + } + if qualified.isRepoOnly() { + return runtime.defaultBranchBumpTarget(qualified.Repo) + } selection := repoSelection{runtime: runtime} _, _, worktree, err := selection.resolveQualifiedWorktree(input, raw) if err != nil { From 66ce305bf481311d9f5c021d660c4ab8dc7dd0c4 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 4 Nov 2026 23:09:47 -0600 Subject: [PATCH 3/3] Document the tagged commit of each bump selector The README and the bundled skill described the selector as naming a worktree only, so a reader could not know that @ tags the default branch and needs no checkout. --- README.md | 13 ++++++++++--- skills/timber/SKILL.md | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c8a225..82d8191 100644 --- a/README.md +++ b/README.md @@ -208,17 +208,23 @@ For the Pi extension and package installation, see [pi/README.md](pi/README.md). ```bash timber bump --patch feature/login@timber +timber bump --patch @timber timber bump --minor --pattern 'v1.*' feature/login@timber timber bump --major --no-push feature/login@timber timber bump --patch --release --no-open feature/login@timber ``` Exactly one of `--major`, `--minor`, or `--patch` is required. -The command reads the highest strict `vMAJOR.MINOR.PATCH` tag in the repository, creates the next tag as an annotated tag on the selected worktree's commit, and pushes it to `origin`. -Tags belong to the repository rather than to one checkout, so every worktree of a repository sees the same tags; the selector names the commit that the new tag points at, and defaults to the current worktree. +The command reads the highest strict `vMAJOR.MINOR.PATCH` tag in the repository, creates the next tag as an annotated tag on the selected commit, and pushes it to `origin`. +Tags belong to the repository rather than to one checkout, so every worktree of a repository sees the same tags; the selector names the commit that the new tag points at. + +- `@` tags that worktree's branch tip. +- `@` tags the repository's default branch, so a release names the commit that `origin` serves and needs no checkout; the command works outside a Git directory. +- No selector tags the worktree that contains the current directory. Before it reads tags or creates one, the command fetches the remote of the tracked branch, and `origin`, so a tag created on another machine is in the candidate list. It refuses a worktree that is behind its upstream, because the new tag would name an outdated commit; update the worktree and run the command again. +The default branch is fetched, so the check does not apply to `@`. A repository without a remote still bumps locally. - `--pattern` limits candidates the way `git tag -l ` does. @@ -397,7 +403,8 @@ Worktree commands take an optional `@` qualifier on the name arg `list` and `prune` use every registered repository unless `@` pins one. `create` (and `switch -c`) use the current repository when the cwd is a managed worktree of a registered repo, otherwise an interactive picker. -`remove`, `herdr space`, `pr create`, and `bump` with no name target the managed worktree that contains the cwd. +`remove`, `herdr space`, and `pr create` with no name target the managed worktree that contains the cwd. +`bump` with no name targets the same worktree, and `bump @` targets the repository's default branch. In non-interactive environments commands that need a single repository fail unless `@` is set or the cwd auto-detects a managed repo. diff --git a/skills/timber/SKILL.md b/skills/timber/SKILL.md index 5db6946..b3e7985 100644 --- a/skills/timber/SKILL.md +++ b/skills/timber/SKILL.md @@ -93,6 +93,8 @@ timber pr create feature/login@owner/repo --title "Add login" --base develop --d # Create the next version tag and push it to origin. timber bump --patch feature/login@owner/repo +# Tag the repository's default branch, without a checkout: +timber bump --patch @owner/repo # Create a draft GitHub release for the new tag, without opening a browser: timber bump --minor --release --no-open feature/login@owner/repo @@ -125,6 +127,7 @@ timber remove feature/login@owner/repo Report the printed URL. 1. For a release tag, name the worktree that has the commit to tag. `bump` tags that worktree's commit, so do not run it on an arbitrary worktree. + Use `@` without a worktree name to tag the repository's default branch; that form needs no checkout and works outside a Git directory. The command fetches first and refuses a worktree that is behind its upstream: update the worktree, then run it again. Push the branch first; a tag never replaces a branch push. Use `--no-push` only when the user wants a local tag. @@ -162,6 +165,7 @@ timber remove feature/login@owner/repo Resolve the path instead of writing a checkout-root TODO.md. Notes are local Git state, not committed source; preserve worktree notes before removal. - `bump` changes shared repository state: it creates a tag, and pushes it to `origin` unless `--no-push` is set. + The selector names the tagged commit: a worktree tags that worktree's branch tip, `@` tags the repository's default branch, and no selector tags the worktree that contains the cwd. It fetches the tracked remote and `origin` first, and fails when the worktree is behind its upstream; never force a tag onto a stale checkout. Tags belong to the repository, so several worktrees see the same tag. The highest tag can be on another branch, so check the refs first when the answer matters.