diff --git a/README.md b/README.md index e44d305..9d62f32 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ usage: versions [options] patch|minor|major|prerelease [files...] -g, --gitless Do not perform any git action like creating commit and tag -D, --dry Do not create a tag or commit, just print what would be done -R, --release Create a GitHub or Gitea release with the changelog as body + -n, --no-push Skip pushing commit and tag + -o, --remote Git remote to push to. Default is "origin" + -B, --branch Git branch to push. Default is the current branch -V, --verbose Print verbose output to stderr -v, --version Print the version -h, --help Print this help @@ -54,9 +57,13 @@ To automatically sign commits and tags created by `versions` with GPG add this t gpgSign = if-asked ``` +## Pushing + +By default, `versions` pushes the commit and tag to `origin` after creating them. Pass `--no-push` to skip the push and keep changes local. Use `--remote` and `--branch` to override the target remote and branch. + ## Creating releases -When using the `--release` option, `versions` will automatically create a GitHub or Gitea release after creating the tag. The release body will contain the same changelog as the commit message. +When using the `--release` option, `versions` will automatically create a GitHub or Gitea release after pushing the tag. The release body will contain the same changelog as the commit message. `--release` requires the push and is incompatible with `--no-push`. The tool will automatically detect whether you're using GitHub or Gitea based on your git remote URL. diff --git a/index.test.ts b/index.test.ts index 670b924..57b6148 100644 --- a/index.test.ts +++ b/index.test.ts @@ -562,6 +562,127 @@ test("release integration - tag already exists on remote (different commit)", () } })); +test("default push - pushes commit and tag without --release", () => withTmpDir(async (tmpDir) => { + await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2)); + + await initGitRepo(tmpDir); + const env = getIsolatedGitEnv(tmpDir); + const bareDir = await createBareRemote(tmpDir); + await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}}); + + await exec("node", [distPath, "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}}); + + const {stdout: localHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: tmpDir, env: {...process.env, ...env}}); + const {stdout: remoteHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir}); + expect(remoteHead.trim()).toEqual(localHead.trim()); + const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1"); +})); + +test("--no-push skips push", () => withTmpDir(async (tmpDir) => { + await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2)); + + await initGitRepo(tmpDir); + const env = getIsolatedGitEnv(tmpDir); + const bareDir = await createBareRemote(tmpDir); + await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}}); + + const {stdout: remoteHeadBefore} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir}); + + await exec("node", [distPath, "--no-push", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}}); + + const {stdout: remoteHeadAfter} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir}); + expect(remoteHeadAfter.trim()).toEqual(remoteHeadBefore.trim()); + const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + expect(remoteTags.trim().split("\n").filter(Boolean)).not.toContain("1.0.1"); +})); + +test("--no-push and --release are mutually exclusive", async () => { + try { + await exec("node", [distPath, "--no-push", "--release", "--base", "1.0.0", "patch"]); + throw new Error("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(SubprocessError); + expect(err.exitCode).toEqual(1); + } +}); + +test("--remote pushes to custom remote", () => withTmpDir(async (tmpDir) => { + await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2)); + + await initGitRepo(tmpDir); + const env = getIsolatedGitEnv(tmpDir); + const bareDir = await createBareRemote(tmpDir); + await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "add", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}}); + + await exec("node", [distPath, "--remote", "upstream", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}}); + + const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1"); +})); + +test("--remote with --release uses that remote for forge detection", () => withTmpDir(async (tmpDir) => { + await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2)); + + await initGitRepo(tmpDir); + const env = getIsolatedGitEnv(tmpDir); + const bareDir = await createBareRemote(tmpDir); + await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}}); + // origin has no forge URL, upstream points at github.com — release must follow --remote + await exec("git", ["remote", "add", "origin", "file:///nowhere"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "add", "upstream", "https://github.com/owner/repo.git"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "set-url", "--push", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}}); + + // fails at api.github.com call (fake token) — but only gets there if forge detection used upstream + try { + await exec("node", [distPath, "--remote", "upstream", "--release", "patch", "package.json"], { + cwd: tmpDir, + env: {...process.env, GITHUB_TOKEN: "fake-token", ...env}, + }); + } catch (err: any) { + expect(err).toBeInstanceOf(SubprocessError); + expect(err.exitCode).toEqual(1); + } + + // confirm push landed on upstream (not origin) + const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1"); +})); + +test("--branch pushes specified branch", () => withTmpDir(async (tmpDir) => { + await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2)); + + await initGitRepo(tmpDir); + const env = getIsolatedGitEnv(tmpDir); + const bareDir = await createBareRemote(tmpDir); + await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}}); + await exec("git", ["checkout", "-b", "release"], {cwd: tmpDir, env: {...process.env, ...env}}); + + await exec("node", [distPath, "--branch", "release", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}}); + + const {stdout: remoteBranches} = await exec("git", ["branch", "--list"], {cwd: bareDir}); + expect(remoteBranches).toContain("release"); +})); + test("incrementSemver prerelease", () => { expect(incrementSemver("1.0.0", "prerelease", "alpha")).toEqual("1.0.1-alpha.0"); expect(incrementSemver("1.0.1-beta.0", "prerelease", "beta")).toEqual("1.0.1-beta.1"); diff --git a/index.ts b/index.ts index 92889f9..efe742a 100755 --- a/index.ts +++ b/index.ts @@ -263,9 +263,9 @@ export type RepoInfo = { type: "github" | "gitea"; }; -export async function getRepoInfo(cwd?: string): Promise { +export async function getRepoInfo(cwd?: string, remote: string = "origin"): Promise { try { - const {stdout} = await exec("git", ["remote", "get-url", "origin"], cwd ? {cwd} : undefined); + const {stdout} = await exec("git", ["remote", "get-url", remote], cwd ? {cwd} : undefined); const url = stdout.trim(); // Parse git URLs: https://host/owner/repo.git or git@host:owner/repo.git @@ -356,6 +356,9 @@ async function main(): Promise { version: {short: "v", type: "boolean"}, date: {short: "d", type: "boolean"}, release: {short: "R", type: "boolean"}, + "no-push": {short: "n", type: "boolean"}, + remote: {short: "o", type: "string"}, + branch: {short: "B", type: "string"}, base: {short: "b", type: "string"}, command: {short: "c", type: "string"}, replace: {short: "r", type: "string", multiple: true}, @@ -389,7 +392,10 @@ async function main(): Promise { -r, --replace Additional replacements in the format "s#regexp#replacement#flags" -g, --gitless Do not perform any git action like creating commit and tag -D, --dry Do not create a tag or commit, just print what would be done - -R, --release Create a GitHub or Gitea release, push commit and tag to origin + -R, --release Create a GitHub or Gitea release with the changelog as body + -n, --no-push Skip pushing commit and tag + -o, --remote Git remote to push to. Default is "origin" + -B, --branch Git branch to push. Default is the current branch -V, --verbose Print verbose output to stderr -v, --version Print the version -h, --help Print this help @@ -412,8 +418,9 @@ async function main(): Promise { const gitDir = findUp(".git", pwd); let projectRoot = gitDir ? dirname(gitDir) : null; if (!projectRoot) projectRoot = pwd; + const pushRemote = typeof args.remote === "string" ? args.remote : "origin"; const releasePrep = (!args.gitless && args.release) ? (() => { - const repoInfo = getRepoInfo(); + const repoInfo = getRepoInfo(undefined, pushRemote); return { repoInfo, tokens: repoInfo.then(info => { @@ -493,6 +500,23 @@ async function main(): Promise { if (args.gitless && args.release) { return end(new Error("--gitless and --release are mutually exclusive")); } + if (args["no-push"] && args.release) { + return end(new Error("--no-push and --release are mutually exclusive")); + } + + // resolve push branch early so detached HEAD fails before commit/tag + let pushBranch: string = ""; + if (!args.gitless && !args.dry && !args["no-push"]) { + if (typeof args.branch === "string") { + pushBranch = args.branch; + } else { + const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); + pushBranch = branchOut.trim(); + if (pushBranch === "HEAD") { + return end(new Error("Cannot push from detached HEAD. Pass --branch or --no-push.")); + } + } + } // set new version const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined); @@ -592,6 +616,11 @@ async function main(): Promise { // adding explicit -a here seems to make git no longer sign the tag writeResult(await exec("git", ["tag", "-f", "-F", "-", tagName], {stdin: {string: tagMsg}})); + // push commit and tag + if (!args["no-push"]) { + writeResult(await exec("git", ["push", pushRemote, pushBranch, tagName])); + } + // create release if requested if (releasePrep) { const repoInfo = await releasePrep.repoInfo; @@ -599,11 +628,6 @@ async function main(): Promise { throw new Error("Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation."); } - const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); - const branch = branchOut.trim(); - if (branch === "HEAD") throw new Error("Cannot create release from detached HEAD"); - writeResult(await exec("git", ["push", "origin", branch, tagName])); - const releaseBody = changelog || tagName; const forgeName = repoInfo.type === "github" ? "GitHub" : "Gitea"; const tokens = await releasePrep.tokens;