Skip to content

Commit 96194fe

Browse files
authored
feat: add git and manual platform adapters (#68)
* feat: add git and manual platform adapters Add two new platform types that enable GTS to run outside GitHub Actions and GitLab CI: - `git` platform: reads commit metadata directly from git commands (rev-parse, branch --show-current, describe --tags) - `manual` platform: accepts commit SHA, ref name, tag, and change request ID via CLI flags (--commit-sha, --ref-name, --git-tag, --change-request-id) or GTS_ env vars Split Platform interface: isSupported() moved to AutoDetectablePlatform, which only CI adapters (GitHub, GitLab) implement. Auto-detection still only considers CI platforms and throws if none match. When platform is 'auto' and any manual CLI flag/env var is present, automatically switches to the manual platform. New CLI options: --platform, --commit-sha, --ref-name, --git-tag, --change-request-id (each with GTS_ env var equivalents). Closes #62 * fix: address PR review feedback - Remove isSupported from mock platform in versionResolver.test.ts (no longer part of Platform interface) - Replace non-null assertions with empty string defaults in index.ts manual opts construction — ManualPlatform constructor validates and throws a clear error when required fields are missing * fix: address second round of PR review feedback - Mock executeCommand in git platform unit tests for determinism: tests now assert exact git commands called and return values, including detached HEAD fallback and tag-not-found error handling - Export MANUAL_PLATFORM_REQUIRED_OPTIONS_ERROR constant from manual.ts, reuse in index.ts and manual.test.ts - Derive --platform CLI choices from allPlatformTypes instead of hard-coding the list * fix: restore executeCommand mock after git platform tests mock.module is global in bun's test runner, so the mocked executeCommand was leaking into process.test.ts and causing CI failures. Import the real function before mocking and restore it in afterAll. * fix: use constructor injection instead of mock.module for git tests mock.module in bun is process-global and leaks across test files even with afterAll restoration. Switch to constructor injection: GitPlatform accepts an optional CommandExecutor parameter (defaults to executeCommand). Tests pass a mock function directly, avoiding any global module state. * fix: log platform resolution after successful construction Log ManualPlatform resolution after successful construction, not before — avoids misleading logs when constructor throws due to missing required fields.
1 parent eeaef45 commit 96194fe

13 files changed

Lines changed: 569 additions & 32 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,20 @@ index.ts CLI entry point (Commander.js)
6666

6767
## CLI Options
6868

69-
| Option | Short | Default | Env Var | Description |
70-
| -------------------- | ----- | ---------------------- | ------------------- | -------------------------------------------- |
71-
| `--config-file` | `-f` | `git-that-semver.yaml` | `GTS_CONFIG_FILE` | Config file path |
72-
| `--config-value` | `-c` | `[]` | | Override config values (`path.to.key=value`) |
73-
| `--log-level` | | `INFO` | `GTS_LOG_LEVEL` | `TRACE\|DEBUG\|INFO\|WARN\|ERROR\|SILENT` |
74-
| `--enable-strategy` | `-e` | `[]` | | Enable strategies by name |
75-
| `--disable-strategy` | `-d` | `[]` | | Disable strategies by name |
76-
| `--output-format` | `-o` | `env` | `GTS_OUTPUT_FORMAT` | `env\|json\|yaml` |
77-
| `--dump-config` | | `false` | | Dump resolved config and exit |
69+
| Option | Short | Default | Env Var | Description |
70+
| --------------------- | ----- | ---------------------- | ----------------------- | --------------------------------------------------- |
71+
| `--config-file` | `-f` | `git-that-semver.yaml` | `GTS_CONFIG_FILE` | Config file path |
72+
| `--config-value` | `-c` | `[]` | | Override config values (`path.to.key=value`) |
73+
| `--log-level` | | `INFO` | `GTS_LOG_LEVEL` | `TRACE\|DEBUG\|INFO\|WARN\|ERROR\|SILENT` |
74+
| `--enable-strategy` | `-e` | `[]` | | Enable strategies by name |
75+
| `--disable-strategy` | `-d` | `[]` | | Disable strategies by name |
76+
| `--output-format` | `-o` | `env` | `GTS_OUTPUT_FORMAT` | `env\|json\|yaml` |
77+
| `--platform` | | (from config) | `GTS_PLATFORM` | Platform type (`auto\|github\|gitlab\|git\|manual`) |
78+
| `--commit-sha` | | | `GTS_COMMIT_SHA` | Commit SHA (manual platform) |
79+
| `--ref-name` | | | `GTS_REF_NAME` | Branch/tag name (manual platform) |
80+
| `--git-tag` | | | `GTS_GIT_TAG` | Git tag (manual platform) |
81+
| `--change-request-id` | | | `GTS_CHANGE_REQUEST_ID` | Change request ID (manual platform) |
82+
| `--dump-config` | | `false` | | Dump resolved config and exit |
7883

7984
Exit codes: `0` success, `2` unexpected error, `3` Zod validation error.
8085

@@ -92,7 +97,7 @@ Strategy configs inherit from `defaults` (deep merge, except `branchPrefixes`).
9297

9398
```
9499
Config
95-
platform: "auto" | "github" | "gitlab"
100+
platform: "auto" | "github" | "gitlab" | "git" | "manual"
96101
defaults: DefaultConfig
97102
branchPrefixes: string[] # stripped from branch names (e.g. "feature/")
98103
snapshot: SnapshotConfig
@@ -181,10 +186,12 @@ Uses LiquidJS. Templates are defined in config YAML (both defaults and per-strat
181186

182187
**Auto-detection** (`platform: auto`): tries GitHub, then GitLab; throws if neither matches.
183188

184-
| Platform | Detection | SHA | Ref Name | Tag | Change Request |
185-
| -------------- | --------------------------------- | --------------- | -------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
186-
| GitHub Actions | `CI=true` + `GITHUB_ACTIONS=true` | `GITHUB_SHA` | `GITHUB_HEAD_REF` (PR, via `GITHUB_EVENT_NAME`) or `GITHUB_REF_NAME` | `GITHUB_REF_NAME` if `GITHUB_REF_TYPE=tag` | `pr-{N}` from `GITHUB_REF` (when `GITHUB_EVENT_NAME=pull_request`) |
187-
| GitLab CI | `CI=true` + `GITLAB_CI=true` | `CI_COMMIT_SHA` | `CI_COMMIT_REF_NAME` | `CI_COMMIT_TAG` | `mr-{N}` from `CI_MERGE_REQUEST_IID` |
189+
| Platform | Detection | SHA | Ref Name | Tag | Change Request |
190+
| -------------- | --------------------------------- | -------------------- | -------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
191+
| GitHub Actions | `CI=true` + `GITHUB_ACTIONS=true` | `GITHUB_SHA` | `GITHUB_HEAD_REF` (PR, via `GITHUB_EVENT_NAME`) or `GITHUB_REF_NAME` | `GITHUB_REF_NAME` if `GITHUB_REF_TYPE=tag` | `pr-{N}` from `GITHUB_REF` (when `GITHUB_EVENT_NAME=pull_request`) |
192+
| GitLab CI | `CI=true` + `GITLAB_CI=true` | `CI_COMMIT_SHA` | `CI_COMMIT_REF_NAME` | `CI_COMMIT_TAG` | `mr-{N}` from `CI_MERGE_REQUEST_IID` |
193+
| Git | Explicit (`--platform git`) | `git rev-parse HEAD` | `git branch --show-current` | `git describe --tags --exact-match HEAD` | Always `undefined` |
194+
| Manual | Explicit or auto (when flags set) | `--commit-sha` / env | `--ref-name` / env | `--git-tag` / env | `--change-request-id` / env |
188195

189196
## Output Formats
190197

index.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ZodError } from "zod";
66
import { resolveConfig } from "./src/config";
77
import { style, LogLevel, logger } from "./src/logging";
88
import { resolveOutputPrinter } from "./src/output";
9-
import { resolvePlatform } from "./src/platform";
9+
import { allPlatformTypes, resolvePlatform } from "./src/platform";
1010
import { resolveVersion } from "./src/version/versionResolver";
1111
import { resolveStrategies } from "./src/version/versionStrategy";
1212

@@ -54,6 +54,32 @@ const program = new Command("git-that-semver")
5454
.default("env")
5555
.choices(["env", "json", "yaml"] as const),
5656
)
57+
.addOption(
58+
new Option("--platform <platform>", "Platform type")
59+
.env("GTS_PLATFORM")
60+
.choices(["auto", ...allPlatformTypes] as const),
61+
)
62+
.addOption(
63+
new Option("--commit-sha <sha>", "Commit SHA (manual platform)").env(
64+
"GTS_COMMIT_SHA",
65+
),
66+
)
67+
.addOption(
68+
new Option("--ref-name <name>", "Branch/tag name (manual platform)").env(
69+
"GTS_REF_NAME",
70+
),
71+
)
72+
.addOption(
73+
new Option("--git-tag <tag>", "Git tag (manual platform)").env(
74+
"GTS_GIT_TAG",
75+
),
76+
)
77+
.addOption(
78+
new Option(
79+
"--change-request-id <id>",
80+
"Change request identifier (manual platform)",
81+
).env("GTS_CHANGE_REQUEST_ID"),
82+
)
5783
.option("--dump-config", "Dump configuration for debug purposes")
5884
.configureOutput({
5985
writeErr: (str) =>
@@ -93,7 +119,25 @@ try {
93119
process.exit(0);
94120
}
95121

96-
const platform = resolvePlatform(config.platform);
122+
const opts = program.opts();
123+
const platformType = opts.platform ?? config.platform;
124+
125+
const hasManualOpts = !!(
126+
opts.commitSha ||
127+
opts.refName ||
128+
opts.gitTag ||
129+
opts.changeRequestId
130+
);
131+
const manualOpts = hasManualOpts
132+
? {
133+
sha: opts.commitSha ?? "",
134+
refName: opts.refName ?? "",
135+
tag: opts.gitTag,
136+
changeRequestId: opts.changeRequestId,
137+
}
138+
: undefined;
139+
140+
const platform = resolvePlatform(platformType, manualOpts);
97141
const strategies = resolveStrategies(config.strategies);
98142
const result = resolveVersion(config, platform, strategies);
99143

src/config/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22

3-
import { specificPlatformTypes } from "../platform";
3+
import { allPlatformTypes } from "../platform";
44

55
export const FreeformProperties = z.record(z.string(), z.string());
66

@@ -55,7 +55,7 @@ export const OutputConfig = z.object({
5555

5656
export const Config = z.object({
5757
defaults: DefaultConfig.prefault({}),
58-
platform: z.enum(["auto", ...specificPlatformTypes]).default("auto"),
58+
platform: z.enum(["auto", ...allPlatformTypes]).default("auto"),
5959
tagPrefix: z.string().default(""),
6060
strategies: z.record(
6161
z

src/platform/git.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterEach, describe, expect, mock, test } from "bun:test";
2+
3+
import { GitPlatform } from "./git";
4+
5+
describe("Git Platform", () => {
6+
const mockExec = mock();
7+
const platform = new GitPlatform(mockExec);
8+
9+
afterEach(() => {
10+
mockExec.mockReset();
11+
});
12+
13+
test("identifies as git platform", () => {
14+
expect(platform.type).toBe("git");
15+
});
16+
17+
test("returns commit SHA from git rev-parse HEAD", () => {
18+
mockExec.mockReturnValueOnce("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
19+
20+
expect(platform.getCommitSha()).toBe(
21+
"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
22+
);
23+
expect(mockExec).toHaveBeenLastCalledWith(["git", "rev-parse", "HEAD"]);
24+
});
25+
26+
test("returns branch name from git branch --show-current", () => {
27+
mockExec.mockReturnValueOnce("feature/my-branch");
28+
29+
expect(platform.getCommitRefName()).toBe("feature/my-branch");
30+
expect(mockExec).toHaveBeenLastCalledWith([
31+
"git",
32+
"branch",
33+
"--show-current",
34+
]);
35+
});
36+
37+
test("falls back to git rev-parse --abbrev-ref HEAD on detached HEAD", () => {
38+
mockExec
39+
.mockReturnValueOnce("") // git branch --show-current returns empty
40+
.mockReturnValueOnce("HEAD"); // git rev-parse --abbrev-ref HEAD
41+
42+
expect(platform.getCommitRefName()).toBe("HEAD");
43+
expect(mockExec).toHaveBeenLastCalledWith([
44+
"git",
45+
"rev-parse",
46+
"--abbrev-ref",
47+
"HEAD",
48+
]);
49+
});
50+
51+
test("returns tag from git describe --tags --exact-match", () => {
52+
mockExec.mockReturnValueOnce("v1.2.3");
53+
54+
expect(platform.getGitTag()).toBe("v1.2.3");
55+
expect(mockExec).toHaveBeenLastCalledWith([
56+
"git",
57+
"describe",
58+
"--tags",
59+
"--exact-match",
60+
"HEAD",
61+
]);
62+
});
63+
64+
test("returns undefined when HEAD is not tagged", () => {
65+
mockExec.mockImplementationOnce(() => {
66+
throw new Error("fatal: no tag exactly matches");
67+
});
68+
69+
expect(platform.getGitTag()).toBeUndefined();
70+
});
71+
72+
test("always returns undefined for change request identifier", () => {
73+
expect(platform.getChangeRequestIdentifier()).toBeUndefined();
74+
});
75+
});

src/platform/git.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Platform } from "../platform";
2+
import { executeCommand as defaultExecuteCommand } from "../util/process";
3+
4+
type CommandExecutor = (parts: string[]) => string;
5+
6+
export class GitPlatform implements Platform {
7+
type = "git";
8+
9+
constructor(private exec: CommandExecutor = defaultExecuteCommand) {}
10+
11+
getCommitSha(): string {
12+
return this.exec(["git", "rev-parse", "HEAD"]);
13+
}
14+
15+
getCommitRefName(): string {
16+
const branch = this.exec(["git", "branch", "--show-current"]);
17+
if (branch.length > 0) {
18+
return branch;
19+
}
20+
21+
return this.exec(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
22+
}
23+
24+
getGitTag(): string | undefined {
25+
try {
26+
return this.exec(["git", "describe", "--tags", "--exact-match", "HEAD"]);
27+
} catch {
28+
return undefined;
29+
}
30+
}
31+
32+
getChangeRequestIdentifier(): string | undefined {
33+
return undefined;
34+
}
35+
}

src/platform/github.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { Platform } from "../platform";
1+
import type { AutoDetectablePlatform } from "../platform";
22
import { requiredEnv } from "../util/env";
33

4-
export class GitHubPlatform implements Platform {
4+
export class GitHubPlatform implements AutoDetectablePlatform {
55
type = "github";
66

77
getGitTag(): string | undefined {

src/platform/gitlab.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { Platform } from "../platform";
1+
import type { AutoDetectablePlatform } from "../platform";
22
import { env, requiredEnv } from "../util/env";
33

4-
export class GitLabPlatform implements Platform {
4+
export class GitLabPlatform implements AutoDetectablePlatform {
55
type = "gitlab";
66

77
getGitTag(): string | undefined {

src/platform/index.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22

3-
import { resolvePlatform, specificPlatformTypes } from "./index";
3+
import {
4+
allPlatformTypes,
5+
resolvePlatform,
6+
specificPlatformTypes,
7+
} from "./index";
48

59
describe("Platform Resolution", () => {
610
const originalEnv = process.env;
@@ -25,6 +29,25 @@ describe("Platform Resolution", () => {
2529
expect(platform.type).toBe("gitlab");
2630
});
2731

32+
test("resolves git platform when specified", () => {
33+
const platform = resolvePlatform("git");
34+
expect(platform.type).toBe("git");
35+
});
36+
37+
test("resolves manual platform when specified with options", () => {
38+
const platform = resolvePlatform("manual", {
39+
sha: "abc123",
40+
refName: "main",
41+
});
42+
expect(platform.type).toBe("manual");
43+
});
44+
45+
test("throws when manual platform specified without options", () => {
46+
expect(() => resolvePlatform("manual")).toThrow(
47+
"Manual platform requires --commit-sha and --ref-name",
48+
);
49+
});
50+
2851
test("throws on unknown platform", () => {
2952
expect(() => resolvePlatform("unknown")).toThrow(
3053
"Unknown platform: unknown",
@@ -47,14 +70,28 @@ describe("Platform Resolution", () => {
4770
expect(platform.type).toBe("gitlab");
4871
});
4972

73+
test("auto-resolves manual platform when manual options provided", () => {
74+
process.env["CI"] = "false";
75+
76+
const platform = resolvePlatform("auto", {
77+
sha: "abc123",
78+
refName: "main",
79+
});
80+
expect(platform.type).toBe("manual");
81+
});
82+
5083
test("throws when no platform can be auto-resolved", () => {
5184
process.env["CI"] = "false";
5285
expect(() => resolvePlatform("auto")).toThrow(
5386
"Platform could not be resolved automatically.",
5487
);
5588
});
5689

57-
test("exports supported platform types", () => {
90+
test("exports auto-detectable platform types", () => {
5891
expect(specificPlatformTypes).toEqual(["github", "gitlab"]);
5992
});
93+
94+
test("exports all platform types including git and manual", () => {
95+
expect(allPlatformTypes).toEqual(["github", "gitlab", "git", "manual"]);
96+
});
6097
});

0 commit comments

Comments
 (0)