From 0c4a9a83716d955ed3b230a172472ecdcfec745b Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 19:54:08 -0500 Subject: [PATCH 1/8] feat: support configurable version sources Resolve versions from local Git tags or VERSION, with source-specific increment and set behavior. Add specs, requirements, and regression coverage for issue #181. --- .agents/SPECS | 2 + .agents/requirements/README.md | 13 ++ .../001-file-authority.requirement.md | 25 +++ .../002-file-initialization.requirement.md | 24 +++ .../003-git-current-version.requirement.md | 25 +++ .../004-git-increment.requirement.md | 23 +++ .../005-git-set.requirement.md | 24 +++ .../006-current-ref-tags.requirement.md | 25 +++ .../007-file-increment.requirement.md | 24 +++ .../008-file-set.requirement.md | 23 +++ .agents/specifications/README.md | 11 ++ .../specifications/version-sources.spec.md | 39 +++++ AGENTS.md | 17 ++ README.md | 33 +++- deno.json | 2 +- deno.lock | 19 +- src/commands/get/major.ts | 4 +- src/commands/get/minor.ts | 4 +- src/commands/get/none.ts | 4 +- src/commands/get/patch.ts | 4 +- src/commands/inc/major.ts | 17 +- src/commands/inc/minor.ts | 17 +- src/commands/inc/none.ts | 17 +- src/commands/inc/patch.ts | 17 +- src/commands/parse.ts | 9 +- src/commands/set.test.ts | 27 +-- src/commands/set.ts | 8 +- src/hooks/hooks.interfaces.ts | 1 + src/hooks/post.ts | 26 +-- src/util/config.test.ts | 17 ++ src/util/config.ts | 45 +++++ src/util/source.test.ts | 163 ++++++++++++++++++ src/util/version.test.ts | 16 +- src/util/version.ts | 66 +++++-- 34 files changed, 662 insertions(+), 129 deletions(-) create mode 100644 .agents/SPECS create mode 100644 .agents/requirements/README.md create mode 100644 .agents/requirements/version-sources/001-file-authority.requirement.md create mode 100644 .agents/requirements/version-sources/002-file-initialization.requirement.md create mode 100644 .agents/requirements/version-sources/003-git-current-version.requirement.md create mode 100644 .agents/requirements/version-sources/004-git-increment.requirement.md create mode 100644 .agents/requirements/version-sources/005-git-set.requirement.md create mode 100644 .agents/requirements/version-sources/006-current-ref-tags.requirement.md create mode 100644 .agents/requirements/version-sources/007-file-increment.requirement.md create mode 100644 .agents/requirements/version-sources/008-file-set.requirement.md create mode 100644 .agents/specifications/README.md create mode 100644 .agents/specifications/version-sources.spec.md create mode 100644 AGENTS.md create mode 100644 src/util/config.test.ts create mode 100644 src/util/config.ts create mode 100644 src/util/source.test.ts diff --git a/.agents/SPECS b/.agents/SPECS new file mode 100644 index 0000000..cc1e3d2 --- /dev/null +++ b/.agents/SPECS @@ -0,0 +1,2 @@ +specs v1 +https://github.com/optum-eeps/specs diff --git a/.agents/requirements/README.md b/.agents/requirements/README.md new file mode 100644 index 0000000..aa27818 --- /dev/null +++ b/.agents/requirements/README.md @@ -0,0 +1,13 @@ +# semver-cli requirements + +Testable requirements derived from the chapters in +[`../specifications/`](../specifications/README.md), one per file under a topic +folder. Each cites the section it refines in `spec_ref`, and the tests that +verify it cite it with `req:{id}`. + +## Topics + +- [Version sources](version-sources/001-file-authority.requirement.md) covers + source selection, file initialization, tag lookup, increments, and setting a + version. The requirements are verified by `src/util/config.test.ts` and + `src/util/source.test.ts`. diff --git a/.agents/requirements/version-sources/001-file-authority.requirement.md b/.agents/requirements/version-sources/001-file-authority.requirement.md new file mode 100644 index 0000000..508e40b --- /dev/null +++ b/.agents/requirements/version-sources/001-file-authority.requirement.md @@ -0,0 +1,25 @@ +--- +id: version-sources-001 +title: File source uses VERSION as its authority +spec_ref: ".agents/specifications/version-sources.spec.md#source-selection; .agents/specifications/version-sources.spec.md#file-source" +--- + +# File source uses VERSION as its authority + +## Requirement + +Preconditions: + +- The configuration omits `source` or sets it to `file`. +- `VERSION` contains a valid version, which may differ from the latest tag. + +Expected behavior: + +- `get` MUST report the version in `VERSION`. +- `get` MUST NOT change the existing file. + +Postconditions: + +- `VERSION` retains its original content. +- Tests: `src/util/config.test.ts` and `src/util/source.test.ts` + (`req:version-sources-001`). diff --git a/.agents/requirements/version-sources/002-file-initialization.requirement.md b/.agents/requirements/version-sources/002-file-initialization.requirement.md new file mode 100644 index 0000000..8e9204a --- /dev/null +++ b/.agents/requirements/version-sources/002-file-initialization.requirement.md @@ -0,0 +1,24 @@ +--- +id: version-sources-002 +title: Missing VERSION is initialized from a tag or the default +spec_ref: ".agents/specifications/version-sources.spec.md#file-source" +--- + +# Missing VERSION is initialized from a tag or the default + +## Requirement + +Preconditions: + +- The source is `file` and `VERSION` is absent. + +Expected behavior: + +- The CLI MUST create `VERSION` containing the highest parseable local tag + reachable from the current ref, or `0.1.0` when no such tag exists. +- Initialization MUST work outside a Git repository by using `0.1.0`. + +Postconditions: + +- The file contains the selected version followed by a newline. +- Tests: `src/util/source.test.ts` (`req:version-sources-002`). diff --git a/.agents/requirements/version-sources/003-git-current-version.requirement.md b/.agents/requirements/version-sources/003-git-current-version.requirement.md new file mode 100644 index 0000000..e46fe43 --- /dev/null +++ b/.agents/requirements/version-sources/003-git-current-version.requirement.md @@ -0,0 +1,25 @@ +--- +id: version-sources-003 +title: Git source derives the current version from tags +spec_ref: ".agents/specifications/version-sources.spec.md#git-source" +--- + +# Git source derives the current version from tags + +## Requirement + +Preconditions: + +- The configuration sets `source: git`. + +Expected behavior: + +- `get` and `parse` without a value MUST report the version from the highest + parseable local tag reachable from the current ref, or `0.1.0` if none exists. +- Resolving the current version MUST remove an existing `VERSION` file. + +Postconditions: + +- The resolved version is independent of the former file content. +- Tests: `src/util/config.test.ts` and `src/util/source.test.ts` + (`req:version-sources-003`). diff --git a/.agents/requirements/version-sources/004-git-increment.requirement.md b/.agents/requirements/version-sources/004-git-increment.requirement.md new file mode 100644 index 0000000..b5d593c --- /dev/null +++ b/.agents/requirements/version-sources/004-git-increment.requirement.md @@ -0,0 +1,23 @@ +--- +id: version-sources-004 +title: Git increment reports a proposal without changing state +spec_ref: ".agents/specifications/version-sources.spec.md#git-source" +--- + +# Git increment reports a proposal without changing state + +## Requirement + +Preconditions: + +- The configuration sets `source: git` and defines a post hook. + +Expected behavior: + +- `inc patch` MUST report the same proposed version as `git-latest patch`. +- It MUST NOT create `VERSION` or a tag, and MUST NOT run the post hook. + +Postconditions: + +- Local tags and the hook target retain their original content. +- Tests: `src/util/source.test.ts` (`req:version-sources-004`). diff --git a/.agents/requirements/version-sources/005-git-set.requirement.md b/.agents/requirements/version-sources/005-git-set.requirement.md new file mode 100644 index 0000000..a2f4ff6 --- /dev/null +++ b/.agents/requirements/version-sources/005-git-set.requirement.md @@ -0,0 +1,24 @@ +--- +id: version-sources-005 +title: Git set applies hooks without persisting the specified version +spec_ref: ".agents/specifications/version-sources.spec.md#git-source" +--- + +# Git set applies hooks without persisting the specified version + +## Requirement + +Preconditions: + +- The configuration sets `source: git` and defines a post hook. + +Expected behavior: + +- `set` MUST apply its specified version, including prerelease metadata, to the + configured post hook. +- It MUST NOT create `VERSION` or a Git tag. + +Postconditions: + +- The hook target reflects the specified version; the tag set is unchanged. +- Tests: `src/util/source.test.ts` (`req:version-sources-005`). diff --git a/.agents/requirements/version-sources/006-current-ref-tags.requirement.md b/.agents/requirements/version-sources/006-current-ref-tags.requirement.md new file mode 100644 index 0000000..07fd3dd --- /dev/null +++ b/.agents/requirements/version-sources/006-current-ref-tags.requirement.md @@ -0,0 +1,25 @@ +--- +id: version-sources-006 +title: Tag lookup excludes tags outside the current ref +spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" +--- + +# Tag lookup excludes tags outside the current ref + +## Requirement + +Preconditions: + +- A higher version tag exists only on an unrelated branch. +- A lower version tag is reachable from the current ref. + +Expected behavior: + +- Tag based resolution MUST choose the reachable tag even though the unrelated + tag has a higher version. +- It MUST work without a configured remote. + +Postconditions: + +- Neither the branch nor its tags are changed by lookup. +- Tests: `src/util/source.test.ts` (`req:version-sources-006`). diff --git a/.agents/requirements/version-sources/007-file-increment.requirement.md b/.agents/requirements/version-sources/007-file-increment.requirement.md new file mode 100644 index 0000000..1ac5a9a --- /dev/null +++ b/.agents/requirements/version-sources/007-file-increment.requirement.md @@ -0,0 +1,24 @@ +--- +id: version-sources-007 +title: File increment persists the proposed version and runs hooks +spec_ref: ".agents/specifications/version-sources.spec.md#file-source" +--- + +# File increment persists the proposed version and runs hooks + +## Requirement + +Preconditions: + +- The source is `file`, `VERSION` contains `1.2.3`, and a post hook targets a + project file containing `1.2.3`. + +Expected behavior: + +- `inc patch` MUST persist `1.2.4` in `VERSION` and apply the configured hook + with that version. + +Postconditions: + +- Both `VERSION` and the hook target reflect `1.2.4`. +- Tests: `src/util/source.test.ts` (`req:version-sources-007`). diff --git a/.agents/requirements/version-sources/008-file-set.requirement.md b/.agents/requirements/version-sources/008-file-set.requirement.md new file mode 100644 index 0000000..fd673a8 --- /dev/null +++ b/.agents/requirements/version-sources/008-file-set.requirement.md @@ -0,0 +1,23 @@ +--- +id: version-sources-008 +title: File set persists the specified version and runs hooks +spec_ref: ".agents/specifications/version-sources.spec.md#file-source" +--- + +# File set persists the specified version and runs hooks + +## Requirement + +Preconditions: + +- The source is `file` and a post hook targets a project file. + +Expected behavior: + +- `set` MUST write the specified version, including prerelease metadata, to + `VERSION` and apply the configured post hook with that version. + +Postconditions: + +- Both `VERSION` and the hook target reflect the specified version. +- Tests: `src/util/source.test.ts` (`req:version-sources-008`). diff --git a/.agents/specifications/README.md b/.agents/specifications/README.md new file mode 100644 index 0000000..79bcec7 --- /dev/null +++ b/.agents/specifications/README.md @@ -0,0 +1,11 @@ +# semver-cli specification + +The normative contract for semver-cli, as a set of focused chapters. Each +chapter states what the system guarantees; requirements in +[`../requirements/`](../requirements/README.md) refine chapters into testable +statements. + +## Chapters + +- [Version sources](version-sources.spec.md) describes how commands derive and + apply the current version. diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md new file mode 100644 index 0000000..f506a28 --- /dev/null +++ b/.agents/specifications/version-sources.spec.md @@ -0,0 +1,39 @@ +# Version sources + +This chapter defines how semver-cli derives the current version and how commands +apply a proposed version. The source is selected by the top level `source` +setting in the version configuration. + +## Source selection + +The supported sources are `file` and `git`. When `source` is absent, the source +is `file`. + +## File source + +An existing `VERSION` file in the working directory is authoritative. When it is +absent, the CLI initializes it from the highest parseable local Git tag +reachable from the current ref. If no such tag exists, it initializes the file +to `0.1.0`. + +`get` reports a version derived from the current file without changing its +content. `inc` persists the proposed version to the file and applies configured +post hooks. `set` writes the specified version to the file and applies the +configured post hooks. + +## Git source + +The highest parseable local Git tag reachable from the current ref is +authoritative. If no such tag exists, the current version is `0.1.0`. Commands +that resolve the current version remove an existing `VERSION` file. + +`get` and `parse` without an explicit value derive their result from that +current version. `inc` calculates and reports a proposed version using the same +version calculation as `git-latest`; it does not write a file, create a tag, or +apply post hooks. `set` applies the specified version to configured post hooks +without creating a `VERSION` file or a tag. + +## Tag lookup + +Tag based version resolution uses local tags for the current ref and does not +fetch from a remote. If no parseable tag is reachable, it uses `0.1.0`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c309fbe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +## Specifications + +This repository follows the specs method, as declared in `.agents/SPECS` +(). + +- Authority, highest first: specifications (`.agents/specifications/`), + requirements (`.agents/requirements/`), tests, implementation. Never change a + lower layer to contradict a higher one; if a request conflicts with the + specification or a requirement, ask before proceeding. +- A behavior change updates the specification or requirement that covers it in + the same change as the code. +- Tests cite the requirements they verify with `req:{id}`. +- Before writing specifications or requirements, use the `specs` and + `requirements` skills. Without the plugin installed, read them at + + and + . diff --git a/README.md b/README.md index 24cbe21..339f6fa 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,11 @@ Options: ### About -The `get`, `set` and `inc` commands operate against your working directory. They -all operate against a `VERSION` file found in the root folder of your project. +The `get`, `set` and `inc` commands operate against your working directory. By +default, they use a `VERSION` file in the root folder of your project. -If the `VERSION` file is not found the default version is `0.1.0`. The `inc` -command will create the `VERSION` file if it doesn't already exist. +If the `VERSION` file is not found, semver initializes it from the current ref's +local git tags, falling back to `0.1.0` when there are no tags. The `parse` command accepts a version string as input and parses and prints that version as output if it is valid. @@ -82,10 +82,10 @@ The `sort` command accepts one or more version strings and outputs them in sorted order (descending by default, one version per line). Use the `-a` flag for ascending order, or read versions from stdin using `--`. -The `git-latest` command returns the most recent parseable git tag. If an error -occurs (e.g., git is unavailable, not in a git repo, no tags, etc.), exits 1. -This **does not** create or modify tags. Supports the same subcommands and -options as `get`/`set`/`inc`, i.e.: +The `git-latest` command returns the highest parseable local git tag reachable +from the current ref, or `0.1.0` when there are no tags. Git errors still +exit 1. This **does not** create or modify tags. Supports the same subcommands +and options as `get`/`set`/`inc`, i.e.: ```sh semver git-latest # 1.2.3 @@ -179,6 +179,23 @@ In addition to updating the `VERSION` file, semver can optionally update source code files in your project. To configure the version command you can provide a configuration file at `.github/version.yml`. +The optional top level `source` setting selects where the current version comes +from. The default is `file`: an existing `VERSION` is authoritative. When the +file is missing, semver initializes it from the latest local git tag reachable +from the current ref, or `0.1.0` if there is no tag. With `source: git`, local +git tags are authoritative and any `VERSION` file is removed. `get`, `parse` +(without a value), and `inc` use the tag version, falling back to `0.1.0`. `inc` +only prints the proposed version in this mode; `set` applies its specified +version to the configured post hooks without creating a file or tag. + +```yml +source: git +on: + post: + - kind: patch + file: package.json +``` + There are a number of supported post hook kinds and a number of supported known project kinds which make updating source code files easy. diff --git a/deno.json b/deno.json index 340486f..289085f 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,7 @@ "tasks": { "check": "deno fmt && deno lint && deno task test && deno run -A main.ts get -j && deno run -A main.ts parse 1.2.3-alpha.1+build.123 -j", "install": "deno install --allow-run --allow-env --allow-read --allow-write -f main.ts -n semver", - "test": "deno test && deno task test:node && deno task test:helm && deno task test:maven && deno task test:dotnet", + "test": "deno test --allow-all && deno task test:node && deno task test:helm && deno task test:maven && deno task test:dotnet", "test:watch": "deno test --watch", "test:node": "(cd test/node && deno run -A ../../main.ts inc minor --prerelease alpha --build xyz.987)", "test:helm": "(cd test/helm && deno run -A ../../main.ts inc minor --prerelease beta --build abc.123)", diff --git a/deno.lock b/deno.lock index a022c0b..6817720 100644 --- a/deno.lock +++ b/deno.lock @@ -1,10 +1,14 @@ { "version": "5", "specifiers": { + "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/assert@^1.0.17": "1.0.19", "jsr:@std/fmt@^1.0.8": "1.0.10", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.0.6": "1.1.6", "jsr:@std/semver@^1.0.6": "1.0.8", + "jsr:@std/testing@^1.0.3": "1.0.17", "jsr:@std/yaml@^1.0.5": "1.3.0", "npm:@conventional-changelog/git-client@^3.2.0": "3.2.0", "npm:json5@^2.2.3": "2.2.3", @@ -12,6 +16,12 @@ "npm:yargs@^17.7.2": "17.7.3" }, "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/fmt@1.0.10": { "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" }, @@ -21,12 +31,19 @@ "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/semver@1.0.8": { "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/assert@^1.0.17", + "jsr:@std/internal@^1.0.12" + ] + }, "@std/yaml@1.3.0": { "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" } diff --git a/src/commands/get/major.ts b/src/commands/get/major.ts index 0f47b5a..519c8d7 100644 --- a/src/commands/get/major.ts +++ b/src/commands/get/major.ts @@ -1,6 +1,6 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; -import { printVersion, readVersionFile } from "../../util/version.ts"; +import { printVersion, readCurrentVersion } from "../../util/version.ts"; import { IContext } from "../../context.ts"; export const major = { @@ -8,7 +8,7 @@ export const major = { describe: "A major version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { current } = increment({ version, kind: IncrementKind.Major, diff --git a/src/commands/get/minor.ts b/src/commands/get/minor.ts index acbc248..847fd6a 100644 --- a/src/commands/get/minor.ts +++ b/src/commands/get/minor.ts @@ -1,6 +1,6 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; -import { printVersion, readVersionFile } from "../../util/version.ts"; +import { printVersion, readCurrentVersion } from "../../util/version.ts"; import { IContext } from "../../context.ts"; export const minor = { @@ -8,7 +8,7 @@ export const minor = { describe: "A minor version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { current } = increment({ version, kind: IncrementKind.Minor, diff --git a/src/commands/get/none.ts b/src/commands/get/none.ts index ee30ea0..54bfe65 100644 --- a/src/commands/get/none.ts +++ b/src/commands/get/none.ts @@ -1,6 +1,6 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; -import { printVersion, readVersionFile } from "../../util/version.ts"; +import { printVersion, readCurrentVersion } from "../../util/version.ts"; import { IContext } from "../../context.ts"; export const none = { @@ -8,7 +8,7 @@ export const none = { describe: "Gets the version", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { current } = increment({ version, kind: IncrementKind.None, diff --git a/src/commands/get/patch.ts b/src/commands/get/patch.ts index 1b36d35..7a4ed3e 100644 --- a/src/commands/get/patch.ts +++ b/src/commands/get/patch.ts @@ -1,6 +1,6 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; -import { printVersion, readVersionFile } from "../../util/version.ts"; +import { printVersion, readCurrentVersion } from "../../util/version.ts"; import { IContext } from "../../context.ts"; export const patch = { @@ -8,7 +8,7 @@ export const patch = { describe: "A patch version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { current } = increment({ version, kind: IncrementKind.Patch, diff --git a/src/commands/inc/major.ts b/src/commands/inc/major.ts index 2b3ff56..68bf929 100644 --- a/src/commands/inc/major.ts +++ b/src/commands/inc/major.ts @@ -2,10 +2,11 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; import { printVersion, - readVersionFile, - writeVersionFile, + readCurrentVersion, + writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; +import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const major = { @@ -13,19 +14,17 @@ export const major = { describe: "A major version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { previous, current } = increment({ version, kind: IncrementKind.Major, prerelease, build, }); - await writeVersionFile(current); - const hookWarnings = await postVersionHook( - args, - previous, - current, - ); + await writeCurrentVersion(args, current); + const hookWarnings = await getVersionSource(args) === "file" + ? await postVersionHook(args, previous, current) + : []; await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/minor.ts b/src/commands/inc/minor.ts index cb5df7c..c928edf 100644 --- a/src/commands/inc/minor.ts +++ b/src/commands/inc/minor.ts @@ -2,10 +2,11 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; import { printVersion, - readVersionFile, - writeVersionFile, + readCurrentVersion, + writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; +import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const minor = { @@ -13,19 +14,17 @@ export const minor = { describe: "A minor version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { previous, current } = increment({ version, kind: IncrementKind.Minor, prerelease, build, }); - await writeVersionFile(current); - const hookWarnings = await postVersionHook( - args, - previous, - current, - ); + await writeCurrentVersion(args, current); + const hookWarnings = await getVersionSource(args) === "file" + ? await postVersionHook(args, previous, current) + : []; await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/none.ts b/src/commands/inc/none.ts index 3a85c9b..9ddef36 100644 --- a/src/commands/inc/none.ts +++ b/src/commands/inc/none.ts @@ -2,10 +2,11 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; import { printVersion, - readVersionFile, - writeVersionFile, + readCurrentVersion, + writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; +import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const none = { @@ -13,19 +14,17 @@ export const none = { describe: "A none version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { previous, current } = increment({ version, kind: IncrementKind.None, prerelease, build, }); - await writeVersionFile(current); - const hookWarnings = await postVersionHook( - args, - previous, - current, - ); + await writeCurrentVersion(args, current); + const hookWarnings = await getVersionSource(args) === "file" + ? await postVersionHook(args, previous, current) + : []; await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/patch.ts b/src/commands/inc/patch.ts index 73ee5a2..227d185 100644 --- a/src/commands/inc/patch.ts +++ b/src/commands/inc/patch.ts @@ -2,10 +2,11 @@ import type { Arguments } from "yargs"; import { increment, IncrementKind } from "../../util/increment.ts"; import { printVersion, - readVersionFile, - writeVersionFile, + readCurrentVersion, + writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; +import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const patch = { @@ -13,19 +14,17 @@ export const patch = { describe: "A patch version increment", handler: async (args: Arguments & IContext) => { const { prerelease, build } = args; - const version = await readVersionFile(); + const version = await readCurrentVersion(args); const { previous, current } = increment({ version, kind: IncrementKind.Patch, prerelease, build, }); - await writeVersionFile(current); - const hookWarnings = await postVersionHook( - args, - previous, - current, - ); + await writeCurrentVersion(args, current); + const hookWarnings = await getVersionSource(args) === "file" + ? await postVersionHook(args, previous, current) + : []; await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 3376340..3d7eae1 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -2,9 +2,9 @@ import type { Arguments } from "yargs"; import type { YargsInstance } from "yargs"; import * as semver from "semver"; import { InvalidVersionError } from "../errors/mod.ts"; -import { printVersion } from "../util/version.ts"; +import { printVersion, readCurrentVersion } from "../util/version.ts"; import { IContext } from "../context.ts"; -import { json, output } from "./options.ts"; +import { config, json, output } from "./options.ts"; export const parse = { command: "parse [value]", @@ -12,14 +12,15 @@ export const parse = { builder(yargs: YargsInstance) { return yargs .positional("value", { - describe: "The version to parse, or the VERSION file (default)", + describe: "The version to parse, or the current version (default)", }) .option("output", output) + .option("config", config) .option("json", json); }, async handler(args: Arguments & IContext) { const { value } = args; - const result = semver.parse(value); + const result = value ? semver.parse(value) : await readCurrentVersion(args); if (!result) { throw new InvalidVersionError(`${result}`); } diff --git a/src/commands/set.test.ts b/src/commands/set.test.ts index 29a4cf5..8de0446 100644 --- a/src/commands/set.test.ts +++ b/src/commands/set.test.ts @@ -109,20 +109,21 @@ describe("set", () => { }); describe("with an invalid version file", () => { - const notfound = new Deno.errors.NotFound("not found"); - // deno-lint-ignore no-explicit-any - (notfound as any).code = "ENOENT"; it("SET08", async () => { - await assertRejects(async () => { - await set.handler( - { - _: [], - current: "1.2.3", - hooks, - config: "version.yml", - } as unknown as Arguments & IContext, - ); - }); + const readTextFile = stub( + Deno, + "readTextFile", + resolvesNext(["invalid"]), + ); + try { + await assertRejects(async () => { + await set.handler( + { _: [], value: "1.2.3", hooks } as unknown as Arguments & IContext, + ); + }); + } finally { + readTextFile.restore(); + } }); }); describe("patch with csproj posthook", () => { diff --git a/src/commands/set.ts b/src/commands/set.ts index 5788444..c36aa56 100644 --- a/src/commands/set.ts +++ b/src/commands/set.ts @@ -2,8 +2,8 @@ import type { Arguments, YargsInstance } from "yargs"; import { parse } from "semver"; import { printVersion, - readVersionFile, - writeVersionFile, + readCurrentVersion, + writeCurrentVersion, } from "../util/version.ts"; import { postVersionHook } from "../hooks/mod.ts"; import { IContext } from "../context.ts"; @@ -23,9 +23,9 @@ export const set = { }, async handler(args: Arguments & IContext) { const { value } = args; - const previous = await readVersionFile(); + const previous = await readCurrentVersion(args); const version = value ? parse(value) : previous ? previous : parse("0.1.0"); - await writeVersionFile(version); + await writeCurrentVersion(args, version); const hookWarnings = await postVersionHook( args, previous, diff --git a/src/hooks/hooks.interfaces.ts b/src/hooks/hooks.interfaces.ts index 499b7fe..272951a 100644 --- a/src/hooks/hooks.interfaces.ts +++ b/src/hooks/hooks.interfaces.ts @@ -33,6 +33,7 @@ export type PostHook = ); export type VersionConfig = { + source?: "file" | "git"; on?: { post?: PostHook[]; }; diff --git a/src/hooks/post.ts b/src/hooks/post.ts index 84c9c1c..c872e7b 100644 --- a/src/hooks/post.ts +++ b/src/hooks/post.ts @@ -1,11 +1,6 @@ -import * as YAML from "yaml"; import { HookError } from "../errors/mod.ts"; -import { exists } from "../util/exists.ts"; -import { - HookWarning, - PostHookKind, - VersionConfig, -} from "./hooks.interfaces.ts"; +import { getVersionConfig } from "../util/config.ts"; +import { HookWarning, PostHookKind } from "./hooks.interfaces.ts"; import { IContext } from "../context.ts"; import { SemVer } from "semver"; @@ -73,20 +68,3 @@ export async function postVersionHook( } return warnings; } - -async function getVersionConfig(context: IContext) { - const { c: configShorthand, config, githubDir } = context; - const configPath = config ?? configShorthand; - const paths = configPath ? [configPath] : [ - [githubDir, "version.yml"].filter((p) => p).join("/"), - [githubDir, "version.yaml"].filter((p) => p).join("/"), - ]; - - for (const p of paths) { - if (await exists(p)) { - const contents = await Deno.readTextFile(p); - return YAML.parse(contents) as VersionConfig; - } - } - return undefined; -} diff --git a/src/util/config.test.ts b/src/util/config.test.ts new file mode 100644 index 0000000..cf50dbd --- /dev/null +++ b/src/util/config.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from "@std/assert"; +import { IContext } from "../context.ts"; +import { getVersionSource } from "./config.ts"; + +Deno.test("req:version-sources-001 - source defaults to file", async () => { + assertEquals(await getVersionSource({} as IContext), "file"); +}); + +Deno.test("req:version-sources-003 - configuration selects Git tags", async () => { + const config = await Deno.makeTempFile({ suffix: ".yml" }); + try { + await Deno.writeTextFile(config, "source: git\n"); + assertEquals(await getVersionSource({ config } as IContext), "git"); + } finally { + await Deno.remove(config); + } +}); diff --git a/src/util/config.ts b/src/util/config.ts new file mode 100644 index 0000000..97990a6 --- /dev/null +++ b/src/util/config.ts @@ -0,0 +1,45 @@ +import * as YAML from "yaml"; +import { IContext } from "../context.ts"; +import { VersionConfig } from "../hooks/hooks.interfaces.ts"; +import { exists } from "./exists.ts"; + +const configCache = new WeakMap>(); + +export function getVersionConfig( + context: IContext, +): Promise { + let result = configCache.get(context); + if (!result) { + result = readVersionConfig(context); + configCache.set(context, result); + } + return result; +} + +async function readVersionConfig( + context: IContext, +): Promise { + const configPath = context.config ?? context.c; + const paths = configPath ? [configPath] : [ + [context.githubDir, "version.yml"].filter(Boolean).join("/"), + [context.githubDir, "version.yaml"].filter(Boolean).join("/"), + ]; + for (const path of paths) { + if (await exists(path)) { + return YAML.parse(await Deno.readTextFile(path)) as VersionConfig; + } + } +} + +export async function getVersionSource( + context: IContext, +): Promise<"file" | "git"> { + if (!context.config && !context.c && !context.githubDir) return "file"; + const source = (await getVersionConfig(context))?.source ?? "file"; + if (source !== "file" && source !== "git") { + throw new TypeError( + `Invalid version source: ${source}. Expected file or git.`, + ); + } + return source; +} diff --git a/src/util/source.test.ts b/src/util/source.test.ts new file mode 100644 index 0000000..46df62c --- /dev/null +++ b/src/util/source.test.ts @@ -0,0 +1,163 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; + +const main = new URL("../../main.ts", import.meta.url).pathname; +const decoder = new TextDecoder(); + +async function run(cwd: string, command: string, args: string[]) { + const result = await new Deno.Command(command, { + args, + cwd, + stdout: "piped", + stderr: "piped", + }).output(); + const stdout = decoder.decode(result.stdout).trim(); + const stderr = decoder.decode(result.stderr).trim(); + assertEquals( + result.code, + 0, + `${command} ${args.join(" ")} failed: ${stderr}`, + ); + return stdout; +} + +Deno.test("req:version-sources-001 - version sources use file or Git", async () => { + const cwd = await Deno.makeTempDir(); + const cli = (...args: string[]) => + run(cwd, Deno.execPath(), ["run", "--cached-only", "-A", main, ...args]); + const git = (...args: string[]) => run(cwd, "git", args); + try { + await git("init", "-q"); + await git("config", "user.name", "Test"); + await git("config", "user.email", "test@example.com"); + await Deno.writeTextFile(`${cwd}/README`, "test\n"); + await git("add", "README"); + await git("commit", "-qm", "initial"); + + // req:version-sources-002 - a missing file starts at the fallback version. + assertEquals(await cli("get"), "0.1.0"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "0.1.0\n"); + + await Deno.mkdir(`${cwd}/.github`); + await Deno.writeTextFile(`${cwd}/.github/version.yml`, "source: git\n"); + // req:version-sources-003 - Git mode uses the fallback and removes VERSION. + assertEquals(await cli("get"), "0.1.0"); + assertEquals(await exists(`${cwd}/VERSION`), false); + await Deno.remove(`${cwd}/.github/version.yml`); + + // req:version-sources-006 - a higher tag on another branch is ignored. + const currentBranch = await git("branch", "--show-current"); + await git("switch", "-q", "-c", "unrelated"); + await Deno.writeTextFile(`${cwd}/README`, "unrelated\n"); + await git("add", "README"); + await git("commit", "-qm", "unrelated"); + await git("tag", "v9.9.9"); + await git("switch", "-q", currentBranch); + await git("tag", "v1.2.3"); + assertEquals(await cli("get"), "1.2.3"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "1.2.3\n"); + await Deno.writeTextFile(`${cwd}/VERSION`, "9.9.9\n"); + assertEquals(await cli("get"), "9.9.9"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "9.9.9\n"); + + await Deno.writeTextFile( + `${cwd}/.github/version.yml`, + "source: git\non:\n post:\n - kind: patch\n file: package.json\n", + ); + await Deno.writeTextFile(`${cwd}/package.json`, '{"version":"1.2.3"}\n'); + assertEquals(await cli("get"), "1.2.3"); + assertEquals(await exists(`${cwd}/VERSION`), false); + // req:version-sources-004 - inc matches git-latest without persistence. + const proposed = await cli("git-latest", "patch"); + assertEquals(proposed, "1.2.4"); + assertEquals(await cli("inc", "patch"), proposed); + assertEquals(await exists(`${cwd}/VERSION`), false); + assertEquals( + await Deno.readTextFile(`${cwd}/package.json`), + '{"version":"1.2.3"}\n', + ); + + // req:version-sources-005 - set applies a prerelease to the hook only. + assertStringIncludes(await cli("set", "1.2.4-rc.1"), "1.2.4-rc.1"); + assertEquals(await exists(`${cwd}/VERSION`), false); + assertStringIncludes( + await Deno.readTextFile(`${cwd}/package.json`), + "1.2.4-rc.1", + ); + assertEquals(await git("tag", "--list"), "v1.2.3\nv9.9.9"); + assertEquals(await cli("parse"), "1.2.3"); + } finally { + await Deno.remove(cwd, { recursive: true }); + } +}); + +async function exists(path: string) { + try { + await Deno.stat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} + +Deno.test("req:version-sources-002 - file source initializes without Git", async () => { + const cwd = await Deno.makeTempDir(); + try { + const stdout = await run(cwd, Deno.execPath(), [ + "run", + "--cached-only", + "-A", + main, + "get", + ]); + assertEquals(stdout, "0.1.0"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "0.1.0\n"); + } finally { + await Deno.remove(cwd, { recursive: true }); + } +}); + +Deno.test("req:version-sources-007 - file increment persists and runs hooks", async () => { + const cwd = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${cwd}/.github`); + await Deno.writeTextFile( + `${cwd}/.github/version.yml`, + "source: file\non:\n post:\n - kind: patch\n file: package.json\n", + ); + await Deno.writeTextFile(`${cwd}/VERSION`, "1.2.3\n"); + await Deno.writeTextFile(`${cwd}/package.json`, '{"version":"1.2.3"}\n'); + const stdout = await run(cwd, Deno.execPath(), [ + "run", + "--cached-only", + "-A", + main, + "inc", + "patch", + ]); + assertStringIncludes(stdout, "1.2.4"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "1.2.4\n"); + assertStringIncludes( + await Deno.readTextFile(`${cwd}/package.json`), + "1.2.4", + ); + + // req:version-sources-008 - set persists the requested prerelease and runs hooks. + const setOutput = await run(cwd, Deno.execPath(), [ + "run", + "--cached-only", + "-A", + main, + "set", + "2.0.0-rc.1", + ]); + assertStringIncludes(setOutput, "2.0.0-rc.1"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "2.0.0-rc.1\n"); + assertStringIncludes( + await Deno.readTextFile(`${cwd}/package.json`), + "2.0.0-rc.1", + ); + } finally { + await Deno.remove(cwd, { recursive: true }); + } +}); diff --git a/src/util/version.test.ts b/src/util/version.test.ts index 435d5a5..0f39efe 100644 --- a/src/util/version.test.ts +++ b/src/util/version.test.ts @@ -265,24 +265,18 @@ Deno.test({ exec: () => {}, }) as unknown as ConventionalGitClient, ); - const consoleError = stub(console, "error"); - const exit = stub(Deno, "exit"); try { - await getVersionFromGitTags(); + await assertRejects( + () => getVersionFromGitTags(), + Error, + "failed in a test", + ); assertSpyCall(clientFactory, 0, { args: [], }); - assertSpyCall(consoleError, 0, { - args: ["Failed to get the latest semver git tag."], - }); - assertSpyCall(exit, 0, { - args: [1], - }); } finally { clientFactory.restore(); - consoleError.restore(); - exit.restore(); } }, }); diff --git a/src/util/version.ts b/src/util/version.ts index b676176..3105705 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -4,6 +4,7 @@ import { IContext } from "../context.ts"; import { HookWarning } from "../hooks/hooks.interfaces.ts"; import { semverFormats } from "./variant.ts"; import { ConventionalGitClient } from "@conventional-changelog/git-client"; +import { getVersionSource } from "./config.ts"; export const DEFAULT_VERSION = parse("0.1.0"); @@ -145,8 +146,8 @@ export async function printComparison( /** * This reads the current version file found at $CWD/VERSION and parses it as a - * valid SemVer. If the file is not found, is empty, or contains an invalid - * version then the default version `0.0.0` is returned. + * valid SemVer. If the file is not found or is empty, the default version + * `0.1.0` is returned. Invalid versions raise a parse error. * @returns The parsed version or default version */ export async function readVersionFile(): Promise { @@ -185,25 +186,52 @@ export const gitClientFactory = { */ export async function getVersionFromGitTags() { const client = gitClientFactory.create(); + const version = await client.getVersionFromTags(); + return version ? parse(version) : DEFAULT_VERSION; +} +export async function readCurrentVersion(context: IContext): Promise { + let contents: string | undefined; try { - const isShallow = - (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; - await client.exec( - "fetch", - "--filter=tree:0", - "--tags", - ...(isShallow ? ["--unshallow"] : []), - "origin", - ); - const version = await client.getVersionFromTags(); - if (!version) { - throw new Error("Unable to resolve version from tags"); + contents = await Deno.readTextFile("VERSION"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + if (await getVersionSource(context) === "git") { + await removeVersionFile(); + return await getVersionFromGitTags(); + } + if (contents === undefined) { + let version: SemVer; + try { + version = await getVersionFromGitTags(); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes("not a git repository") + ) { + throw error; + } + version = DEFAULT_VERSION; } - return parse(version); - } catch (e) { - console.error("Failed to get the latest semver git tag."); - console.error(e); - Deno.exit(1); + await writeVersionFile(version); + return version; + } + return contents.trim() ? parse(contents.trim()) : DEFAULT_VERSION; +} + +export async function removeVersionFile() { + try { + await Deno.remove("VERSION"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } +} + +export async function writeCurrentVersion(context: IContext, version: SemVer) { + if (await getVersionSource(context) === "file") { + await writeVersionFile(version); + } else { + await removeVersionFile(); } } From 25526ff64a34db78175c9ca4338220efe4ba98dc Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 20:04:25 -0500 Subject: [PATCH 2/8] fix: run post hooks for git source increments Keep Git tags as the version source while applying proposed versions to configured hook targets. Update the spec and exercise all increment variants. --- .../004-git-increment.requirement.md | 10 ++++--- .../specifications/version-sources.spec.md | 6 ++-- README.md | 4 +-- src/commands/inc/major.ts | 5 +--- src/commands/inc/minor.ts | 5 +--- src/commands/inc/none.ts | 5 +--- src/commands/inc/patch.ts | 5 +--- src/util/source.test.ts | 28 ++++++++++++++++--- 8 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.agents/requirements/version-sources/004-git-increment.requirement.md b/.agents/requirements/version-sources/004-git-increment.requirement.md index b5d593c..dad545f 100644 --- a/.agents/requirements/version-sources/004-git-increment.requirement.md +++ b/.agents/requirements/version-sources/004-git-increment.requirement.md @@ -1,10 +1,10 @@ --- id: version-sources-004 -title: Git increment reports a proposal without changing state +title: Git increment updates hooks without writing the source of truth spec_ref: ".agents/specifications/version-sources.spec.md#git-source" --- -# Git increment reports a proposal without changing state +# Git increment updates hooks without writing the source of truth ## Requirement @@ -15,9 +15,11 @@ Preconditions: Expected behavior: - `inc patch` MUST report the same proposed version as `git-latest patch`. -- It MUST NOT create `VERSION` or a tag, and MUST NOT run the post hook. +- Every `inc` variant MUST apply configured post hooks with its proposed + version. +- It MUST NOT create `VERSION` or a tag. Postconditions: -- Local tags and the hook target retain their original content. +- Local tags are unchanged, and the hook target reflects the proposed version. - Tests: `src/util/source.test.ts` (`req:version-sources-004`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index f506a28..8dc1b05 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -29,9 +29,9 @@ that resolve the current version remove an existing `VERSION` file. `get` and `parse` without an explicit value derive their result from that current version. `inc` calculates and reports a proposed version using the same -version calculation as `git-latest`; it does not write a file, create a tag, or -apply post hooks. `set` applies the specified version to configured post hooks -without creating a `VERSION` file or a tag. +version calculation as `git-latest` and applies configured post hooks. It does +not write a `VERSION` file or create a tag. `set` applies the specified version +to configured post hooks without creating a `VERSION` file or a tag. ## Tag lookup diff --git a/README.md b/README.md index 339f6fa..13f2ae6 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ file is missing, semver initializes it from the latest local git tag reachable from the current ref, or `0.1.0` if there is no tag. With `source: git`, local git tags are authoritative and any `VERSION` file is removed. `get`, `parse` (without a value), and `inc` use the tag version, falling back to `0.1.0`. `inc` -only prints the proposed version in this mode; `set` applies its specified -version to the configured post hooks without creating a file or tag. +applies configured post hooks with its proposed version, and `set` applies its +specified version to those hooks. Neither command creates a file or tag. ```yml source: git diff --git a/src/commands/inc/major.ts b/src/commands/inc/major.ts index 68bf929..eb5c2d9 100644 --- a/src/commands/inc/major.ts +++ b/src/commands/inc/major.ts @@ -6,7 +6,6 @@ import { writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; -import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const major = { @@ -22,9 +21,7 @@ export const major = { build, }); await writeCurrentVersion(args, current); - const hookWarnings = await getVersionSource(args) === "file" - ? await postVersionHook(args, previous, current) - : []; + const hookWarnings = await postVersionHook(args, previous, current); await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/minor.ts b/src/commands/inc/minor.ts index c928edf..c5833f7 100644 --- a/src/commands/inc/minor.ts +++ b/src/commands/inc/minor.ts @@ -6,7 +6,6 @@ import { writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; -import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const minor = { @@ -22,9 +21,7 @@ export const minor = { build, }); await writeCurrentVersion(args, current); - const hookWarnings = await getVersionSource(args) === "file" - ? await postVersionHook(args, previous, current) - : []; + const hookWarnings = await postVersionHook(args, previous, current); await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/none.ts b/src/commands/inc/none.ts index 9ddef36..7a9b0a8 100644 --- a/src/commands/inc/none.ts +++ b/src/commands/inc/none.ts @@ -6,7 +6,6 @@ import { writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; -import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const none = { @@ -22,9 +21,7 @@ export const none = { build, }); await writeCurrentVersion(args, current); - const hookWarnings = await getVersionSource(args) === "file" - ? await postVersionHook(args, previous, current) - : []; + const hookWarnings = await postVersionHook(args, previous, current); await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/commands/inc/patch.ts b/src/commands/inc/patch.ts index 227d185..4c82582 100644 --- a/src/commands/inc/patch.ts +++ b/src/commands/inc/patch.ts @@ -6,7 +6,6 @@ import { writeCurrentVersion, } from "../../util/version.ts"; import { postVersionHook } from "../../hooks/mod.ts"; -import { getVersionSource } from "../../util/config.ts"; import { IContext } from "../../context.ts"; export const patch = { @@ -22,9 +21,7 @@ export const patch = { build, }); await writeCurrentVersion(args, current); - const hookWarnings = await getVersionSource(args) === "file" - ? await postVersionHook(args, previous, current) - : []; + const hookWarnings = await postVersionHook(args, previous, current); await printVersion(args, current, args.json, hookWarnings); }, }; diff --git a/src/util/source.test.ts b/src/util/source.test.ts index 46df62c..bc14dc5 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -66,14 +66,16 @@ Deno.test("req:version-sources-001 - version sources use file or Git", async () await Deno.writeTextFile(`${cwd}/package.json`, '{"version":"1.2.3"}\n'); assertEquals(await cli("get"), "1.2.3"); assertEquals(await exists(`${cwd}/VERSION`), false); - // req:version-sources-004 - inc matches git-latest without persistence. + // req:version-sources-004 - inc matches git-latest and runs hooks without persistence. const proposed = await cli("git-latest", "patch"); assertEquals(proposed, "1.2.4"); - assertEquals(await cli("inc", "patch"), proposed); + const incOutput = await cli("inc", "patch"); + assertStringIncludes(incOutput, "Invoking post_version hook..."); + assertEquals(incOutput.split("\n").at(-1), proposed); assertEquals(await exists(`${cwd}/VERSION`), false); - assertEquals( + assertStringIncludes( await Deno.readTextFile(`${cwd}/package.json`), - '{"version":"1.2.3"}\n', + "1.2.4", ); // req:version-sources-005 - set applies a prerelease to the hook only. @@ -83,6 +85,24 @@ Deno.test("req:version-sources-001 - version sources use file or Git", async () await Deno.readTextFile(`${cwd}/package.json`), "1.2.4-rc.1", ); + + for ( + const [kind, expected] of [ + ["major", "2.0.0"], + ["minor", "1.3.0"], + ["none", "1.2.3"], + ] + ) { + await Deno.writeTextFile(`${cwd}/package.json`, '{"version":"1.2.3"}\n'); + const output = await cli("inc", kind); + assertStringIncludes(output, "Invoking post_version hook..."); + assertEquals(output.split("\n").at(-1), expected); + assertStringIncludes( + await Deno.readTextFile(`${cwd}/package.json`), + expected, + ); + assertEquals(await exists(`${cwd}/VERSION`), false); + } assertEquals(await git("tag", "--list"), "v1.2.3\nv9.9.9"); assertEquals(await cli("parse"), "1.2.3"); } finally { From 31adbbe722ca64afdb6466341657bfcdd199c91e Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 20:58:15 -0500 Subject: [PATCH 3/8] fix: restore tag fetch for shallow repositories --- .../009-shallow-origin-tags.requirement.md | 26 +++++++++ .../specifications/version-sources.spec.md | 6 +- README.md | 5 +- src/util/source.test.ts | 57 +++++++++++++++++++ src/util/version.test.ts | 4 +- src/util/version.ts | 12 ++++ 6 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 .agents/requirements/version-sources/009-shallow-origin-tags.requirement.md diff --git a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md new file mode 100644 index 0000000..1a7ea5d --- /dev/null +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -0,0 +1,26 @@ +--- +id: version-sources-009 +title: Shallow clones fetch reachable tags from origin +spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" +--- + +# Shallow clones fetch reachable tags from origin + +## Requirement + +Preconditions: + +- A shallow checkout has an `origin` remote and a parseable tag on an earlier + commit reachable through that remote. +- The tag is absent from the shallow checkout before version resolution. + +Expected behavior: + +- Tag based resolution MUST fetch tags from `origin` and complete the shallow + history before selecting the current version. +- It MUST return the reachable tag's version instead of the fallback. + +Postconditions: + +- The tag is available locally and the repository is no longer shallow. +- Tests: `src/util/source.test.ts` (`req:version-sources-009`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index 8dc1b05..ef2370b 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -35,5 +35,7 @@ to configured post hooks without creating a `VERSION` file or a tag. ## Tag lookup -Tag based version resolution uses local tags for the current ref and does not -fetch from a remote. If no parseable tag is reachable, it uses `0.1.0`. +When an `origin` remote exists, tag based version resolution fetches its tags +and completes shallow history before examining the local tags reachable from the +current ref. A repository without `origin` uses its existing local tags. If no +parseable tag is reachable, resolution uses `0.1.0`. diff --git a/README.md b/README.md index 13f2ae6..eeedded 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ for ascending order, or read versions from stdin using `--`. The `git-latest` command returns the highest parseable local git tag reachable from the current ref, or `0.1.0` when there are no tags. Git errors still -exit 1. This **does not** create or modify tags. Supports the same subcommands -and options as `get`/`set`/`inc`, i.e.: +exit 1. When an `origin` remote exists, it fetches tags and completes shallow +history before looking up the version. This **does not** create or modify tags. +It supports the same subcommands and options as `get`/`set`/`inc`, i.e.: ```sh semver git-latest # 1.2.3 diff --git a/src/util/source.test.ts b/src/util/source.test.ts index bc14dc5..eba79c8 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -120,6 +120,63 @@ async function exists(path: string) { } } +Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async () => { + const root = await Deno.makeTempDir(); + const source = `${root}/source`; + const remote = `${root}/origin.git`; + const shallow = `${root}/shallow`; + try { + await Deno.mkdir(source); + const git = (...args: string[]) => run(source, "git", args); + await git("init", "-q"); + await git("config", "user.name", "Test"); + await git("config", "user.email", "test@example.com"); + await Deno.writeTextFile(`${source}/README`, "first\n"); + await git("add", "README"); + await git("commit", "-qm", "tagged"); + await git("tag", "v1.2.3"); + await Deno.writeTextFile(`${source}/README`, "second\n"); + await git("add", "README"); + await git("commit", "-qm", "untagged head"); + + await run(root, "git", ["clone", "--bare", source, remote]); + await run(root, "git", [ + "clone", + "--depth", + "1", + "--no-tags", + `file://${remote}`, + shallow, + ]); + const cloneGit = (...args: string[]) => run(shallow, "git", args); + assertEquals( + await cloneGit("rev-parse", "--is-shallow-repository"), + "true", + ); + assertEquals(await cloneGit("tag", "--list"), ""); + await Deno.mkdir(`${shallow}/.github`); + await Deno.writeTextFile(`${shallow}/.github/version.yml`, "source: git\n"); + + assertEquals( + await run(shallow, Deno.execPath(), [ + "run", + "--cached-only", + "-A", + main, + "get", + ]), + "1.2.3", + ); + assertEquals( + await cloneGit("rev-parse", "--is-shallow-repository"), + "false", + ); + assertEquals(await cloneGit("tag", "--list"), "v1.2.3"); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + Deno.test("req:version-sources-002 - file source initializes without Git", async () => { const cwd = await Deno.makeTempDir(); try { diff --git a/src/util/version.test.ts b/src/util/version.test.ts index 0f39efe..f3b08a6 100644 --- a/src/util/version.test.ts +++ b/src/util/version.test.ts @@ -235,7 +235,7 @@ Deno.test({ () => ({ getVersionFromTags: () => "0.1.0", - exec: () => {}, + exec: () => "", }) as unknown as ConventionalGitClient, ); @@ -262,7 +262,7 @@ Deno.test({ getVersionFromTags: () => { throw new Error("failed in a test"); }, - exec: () => {}, + exec: () => "", }) as unknown as ConventionalGitClient, ); try { diff --git a/src/util/version.ts b/src/util/version.ts index 3105705..5330a60 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -186,6 +186,18 @@ export const gitClientFactory = { */ export async function getVersionFromGitTags() { const client = gitClientFactory.create(); + const remotes = await client.exec("remote"); + if (remotes.split(/\s+/).includes("origin")) { + const isShallow = + (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; + await client.exec( + "fetch", + "--filter=tree:0", + "--tags", + ...(isShallow ? ["--unshallow"] : []), + "origin", + ); + } const version = await client.getVersionFromTags(); return version ? parse(version) : DEFAULT_VERSION; } From 78342bc0b1d5dd5fd6380cdb6e1713349ba427ae Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 21:02:37 -0500 Subject: [PATCH 4/8] fix: limit shallow tag fetch to current history --- .../009-shallow-origin-tags.requirement.md | 16 ++++--- .../specifications/version-sources.spec.md | 11 +++-- README.md | 7 ++-- src/util/source.test.ts | 29 ++++++++++++- src/util/version.ts | 42 ++++++++++++++++--- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md index 1a7ea5d..057ef15 100644 --- a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -1,10 +1,10 @@ --- id: version-sources-009 -title: Shallow clones fetch reachable tags from origin +title: Shallow clones fetch current history and reachable tags only spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" --- -# Shallow clones fetch reachable tags from origin +# Shallow clones fetch current history and reachable tags only ## Requirement @@ -13,14 +13,20 @@ Preconditions: - A shallow checkout has an `origin` remote and a parseable tag on an earlier commit reachable through that remote. - The tag is absent from the shallow checkout before version resolution. +- Another branch has an unrelated, higher version tag. Expected behavior: -- Tag based resolution MUST fetch tags from `origin` and complete the shallow - history before selecting the current version. +- Tag based resolution MUST fetch the current checkout's missing commit history + from `origin` with a tree and blob filter. +- It MUST fetch tags on that history without fetching unrelated branch histories + or their tags. - It MUST return the reachable tag's version instead of the fallback. Postconditions: -- The tag is available locally and the repository is no longer shallow. +- The reachable tag is available locally and the repository is no longer + shallow. +- The unrelated tag and commit, and historical tree and blob objects, are absent + locally. - Tests: `src/util/source.test.ts` (`req:version-sources-009`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index ef2370b..9094209 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -35,7 +35,10 @@ to configured post hooks without creating a `VERSION` file or a tag. ## Tag lookup -When an `origin` remote exists, tag based version resolution fetches its tags -and completes shallow history before examining the local tags reachable from the -current ref. A repository without `origin` uses its existing local tags. If no -parseable tag is reachable, resolution uses `0.1.0`. +Tag based version resolution uses tags reachable from the current ref. In a +shallow repository with an `origin` remote, it obtains the missing commit +history for the current checkout without downloading historical file trees or +blobs. It obtains only tags whose targets are in that history; unrelated branch +histories and their tags are excluded. A complete repository, or one without +`origin`, uses its existing local tags. If no parseable tag is reachable, +resolution uses `0.1.0`. diff --git a/README.md b/README.md index eeedded..fa2fa1f 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ for ascending order, or read versions from stdin using `--`. The `git-latest` command returns the highest parseable local git tag reachable from the current ref, or `0.1.0` when there are no tags. Git errors still -exit 1. When an `origin` remote exists, it fetches tags and completes shallow -history before looking up the version. This **does not** create or modify tags. -It supports the same subcommands and options as `get`/`set`/`inc`, i.e.: +exit 1. In a shallow checkout with `origin`, it fetches the current checkout's +commit history and reachable tags without historical file trees or unrelated +branch histories. This **does not** create release tags. It supports the same +subcommands and options as `get`/`set`/`inc`, i.e.: ```sh semver git-latest # 1.2.3 diff --git a/src/util/source.test.ts b/src/util/source.test.ts index eba79c8..3ccc0bb 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -134,12 +134,29 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async await Deno.writeTextFile(`${source}/README`, "first\n"); await git("add", "README"); await git("commit", "-qm", "tagged"); - await git("tag", "v1.2.3"); + await git("tag", "-a", "v1.2.3", "-m", "release 1.2.3"); + const oldTree = await git("rev-parse", "v1.2.3^{tree}"); + const oldBlob = await git("rev-parse", "v1.2.3:README"); await Deno.writeTextFile(`${source}/README`, "second\n"); await git("add", "README"); await git("commit", "-qm", "untagged head"); + const currentBranch = await git("branch", "--show-current"); + await git("switch", "-q", "-c", "unrelated"); + await Deno.writeTextFile(`${source}/README`, "unrelated\n"); + await git("add", "README"); + await git("commit", "-qm", "unrelated branch"); + const unrelatedCommit = await git("rev-parse", "HEAD"); + await git("tag", "v9.9.9"); + await git("switch", "-q", currentBranch); await run(root, "git", ["clone", "--bare", source, remote]); + await run(root, "git", [ + "--git-dir", + remote, + "config", + "uploadpack.allowFilter", + "true", + ]); await run(root, "git", [ "clone", "--depth", @@ -172,6 +189,16 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async "false", ); assertEquals(await cloneGit("tag", "--list"), "v1.2.3"); + for (const object of [oldTree, oldBlob, unrelatedCommit]) { + const check = await new Deno.Command("git", { + args: ["cat-file", "-e", object], + cwd: shallow, + env: { GIT_NO_LAZY_FETCH: "1" }, + stdout: "null", + stderr: "null", + }).output(); + assertEquals(check.code, 1, `${object} should not be fetched`); + } } finally { await Deno.remove(root, { recursive: true }); } diff --git a/src/util/version.ts b/src/util/version.ts index 5330a60..af808aa 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -186,17 +186,47 @@ export const gitClientFactory = { */ export async function getVersionFromGitTags() { const client = gitClientFactory.create(); - const remotes = await client.exec("remote"); - if (remotes.split(/\s+/).includes("origin")) { - const isShallow = - (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; + const isShallow = + (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; + if ( + isShallow && (await client.exec("remote")).split(/\s+/).includes("origin") + ) { + const head = await client.exec("rev-parse", "HEAD"); await client.exec( "fetch", "--filter=tree:0", - "--tags", - ...(isShallow ? ["--unshallow"] : []), + "--no-tags", + "--unshallow", "origin", + head, ); + const commits = new Set( + (await client.exec("rev-list", "HEAD")).split("\n"), + ); + const remoteTags = await client.exec("ls-remote", "--tags", "origin"); + const tags = new Map(); + for (const line of remoteTags.split("\n")) { + const [object, ref] = line.split("\t"); + if (!object || !ref?.startsWith("refs/tags/")) continue; + const peeled = ref.endsWith("^{}"); + const name = peeled ? ref.slice(0, -3) : ref; + const tag = tags.get(name) ?? {}; + if (peeled) tag.commit = object; + else tag.object = object; + tags.set(name, tag); + } + const reachableTags = [...tags] + .filter(([, tag]) => commits.has(tag.commit ?? tag.object ?? "")) + .map(([ref]) => `${ref}:${ref}`); + if (reachableTags.length > 0) { + await client.exec( + "fetch", + "--filter=tree:0", + "--no-tags", + "origin", + ...reachableTags, + ); + } } const version = await client.getVersionFromTags(); return version ? parse(version) : DEFAULT_VERSION; From 4e77824be94bb5992c7473f9cc1ed772f888f578 Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 21:19:14 -0500 Subject: [PATCH 5/8] fix: fetch only selected reachable version tag --- .../009-shallow-origin-tags.requirement.md | 9 +++++---- .agents/specifications/version-sources.spec.md | 8 ++++---- README.md | 6 +++--- src/util/source.test.ts | 1 + src/util/version.ts | 17 +++++++++++------ 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md index 057ef15..23cabac 100644 --- a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -13,20 +13,21 @@ Preconditions: - A shallow checkout has an `origin` remote and a parseable tag on an earlier commit reachable through that remote. - The tag is absent from the shallow checkout before version resolution. +- A lower version tag is closer to the current ref than the highest version tag. - Another branch has an unrelated, higher version tag. Expected behavior: - Tag based resolution MUST fetch the current checkout's missing commit history from `origin` with a tree and blob filter. -- It MUST fetch tags on that history without fetching unrelated branch histories - or their tags. +- It MUST fetch only the highest parseable tag on that history without fetching + other tags or unrelated branch histories. - It MUST return the reachable tag's version instead of the fallback. Postconditions: - The reachable tag is available locally and the repository is no longer shallow. -- The unrelated tag and commit, and historical tree and blob objects, are absent - locally. +- The lower and unrelated tags, the unrelated commit, and historical tree and + blob objects are absent locally. - Tests: `src/util/source.test.ts` (`req:version-sources-009`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index 9094209..00158ed 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -38,7 +38,7 @@ to configured post hooks without creating a `VERSION` file or a tag. Tag based version resolution uses tags reachable from the current ref. In a shallow repository with an `origin` remote, it obtains the missing commit history for the current checkout without downloading historical file trees or -blobs. It obtains only tags whose targets are in that history; unrelated branch -histories and their tags are excluded. A complete repository, or one without -`origin`, uses its existing local tags. If no parseable tag is reachable, -resolution uses `0.1.0`. +blobs. It fetches only the highest parseable tag whose target is in that +history; unrelated branch histories and their tags are excluded. A complete +repository, or one without `origin`, uses its existing local tags. If no +parseable tag is reachable, resolution uses `0.1.0`. diff --git a/README.md b/README.md index fa2fa1f..0480259 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ for ascending order, or read versions from stdin using `--`. The `git-latest` command returns the highest parseable local git tag reachable from the current ref, or `0.1.0` when there are no tags. Git errors still exit 1. In a shallow checkout with `origin`, it fetches the current checkout's -commit history and reachable tags without historical file trees or unrelated -branch histories. This **does not** create release tags. It supports the same -subcommands and options as `get`/`set`/`inc`, i.e.: +commit history and the highest reachable version tag without historical file +trees or unrelated branch histories. This **does not** create release tags. It +supports the same subcommands and options as `get`/`set`/`inc`, i.e.: ```sh semver git-latest # 1.2.3 diff --git a/src/util/source.test.ts b/src/util/source.test.ts index 3ccc0bb..67fc83d 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -140,6 +140,7 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async await Deno.writeTextFile(`${source}/README`, "second\n"); await git("add", "README"); await git("commit", "-qm", "untagged head"); + await git("tag", "v1.1.0"); const currentBranch = await git("branch", "--show-current"); await git("switch", "-q", "-c", "unrelated"); await Deno.writeTextFile(`${source}/README`, "unrelated\n"); diff --git a/src/util/version.ts b/src/util/version.ts index af808aa..37253ae 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { format, parse, SemVer } from "semver"; +import { compare, format, parse, SemVer, tryParse } from "semver"; import { IContext } from "../context.ts"; import { HookWarning } from "../hooks/hooks.interfaces.ts"; import { semverFormats } from "./variant.ts"; @@ -215,16 +215,21 @@ export async function getVersionFromGitTags() { else tag.object = object; tags.set(name, tag); } - const reachableTags = [...tags] - .filter(([, tag]) => commits.has(tag.commit ?? tag.object ?? "")) - .map(([ref]) => `${ref}:${ref}`); - if (reachableTags.length > 0) { + let bestTag: { ref: string; version: SemVer } | undefined; + for (const [ref, tag] of tags) { + if (!commits.has(tag.commit ?? tag.object ?? "")) continue; + const version = tryParse(ref.slice("refs/tags/".length)); + if (version && (!bestTag || compare(version, bestTag.version) > 0)) { + bestTag = { ref, version }; + } + } + if (bestTag) { await client.exec( "fetch", "--filter=tree:0", "--no-tags", "origin", - ...reachableTags, + `${bestTag.ref}:${bestTag.ref}`, ); } } From 35bf1857f529eef547cf62a303ddebb918aaa3a6 Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 21:25:40 -0500 Subject: [PATCH 6/8] feat: resolve nearest version tag with incremental shallow fetch --- .../002-file-initialization.requirement.md | 2 +- .../003-git-current-version.requirement.md | 4 +- .../009-shallow-origin-tags.requirement.md | 31 ++--- .../specifications/version-sources.spec.md | 22 ++-- README.md | 11 +- src/util/source.test.ts | 109 +++++++++++++++++- src/util/version.test.ts | 3 +- src/util/version.ts | 106 ++++++++++++----- 8 files changed, 221 insertions(+), 67 deletions(-) diff --git a/.agents/requirements/version-sources/002-file-initialization.requirement.md b/.agents/requirements/version-sources/002-file-initialization.requirement.md index 8e9204a..1f09503 100644 --- a/.agents/requirements/version-sources/002-file-initialization.requirement.md +++ b/.agents/requirements/version-sources/002-file-initialization.requirement.md @@ -14,7 +14,7 @@ Preconditions: Expected behavior: -- The CLI MUST create `VERSION` containing the highest parseable local tag +- The CLI MUST create `VERSION` containing the nearest parseable local tag reachable from the current ref, or `0.1.0` when no such tag exists. - Initialization MUST work outside a Git repository by using `0.1.0`. diff --git a/.agents/requirements/version-sources/003-git-current-version.requirement.md b/.agents/requirements/version-sources/003-git-current-version.requirement.md index e46fe43..872fc5d 100644 --- a/.agents/requirements/version-sources/003-git-current-version.requirement.md +++ b/.agents/requirements/version-sources/003-git-current-version.requirement.md @@ -14,8 +14,10 @@ Preconditions: Expected behavior: -- `get` and `parse` without a value MUST report the version from the highest +- `get` and `parse` without a value MUST report the version from the nearest parseable local tag reachable from the current ref, or `0.1.0` if none exists. +- If several version tags point to that commit, the highest version among them + MUST be selected. - Resolving the current version MUST remove an existing `VERSION` file. Postconditions: diff --git a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md index 23cabac..06162c7 100644 --- a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -1,33 +1,34 @@ --- id: version-sources-009 -title: Shallow clones fetch current history and reachable tags only +title: Shallow clones stop deepening at the nearest version tag spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" --- -# Shallow clones fetch current history and reachable tags only +# Shallow clones stop deepening at the nearest version tag ## Requirement Preconditions: -- A shallow checkout has an `origin` remote and a parseable tag on an earlier - commit reachable through that remote. -- The tag is absent from the shallow checkout before version resolution. -- A lower version tag is closer to the current ref than the highest version tag. +- A shallow checkout has an `origin` remote and a parseable tag beyond its + shallow boundary. +- An older reachable tag has a higher version number. - Another branch has an unrelated, higher version tag. Expected behavior: -- Tag based resolution MUST fetch the current checkout's missing commit history - from `origin` with a tree and blob filter. -- It MUST fetch only the highest parseable tag on that history without fetching - other tags or unrelated branch histories. -- It MUST return the reachable tag's version instead of the fallback. +- Tag based resolution MUST deepen only the current checkout's commit history, + with a tree and blob filter, until the nearest parseable tag is found. +- It MUST stop deepening once that tag is found and fetch only that tag object. +- It MUST report the nearest tag's version even when an older reachable tag has + a higher version number. +- If the remote has no parseable version tags, it MUST use `0.1.0` without + deepening the checkout. Postconditions: -- The reachable tag is available locally and the repository is no longer - shallow. -- The lower and unrelated tags, the unrelated commit, and historical tree and - blob objects are absent locally. +- The selected tag is available locally and the repository remains shallow when + older history exists beyond it. +- The older and unrelated tags, their commits, and historical tree and blob + objects are absent locally. - Tests: `src/util/source.test.ts` (`req:version-sources-009`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index 00158ed..a9f23eb 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -12,7 +12,7 @@ is `file`. ## File source An existing `VERSION` file in the working directory is authoritative. When it is -absent, the CLI initializes it from the highest parseable local Git tag +absent, the CLI initializes it from the nearest parseable local Git tag reachable from the current ref. If no such tag exists, it initializes the file to `0.1.0`. @@ -23,7 +23,7 @@ configured post hooks. ## Git source -The highest parseable local Git tag reachable from the current ref is +The nearest parseable local Git tag reachable from the current ref is authoritative. If no such tag exists, the current version is `0.1.0`. Commands that resolve the current version remove an existing `VERSION` file. @@ -35,10 +35,14 @@ to configured post hooks without creating a `VERSION` file or a tag. ## Tag lookup -Tag based version resolution uses tags reachable from the current ref. In a -shallow repository with an `origin` remote, it obtains the missing commit -history for the current checkout without downloading historical file trees or -blobs. It fetches only the highest parseable tag whose target is in that -history; unrelated branch histories and their tags are excluded. A complete -repository, or one without `origin`, uses its existing local tags. If no -parseable tag is reachable, resolution uses `0.1.0`. +The nearest tag is the first parseable version tag encountered while walking +commits from the current ref in Git date order. When several version tags name +that commit, the highest version among them wins. Tags on unrelated branches are +excluded. + +In a shallow repository with an `origin` remote, resolution discovers tag refs +without fetching their objects, then deepens only the current checkout's commit +history until it finds that nearest tag. It stops at that point and fetches only +the selected tag. Historical file trees and blobs are excluded from these +fetches. A complete repository, or one without `origin`, uses its existing local +tags. If no parseable tag is reachable, resolution uses `0.1.0`. diff --git a/README.md b/README.md index 0480259..bc85f44 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,13 @@ The `sort` command accepts one or more version strings and outputs them in sorted order (descending by default, one version per line). Use the `-a` flag for ascending order, or read versions from stdin using `--`. -The `git-latest` command returns the highest parseable local git tag reachable +The `git-latest` command returns the nearest parseable local git tag reachable from the current ref, or `0.1.0` when there are no tags. Git errors still -exit 1. In a shallow checkout with `origin`, it fetches the current checkout's -commit history and the highest reachable version tag without historical file -trees or unrelated branch histories. This **does not** create release tags. It -supports the same subcommands and options as `get`/`set`/`inc`, i.e.: +exit 1. In a shallow checkout with `origin`, it deepens the current checkout's +commit history only until it finds a version tag, then fetches just that tag +without historical file trees or unrelated branch histories. This **does not** +create release tags. It supports the same subcommands and options as +`get`/`set`/`inc`, i.e.: ```sh semver git-latest # 1.2.3 diff --git a/src/util/source.test.ts b/src/util/source.test.ts index 67fc83d..fabf936 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -120,7 +120,7 @@ async function exists(path: string) { } } -Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async () => { +Deno.test("req:version-sources-009 - stops at the nearest tag in a shallow clone", async () => { const root = await Deno.makeTempDir(); const source = `${root}/source`; const remote = `${root}/origin.git`; @@ -135,12 +135,21 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async await git("add", "README"); await git("commit", "-qm", "tagged"); await git("tag", "-a", "v1.2.3", "-m", "release 1.2.3"); + const olderCommit = await git("rev-parse", "HEAD"); const oldTree = await git("rev-parse", "v1.2.3^{tree}"); const oldBlob = await git("rev-parse", "v1.2.3:README"); await Deno.writeTextFile(`${source}/README`, "second\n"); await git("add", "README"); await git("commit", "-qm", "untagged head"); await git("tag", "v1.1.0"); + await Deno.writeTextFile(`${source}/README`, "third\n"); + await git("add", "README"); + await git("commit", "-qm", "head after nearest tag"); + for (const content of ["fourth", "fifth"]) { + await Deno.writeTextFile(`${source}/README`, `${content}\n`); + await git("add", "README"); + await git("commit", "-qm", content); + } const currentBranch = await git("branch", "--show-current"); await git("switch", "-q", "-c", "unrelated"); await Deno.writeTextFile(`${source}/README`, "unrelated\n"); @@ -167,6 +176,7 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async shallow, ]); const cloneGit = (...args: string[]) => run(shallow, "git", args); + await cloneGit("checkout", "--detach", "-q"); assertEquals( await cloneGit("rev-parse", "--is-shallow-repository"), "true", @@ -183,14 +193,14 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async main, "get", ]), - "1.2.3", + "1.1.0", ); assertEquals( await cloneGit("rev-parse", "--is-shallow-repository"), - "false", + "true", ); - assertEquals(await cloneGit("tag", "--list"), "v1.2.3"); - for (const object of [oldTree, oldBlob, unrelatedCommit]) { + assertEquals(await cloneGit("tag", "--list"), "v1.1.0"); + for (const object of [olderCommit, oldTree, oldBlob, unrelatedCommit]) { const check = await new Deno.Command("git", { args: ["cat-file", "-e", object], cwd: shallow, @@ -205,6 +215,95 @@ Deno.test("req:version-sources-009 - fetches tags and unshallows origin", async } }); +Deno.test("req:version-sources-002 req:version-sources-003 - nearest tag wins in a complete repository", async () => { + const cwd = await Deno.makeTempDir(); + const git = (...args: string[]) => run(cwd, "git", args); + const cli = (...args: string[]) => + run(cwd, Deno.execPath(), ["run", "--cached-only", "-A", main, ...args]); + try { + await git("init", "-q"); + await git("config", "user.name", "Test"); + await git("config", "user.email", "test@example.com"); + await Deno.writeTextFile(`${cwd}/README`, "old\n"); + await git("add", "README"); + await git("commit", "-qm", "old release"); + await git("tag", "v2.0.0"); + await Deno.writeTextFile(`${cwd}/README`, "new\n"); + await git("add", "README"); + await git("commit", "-qm", "new release"); + await git("tag", "v1.0.0"); + await git("tag", "v0.9.0"); + await Deno.writeTextFile(`${cwd}/README`, "head\n"); + await git("add", "README"); + await git("commit", "-qm", "head"); + + assertEquals(await cli("get"), "1.0.0"); + assertEquals(await Deno.readTextFile(`${cwd}/VERSION`), "1.0.0\n"); + await Deno.mkdir(`${cwd}/.github`); + await Deno.writeTextFile(`${cwd}/.github/version.yml`, "source: git\n"); + assertEquals(await cli("get"), "1.0.0"); + assertEquals(await exists(`${cwd}/VERSION`), false); + } finally { + await Deno.remove(cwd, { recursive: true }); + } +}); + +Deno.test("req:version-sources-009 - no tags leaves a shallow checkout shallow", async () => { + const root = await Deno.makeTempDir(); + const source = `${root}/source`; + const remote = `${root}/origin.git`; + const shallow = `${root}/shallow`; + try { + await Deno.mkdir(source); + const git = (...args: string[]) => run(source, "git", args); + await git("init", "-q"); + await git("config", "user.name", "Test"); + await git("config", "user.email", "test@example.com"); + await Deno.writeTextFile(`${source}/README`, "first\n"); + await git("add", "README"); + await git("commit", "-qm", "first"); + const olderCommit = await git("rev-parse", "HEAD"); + await Deno.writeTextFile(`${source}/README`, "second\n"); + await git("add", "README"); + await git("commit", "-qm", "second"); + await run(root, "git", ["clone", "--bare", source, remote]); + await run(root, "git", [ + "clone", + "--depth", + "1", + "--no-tags", + `file://${remote}`, + shallow, + ]); + await Deno.mkdir(`${shallow}/.github`); + await Deno.writeTextFile(`${shallow}/.github/version.yml`, "source: git\n"); + + assertEquals( + await run(shallow, Deno.execPath(), [ + "run", + "--cached-only", + "-A", + main, + "get", + ]), + "0.1.0", + ); + assertEquals( + await run(shallow, "git", ["rev-parse", "--is-shallow-repository"]), + "true", + ); + const oldCommitCheck = await new Deno.Command("git", { + args: ["cat-file", "-e", olderCommit], + cwd: shallow, + stdout: "null", + stderr: "null", + }).output(); + assertEquals(oldCommitCheck.code, 1); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + Deno.test("req:version-sources-002 - file source initializes without Git", async () => { const cwd = await Deno.makeTempDir(); try { diff --git a/src/util/version.test.ts b/src/util/version.test.ts index f3b08a6..2d9e5d2 100644 --- a/src/util/version.test.ts +++ b/src/util/version.test.ts @@ -259,10 +259,9 @@ Deno.test({ "create", () => ({ - getVersionFromTags: () => { + exec: () => { throw new Error("failed in a test"); }, - exec: () => "", }) as unknown as ConventionalGitClient, ); try { diff --git a/src/util/version.ts b/src/util/version.ts index 37253ae..4808577 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -186,55 +186,103 @@ export const gitClientFactory = { */ export async function getVersionFromGitTags() { const client = gitClientFactory.create(); - const isShallow = + const tags = new Map< + string, + { ref: string; version: SemVer; remote: boolean }[] + >(); + const localRefs = new Set(); + function addTag(commit: string, ref: string, remote: boolean) { + const version = tryParse(ref.slice("refs/tags/".length)); + if (!version || !commit) return; + const entries = tags.get(commit) ?? []; + entries.push({ ref, version, remote }); + tags.set(commit, entries); + } + + const localTags = await client.exec( + "for-each-ref", + "--format=%(refname)%09%(objectname)%09%(*objectname)", + "refs/tags", + ); + for (const line of localTags.split("\n")) { + const [ref, object, peeled] = line.split("\t"); + if (!ref?.startsWith("refs/tags/")) continue; + localRefs.add(ref); + addTag(peeled || object, ref, false); + } + + let isShallow = (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; - if ( - isShallow && (await client.exec("remote")).split(/\s+/).includes("origin") - ) { - const head = await client.exec("rev-parse", "HEAD"); - await client.exec( - "fetch", - "--filter=tree:0", - "--no-tags", - "--unshallow", - "origin", - head, - ); - const commits = new Set( - (await client.exec("rev-list", "HEAD")).split("\n"), - ); + const hasOrigin = isShallow && + (await client.exec("remote")).split(/\s+/).includes("origin"); + if (hasOrigin) { const remoteTags = await client.exec("ls-remote", "--tags", "origin"); - const tags = new Map(); + const remoteRefs = new Map(); for (const line of remoteTags.split("\n")) { const [object, ref] = line.split("\t"); if (!object || !ref?.startsWith("refs/tags/")) continue; const peeled = ref.endsWith("^{}"); const name = peeled ? ref.slice(0, -3) : ref; - const tag = tags.get(name) ?? {}; + const tag = remoteRefs.get(name) ?? {}; if (peeled) tag.commit = object; else tag.object = object; - tags.set(name, tag); + remoteRefs.set(name, tag); + } + for (const [ref, tag] of remoteRefs) { + if (!localRefs.has(ref)) { + addTag(tag.commit ?? tag.object ?? "", ref, true); + } + } + } + + async function nearestTag() { + const history = await client.exec("rev-list", "--date-order", "HEAD"); + for (const commit of history.split("\n")) { + const candidates = tags.get(commit); + if (candidates?.length) { + return candidates.reduce((best, tag) => + compare(tag.version, best.version) > 0 ? tag : best + ); + } } - let bestTag: { ref: string; version: SemVer } | undefined; - for (const [ref, tag] of tags) { - if (!commits.has(tag.commit ?? tag.object ?? "")) continue; - const version = tryParse(ref.slice("refs/tags/".length)); - if (version && (!bestTag || compare(version, bestTag.version) > 0)) { - bestTag = { ref, version }; + } + + let nearest = await nearestTag(); + if (hasOrigin) { + const head = await client.exec("rev-parse", "HEAD"); + let deepen = 1; + while (!nearest && isShallow && tags.size > 0) { + const before = await client.exec("rev-list", "--count", "HEAD"); + await client.exec( + "fetch", + "--filter=tree:0", + "--no-tags", + `--deepen=${deepen}`, + "origin", + head, + ); + nearest = await nearestTag(); + isShallow = + (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; + if ( + isShallow && + (await client.exec("rev-list", "--count", "HEAD")) === before + ) { + throw new Error("Unable to deepen the current Git history from origin"); } + deepen *= 2; } - if (bestTag) { + if (nearest?.remote) { await client.exec( "fetch", "--filter=tree:0", "--no-tags", "origin", - `${bestTag.ref}:${bestTag.ref}`, + `${nearest.ref}:${nearest.ref}`, ); } } - const version = await client.getVersionFromTags(); - return version ? parse(version) : DEFAULT_VERSION; + return nearest?.version ?? DEFAULT_VERSION; } export async function readCurrentVersion(context: IContext): Promise { From 1f93e55dd24e358f0deeb96d83bba871a6cd98e2 Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 22:00:58 -0500 Subject: [PATCH 7/8] fix: drastically simplify git tag resolution getVersionFromGitTags had grown an unreachable-remote-tag map, a compare-based "highest version wins" reduction for same-commit tags, and a separate ls-remote-and-precision-fetch pass just to avoid pulling in unrelated tags. Replace it with: check local tags first (zero network calls in the common case), and only when shallow with tags on the remote, deepen the commit history incrementally with a tree/blob filter until a reachable tag turns up. No comparison between same-commit tags, no reachability bookkeeping against ls-remote output. This changes two observable behaviors, now reflected in the spec and requirements: when multiple version tags name the same commit, one is used arbitrarily instead of picking the highest; and deepening may also pull in unrelated tag refs (never their tree/blob contents) instead of fetching only the one resolved tag. Co-Authored-By: Claude Sonnet 5 --- .../003-git-current-version.requirement.md | 4 +- .../009-shallow-origin-tags.requirement.md | 19 +-- .../specifications/version-sources.spec.md | 24 +-- src/util/source.test.ts | 13 +- src/util/version.ts | 144 +++++++----------- 5 files changed, 85 insertions(+), 119 deletions(-) diff --git a/.agents/requirements/version-sources/003-git-current-version.requirement.md b/.agents/requirements/version-sources/003-git-current-version.requirement.md index 872fc5d..ad5a3c7 100644 --- a/.agents/requirements/version-sources/003-git-current-version.requirement.md +++ b/.agents/requirements/version-sources/003-git-current-version.requirement.md @@ -16,8 +16,8 @@ Expected behavior: - `get` and `parse` without a value MUST report the version from the nearest parseable local tag reachable from the current ref, or `0.1.0` if none exists. -- If several version tags point to that commit, the highest version among them - MUST be selected. +- If several version tags point to that commit, any one of them MAY be selected + without comparing version numbers. - Resolving the current version MUST remove an existing `VERSION` file. Postconditions: diff --git a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md index 06162c7..7b58dbd 100644 --- a/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -1,10 +1,10 @@ --- id: version-sources-009 -title: Shallow clones stop deepening at the nearest version tag +title: Shallow clones deepen minimally to find the nearest version tag spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" --- -# Shallow clones stop deepening at the nearest version tag +# Shallow clones deepen minimally to find the nearest version tag ## Requirement @@ -18,17 +18,14 @@ Preconditions: Expected behavior: - Tag based resolution MUST deepen only the current checkout's commit history, - with a tree and blob filter, until the nearest parseable tag is found. -- It MUST stop deepening once that tag is found and fetch only that tag object. + filtering out file trees and blobs, re-checking local tags after each fetch, + until a reachable tag is found or the full history has been fetched. - It MUST report the nearest tag's version even when an older reachable tag has - a higher version number. -- If the remote has no parseable version tags, it MUST use `0.1.0` without - deepening the checkout. + a higher version number, and even when deepening also pulls in unrelated tags. +- If the remote advertises no tags at all, it MUST use `0.1.0` without deepening + the checkout. Postconditions: -- The selected tag is available locally and the repository remains shallow when - older history exists beyond it. -- The older and unrelated tags, their commits, and historical tree and blob - objects are absent locally. +- Historical tree and blob objects beyond the resolved tag are absent locally. - Tests: `src/util/source.test.ts` (`req:version-sources-009`). diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index a9f23eb..7af7291 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -35,14 +35,16 @@ to configured post hooks without creating a `VERSION` file or a tag. ## Tag lookup -The nearest tag is the first parseable version tag encountered while walking -commits from the current ref in Git date order. When several version tags name -that commit, the highest version among them wins. Tags on unrelated branches are -excluded. - -In a shallow repository with an `origin` remote, resolution discovers tag refs -without fetching their objects, then deepens only the current checkout's commit -history until it finds that nearest tag. It stops at that point and fetches only -the selected tag. Historical file trees and blobs are excluded from these -fetches. A complete repository, or one without `origin`, uses its existing local -tags. If no parseable tag is reachable, resolution uses `0.1.0`. +The nearest tag is the first parseable version tag reachable while walking +commits from the current ref, using only the commit history currently available +locally. When several version tags name the same commit, one of them is used +without comparing version numbers. Tags on unrelated branches are excluded. + +In a shallow repository with an `origin` remote, resolution first checks whether +the remote advertises any tags at all. If so, it deepens the current checkout's +commit history incrementally, filtering out file trees and blobs, and re-checks +the local tags after each fetch until it finds a reachable tag or the full +history has been fetched. This may also fetch tags unrelated to the current ref, +but never their tree or blob contents. A complete repository, a shallow +repository without `origin`, or a remote with no tags at all uses `0.1.0` +without deepening further. diff --git a/src/util/source.test.ts b/src/util/source.test.ts index fabf936..2fa688e 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -135,7 +135,6 @@ Deno.test("req:version-sources-009 - stops at the nearest tag in a shallow clone await git("add", "README"); await git("commit", "-qm", "tagged"); await git("tag", "-a", "v1.2.3", "-m", "release 1.2.3"); - const olderCommit = await git("rev-parse", "HEAD"); const oldTree = await git("rev-parse", "v1.2.3^{tree}"); const oldBlob = await git("rev-parse", "v1.2.3:README"); await Deno.writeTextFile(`${source}/README`, "second\n"); @@ -155,7 +154,6 @@ Deno.test("req:version-sources-009 - stops at the nearest tag in a shallow clone await Deno.writeTextFile(`${source}/README`, "unrelated\n"); await git("add", "README"); await git("commit", "-qm", "unrelated branch"); - const unrelatedCommit = await git("rev-parse", "HEAD"); await git("tag", "v9.9.9"); await git("switch", "-q", currentBranch); @@ -199,8 +197,14 @@ Deno.test("req:version-sources-009 - stops at the nearest tag in a shallow clone await cloneGit("rev-parse", "--is-shallow-repository"), "true", ); - assertEquals(await cloneGit("tag", "--list"), "v1.1.0"); - for (const object of [olderCommit, oldTree, oldBlob, unrelatedCommit]) { + // Resolution fetches tags alongside the deepened history, so unrelated + // tags come along for the ride; only their tree and blob contents stay + // excluded by the tree:0 filter. + assertEquals( + await cloneGit("tag", "--list"), + "v1.1.0\nv1.2.3\nv9.9.9", + ); + for (const object of [oldTree, oldBlob]) { const check = await new Deno.Command("git", { args: ["cat-file", "-e", object], cwd: shallow, @@ -232,7 +236,6 @@ Deno.test("req:version-sources-002 req:version-sources-003 - nearest tag wins in await git("add", "README"); await git("commit", "-qm", "new release"); await git("tag", "v1.0.0"); - await git("tag", "v0.9.0"); await Deno.writeTextFile(`${cwd}/README`, "head\n"); await git("add", "README"); await git("commit", "-qm", "head"); diff --git a/src/util/version.ts b/src/util/version.ts index 4808577..6cc857e 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { compare, format, parse, SemVer, tryParse } from "semver"; +import { format, parse, SemVer, tryParse } from "semver"; import { IContext } from "../context.ts"; import { HookWarning } from "../hooks/hooks.interfaces.ts"; import { semverFormats } from "./variant.ts"; @@ -182,107 +182,71 @@ export const gitClientFactory = { }; /** - * Attempts to determine the latest version as specified by git tags + * Finds the nearest parseable version tag reachable from HEAD, using only the + * commit history currently available locally. */ -export async function getVersionFromGitTags() { - const client = gitClientFactory.create(); - const tags = new Map< - string, - { ref: string; version: SemVer; remote: boolean }[] - >(); - const localRefs = new Set(); - function addTag(commit: string, ref: string, remote: boolean) { - const version = tryParse(ref.slice("refs/tags/".length)); - if (!version || !commit) return; - const entries = tags.get(commit) ?? []; - entries.push({ ref, version, remote }); - tags.set(commit, entries); - } - - const localTags = await client.exec( +async function findNearestLocalTag( + client: ConventionalGitClient, +): Promise { + const tagsByCommit = new Map(); + const refs = await client.exec( "for-each-ref", - "--format=%(refname)%09%(objectname)%09%(*objectname)", + "--format=%(objectname)%09%(*objectname)%09%(refname:short)", "refs/tags", ); - for (const line of localTags.split("\n")) { - const [ref, object, peeled] = line.split("\t"); - if (!ref?.startsWith("refs/tags/")) continue; - localRefs.add(ref); - addTag(peeled || object, ref, false); + for (const line of refs.split("\n")) { + const [object, peeled, name] = line.split("\t"); + const commit = peeled || object; + if (!commit || !name || tagsByCommit.has(commit)) continue; + const version = tryParse(name); + if (version) tagsByCommit.set(commit, version); } + if (tagsByCommit.size === 0) return undefined; - let isShallow = - (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; - const hasOrigin = isShallow && - (await client.exec("remote")).split(/\s+/).includes("origin"); - if (hasOrigin) { - const remoteTags = await client.exec("ls-remote", "--tags", "origin"); - const remoteRefs = new Map(); - for (const line of remoteTags.split("\n")) { - const [object, ref] = line.split("\t"); - if (!object || !ref?.startsWith("refs/tags/")) continue; - const peeled = ref.endsWith("^{}"); - const name = peeled ? ref.slice(0, -3) : ref; - const tag = remoteRefs.get(name) ?? {}; - if (peeled) tag.commit = object; - else tag.object = object; - remoteRefs.set(name, tag); - } - for (const [ref, tag] of remoteRefs) { - if (!localRefs.has(ref)) { - addTag(tag.commit ?? tag.object ?? "", ref, true); - } - } + const history = await client.exec("rev-list", "HEAD"); + for (const commit of history.split("\n")) { + const version = tagsByCommit.get(commit); + if (version) return version; } + return undefined; +} - async function nearestTag() { - const history = await client.exec("rev-list", "--date-order", "HEAD"); - for (const commit of history.split("\n")) { - const candidates = tags.get(commit); - if (candidates?.length) { - return candidates.reduce((best, tag) => - compare(tag.version, best.version) > 0 ? tag : best - ); - } - } - } +/** + * Attempts to determine the latest version as specified by git tags + */ +export async function getVersionFromGitTags(): Promise { + const client = gitClientFactory.create(); - let nearest = await nearestTag(); - if (hasOrigin) { - const head = await client.exec("rev-parse", "HEAD"); - let deepen = 1; - while (!nearest && isShallow && tags.size > 0) { - const before = await client.exec("rev-list", "--count", "HEAD"); - await client.exec( - "fetch", - "--filter=tree:0", - "--no-tags", - `--deepen=${deepen}`, - "origin", - head, - ); - nearest = await nearestTag(); - isShallow = - (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; - if ( - isShallow && - (await client.exec("rev-list", "--count", "HEAD")) === before - ) { - throw new Error("Unable to deepen the current Git history from origin"); + let version = await findNearestLocalTag(client); + const isShallow = !version && + (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; + if (isShallow) { + const hasOrigin = (await client.exec("remote")).split(/\s+/).includes( + "origin", + ); + const hasRemoteTags = hasOrigin && + (await client.exec("ls-remote", "--tags", "origin")).trim().length > 0; + if (hasRemoteTags) { + let deepen = 1; + let shallow = true; + while (!version && shallow) { + await client.exec( + "fetch", + "--filter=tree:0", + "--tags", + `--deepen=${deepen}`, + "origin", + "HEAD", + ); + version = await findNearestLocalTag(client); + shallow = + (await client.exec("rev-parse", "--is-shallow-repository")) === + "true"; + deepen *= 2; } - deepen *= 2; - } - if (nearest?.remote) { - await client.exec( - "fetch", - "--filter=tree:0", - "--no-tags", - "origin", - `${nearest.ref}:${nearest.ref}`, - ); } } - return nearest?.version ?? DEFAULT_VERSION; + return version ?? DEFAULT_VERSION; } export async function readCurrentVersion(context: IContext): Promise { From 1d945fceaccdd797a9c87a06bc2133676b6ad3b0 Mon Sep 17 00:00:00 2001 From: Justin Chase Date: Tue, 27 Oct 2026 22:08:39 -0500 Subject: [PATCH 8/8] fix: take the highest version among same-commit tags Same-commit comparison is a narrow, well-defined case (at most a handful of tags), unlike comparing across different commits while walking history for the nearest reachable tag, which stays a plain first-match. Co-Authored-By: Claude Sonnet 5 --- .../003-git-current-version.requirement.md | 4 ++-- .agents/specifications/version-sources.spec.md | 4 ++-- src/util/source.test.ts | 1 + src/util/version.ts | 10 +++++++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.agents/requirements/version-sources/003-git-current-version.requirement.md b/.agents/requirements/version-sources/003-git-current-version.requirement.md index ad5a3c7..872fc5d 100644 --- a/.agents/requirements/version-sources/003-git-current-version.requirement.md +++ b/.agents/requirements/version-sources/003-git-current-version.requirement.md @@ -16,8 +16,8 @@ Expected behavior: - `get` and `parse` without a value MUST report the version from the nearest parseable local tag reachable from the current ref, or `0.1.0` if none exists. -- If several version tags point to that commit, any one of them MAY be selected - without comparing version numbers. +- If several version tags point to that commit, the highest version among them + MUST be selected. - Resolving the current version MUST remove an existing `VERSION` file. Postconditions: diff --git a/.agents/specifications/version-sources.spec.md b/.agents/specifications/version-sources.spec.md index 7af7291..5bbcf54 100644 --- a/.agents/specifications/version-sources.spec.md +++ b/.agents/specifications/version-sources.spec.md @@ -37,8 +37,8 @@ to configured post hooks without creating a `VERSION` file or a tag. The nearest tag is the first parseable version tag reachable while walking commits from the current ref, using only the commit history currently available -locally. When several version tags name the same commit, one of them is used -without comparing version numbers. Tags on unrelated branches are excluded. +locally. When several version tags name the same commit, the highest version +among them is used. Tags on unrelated branches are excluded. In a shallow repository with an `origin` remote, resolution first checks whether the remote advertises any tags at all. If so, it deepens the current checkout's diff --git a/src/util/source.test.ts b/src/util/source.test.ts index 2fa688e..db0139f 100644 --- a/src/util/source.test.ts +++ b/src/util/source.test.ts @@ -236,6 +236,7 @@ Deno.test("req:version-sources-002 req:version-sources-003 - nearest tag wins in await git("add", "README"); await git("commit", "-qm", "new release"); await git("tag", "v1.0.0"); + await git("tag", "v0.9.0"); await Deno.writeTextFile(`${cwd}/README`, "head\n"); await git("add", "README"); await git("commit", "-qm", "head"); diff --git a/src/util/version.ts b/src/util/version.ts index 6cc857e..e25e3f6 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { format, parse, SemVer, tryParse } from "semver"; +import { compare, format, parse, SemVer, tryParse } from "semver"; import { IContext } from "../context.ts"; import { HookWarning } from "../hooks/hooks.interfaces.ts"; import { semverFormats } from "./variant.ts"; @@ -197,9 +197,13 @@ async function findNearestLocalTag( for (const line of refs.split("\n")) { const [object, peeled, name] = line.split("\t"); const commit = peeled || object; - if (!commit || !name || tagsByCommit.has(commit)) continue; + if (!commit || !name) continue; const version = tryParse(name); - if (version) tagsByCommit.set(commit, version); + if (!version) continue; + const existing = tagsByCommit.get(commit); + if (!existing || compare(version, existing) > 0) { + tagsByCommit.set(commit, version); + } } if (tagsByCommit.size === 0) return undefined;