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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

- `<worktree>@<repo>` tags that worktree's branch tip.
- `@<repo>` 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 `@<repo>`.
A repository without a remote still bumps locally.

- `--pattern` limits candidates the way `git tag -l <pattern>` does.
Expand Down Expand Up @@ -397,7 +403,8 @@ Worktree commands take an optional `<worktree>@<repo>` qualifier on the name arg
`list` and `prune` use every registered repository unless `@<repo>` 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 @<repo>` targets the repository's default branch.

In non-interactive environments commands that need a single repository fail unless `@<repo>` is set or the cwd auto-detects a managed repo.

Expand Down
63 changes: 63 additions & 0 deletions internal/cli/bump_functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
29 changes: 20 additions & 9 deletions internal/timber/bump_tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -148,22 +159,22 @@ 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
}
tag, err := bump.Tag(latest, request.Kind)
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
}
}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/timber/qualified_name.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "@") {
Expand Down
30 changes: 30 additions & 0 deletions internal/timber/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 @<repo> 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 {
Expand Down Expand Up @@ -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") {
Expand Down
55 changes: 48 additions & 7 deletions internal/timber/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. @<repo> 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.
Expand All @@ -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
}
Expand Down Expand Up @@ -1052,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 {
Expand Down Expand Up @@ -2045,6 +2064,28 @@ 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. @<repo> 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 {
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
Expand Down
4 changes: 4 additions & 0 deletions skills/timber/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `@<repo>` 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.
Expand Down Expand Up @@ -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, `@<repo>` 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.
Expand Down
Loading