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..1f09503 --- /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 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`. + +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..872fc5d --- /dev/null +++ b/.agents/requirements/version-sources/003-git-current-version.requirement.md @@ -0,0 +1,27 @@ +--- +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 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: + +- 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..dad545f --- /dev/null +++ b/.agents/requirements/version-sources/004-git-increment.requirement.md @@ -0,0 +1,25 @@ +--- +id: version-sources-004 +title: Git increment updates hooks without writing the source of truth +spec_ref: ".agents/specifications/version-sources.spec.md#git-source" +--- + +# Git increment updates hooks without writing the source of truth + +## 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`. +- Every `inc` variant MUST apply configured post hooks with its proposed + version. +- It MUST NOT create `VERSION` or a tag. + +Postconditions: + +- 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/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/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..7b58dbd --- /dev/null +++ b/.agents/requirements/version-sources/009-shallow-origin-tags.requirement.md @@ -0,0 +1,31 @@ +--- +id: version-sources-009 +title: Shallow clones deepen minimally to find the nearest version tag +spec_ref: ".agents/specifications/version-sources.spec.md#tag-lookup" +--- + +# Shallow clones deepen minimally to find the nearest version tag + +## Requirement + +Preconditions: + +- 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 deepen only the current checkout's commit history, + 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, 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: + +- 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/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..5bbcf54 --- /dev/null +++ b/.agents/specifications/version-sources.spec.md @@ -0,0 +1,50 @@ +# 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 nearest 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 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. + +`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` 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 + +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, 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 +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/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..bc85f44 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,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 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 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 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 @@ -179,6 +182,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` +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 +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..eb5c2d9 100644 --- a/src/commands/inc/major.ts +++ b/src/commands/inc/major.ts @@ -2,8 +2,8 @@ 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 { IContext } from "../../context.ts"; @@ -13,19 +13,15 @@ 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 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..c5833f7 100644 --- a/src/commands/inc/minor.ts +++ b/src/commands/inc/minor.ts @@ -2,8 +2,8 @@ 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 { IContext } from "../../context.ts"; @@ -13,19 +13,15 @@ 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 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..7a9b0a8 100644 --- a/src/commands/inc/none.ts +++ b/src/commands/inc/none.ts @@ -2,8 +2,8 @@ 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 { IContext } from "../../context.ts"; @@ -13,19 +13,15 @@ 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 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..4c82582 100644 --- a/src/commands/inc/patch.ts +++ b/src/commands/inc/patch.ts @@ -2,8 +2,8 @@ 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 { IContext } from "../../context.ts"; @@ -13,19 +13,15 @@ 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 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..db0139f --- /dev/null +++ b/src/util/source.test.ts @@ -0,0 +1,371 @@ +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 and runs hooks without persistence. + const proposed = await cli("git-latest", "patch"); + assertEquals(proposed, "1.2.4"); + 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); + assertStringIncludes( + await Deno.readTextFile(`${cwd}/package.json`), + "1.2.4", + ); + + // 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", + ); + + 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 { + 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-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`; + 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", "-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"); + 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"); + await git("add", "README"); + await git("commit", "-qm", "unrelated branch"); + 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", + "1", + "--no-tags", + `file://${remote}`, + shallow, + ]); + const cloneGit = (...args: string[]) => run(shallow, "git", args); + await cloneGit("checkout", "--detach", "-q"); + 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.1.0", + ); + assertEquals( + await cloneGit("rev-parse", "--is-shallow-repository"), + "true", + ); + // 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, + 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 }); + } +}); + +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 { + 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..2d9e5d2 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, ); @@ -259,30 +259,23 @@ Deno.test({ "create", () => ({ - getVersionFromTags: () => { + exec: () => { throw new Error("failed in a 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..e25e3f6 100644 --- a/src/util/version.ts +++ b/src/util/version.ts @@ -1,9 +1,10 @@ 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"; 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 { @@ -180,30 +181,120 @@ export const gitClientFactory = { }, }; +/** + * Finds the nearest parseable version tag reachable from HEAD, using only the + * commit history currently available locally. + */ +async function findNearestLocalTag( + client: ConventionalGitClient, +): Promise { + const tagsByCommit = new Map(); + const refs = await client.exec( + "for-each-ref", + "--format=%(objectname)%09%(*objectname)%09%(refname:short)", + "refs/tags", + ); + for (const line of refs.split("\n")) { + const [object, peeled, name] = line.split("\t"); + const commit = peeled || object; + if (!commit || !name) continue; + const version = tryParse(name); + if (!version) continue; + const existing = tagsByCommit.get(commit); + if (!existing || compare(version, existing) > 0) { + tagsByCommit.set(commit, version); + } + } + if (tagsByCommit.size === 0) return undefined; + + 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; +} + /** * Attempts to determine the latest version as specified by git tags */ -export async function getVersionFromGitTags() { +export async function getVersionFromGitTags(): Promise { const client = gitClientFactory.create(); - try { - const isShallow = - (await client.exec("rev-parse", "--is-shallow-repository")) === "true"; - await client.exec( - "fetch", - "--filter=tree:0", - "--tags", - ...(isShallow ? ["--unshallow"] : []), + 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 version = await client.getVersionFromTags(); - if (!version) { - throw new Error("Unable to resolve version from tags"); + 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; + } + } + } + return version ?? DEFAULT_VERSION; +} + +export async function readCurrentVersion(context: IContext): Promise { + let contents: string | undefined; + try { + 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(); } }