Skip to content

Commit 267883c

Browse files
silverwindclaude
andcommitted
Breaking: Push commit and tag by default with opt-out flag (#47)
Previously only -R (release) pushed, leaving the push implicit and surprising in that one flow (#44). Both GitHub and Gitea require the commit to exist on the remote before a release can be created, so the push cannot be eliminated for -R. Make push the default after commit+tag, with -n/--no-push to opt out (incompatible with --release). Add -o/--remote (default "origin") and -B/--branch (default current branch) to override the push target. Release forge detection follows --remote so the release lands where the push went. Detached HEAD and flag validation now happen before any git mutation. Closes #44 Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
1 parent ba250f5 commit 267883c

File tree

3 files changed

+162
-10
lines changed

3 files changed

+162
-10
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ usage: versions [options] patch|minor|major|prerelease [files...]
2727
-g, --gitless Do not perform any git action like creating commit and tag
2828
-D, --dry Do not create a tag or commit, just print what would be done
2929
-R, --release Create a GitHub or Gitea release with the changelog as body
30+
-n, --no-push Skip pushing commit and tag
31+
-o, --remote <name> Git remote to push to. Default is "origin"
32+
-B, --branch <name> Git branch to push. Default is the current branch
3033
-V, --verbose Print verbose output to stderr
3134
-v, --version Print the version
3235
-h, --help Print this help
@@ -54,9 +57,13 @@ To automatically sign commits and tags created by `versions` with GPG add this t
5457
gpgSign = if-asked
5558
```
5659

60+
## Pushing
61+
62+
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.
63+
5764
## Creating releases
5865

59-
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.
66+
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`.
6067

6168
The tool will automatically detect whether you're using GitHub or Gitea based on your git remote URL.
6269

index.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,127 @@ test("release integration - tag already exists on remote (different commit)", ()
562562
}
563563
}));
564564

565+
test("default push - pushes commit and tag without --release", () => withTmpDir(async (tmpDir) => {
566+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
567+
568+
await initGitRepo(tmpDir);
569+
const env = getIsolatedGitEnv(tmpDir);
570+
const bareDir = await createBareRemote(tmpDir);
571+
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
572+
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
573+
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
574+
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
575+
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
576+
577+
await exec("node", [distPath, "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});
578+
579+
const {stdout: localHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: tmpDir, env: {...process.env, ...env}});
580+
const {stdout: remoteHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});
581+
expect(remoteHead.trim()).toEqual(localHead.trim());
582+
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
583+
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
584+
}));
585+
586+
test("--no-push skips push", () => withTmpDir(async (tmpDir) => {
587+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
588+
589+
await initGitRepo(tmpDir);
590+
const env = getIsolatedGitEnv(tmpDir);
591+
const bareDir = await createBareRemote(tmpDir);
592+
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
593+
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
594+
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
595+
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
596+
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
597+
598+
const {stdout: remoteHeadBefore} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});
599+
600+
await exec("node", [distPath, "--no-push", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});
601+
602+
const {stdout: remoteHeadAfter} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});
603+
expect(remoteHeadAfter.trim()).toEqual(remoteHeadBefore.trim());
604+
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
605+
expect(remoteTags.trim().split("\n").filter(Boolean)).not.toContain("1.0.1");
606+
}));
607+
608+
test("--no-push and --release are mutually exclusive", async () => {
609+
try {
610+
await exec("node", [distPath, "--no-push", "--release", "--base", "1.0.0", "patch"]);
611+
throw new Error("should have thrown");
612+
} catch (err: any) {
613+
expect(err).toBeInstanceOf(SubprocessError);
614+
expect(err.exitCode).toEqual(1);
615+
}
616+
});
617+
618+
test("--remote pushes to custom remote", () => withTmpDir(async (tmpDir) => {
619+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
620+
621+
await initGitRepo(tmpDir);
622+
const env = getIsolatedGitEnv(tmpDir);
623+
const bareDir = await createBareRemote(tmpDir);
624+
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
625+
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
626+
await exec("git", ["remote", "add", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
627+
await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
628+
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
629+
630+
await exec("node", [distPath, "--remote", "upstream", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});
631+
632+
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
633+
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
634+
}));
635+
636+
test("--remote with --release uses that remote for forge detection", () => withTmpDir(async (tmpDir) => {
637+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
638+
639+
await initGitRepo(tmpDir);
640+
const env = getIsolatedGitEnv(tmpDir);
641+
const bareDir = await createBareRemote(tmpDir);
642+
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
643+
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
644+
// origin has no forge URL, upstream points at github.com — release must follow --remote
645+
await exec("git", ["remote", "add", "origin", "file:///nowhere"], {cwd: tmpDir, env: {...process.env, ...env}});
646+
await exec("git", ["remote", "add", "upstream", "https://github.com/owner/repo.git"], {cwd: tmpDir, env: {...process.env, ...env}});
647+
await exec("git", ["remote", "set-url", "--push", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
648+
await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
649+
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
650+
651+
// fails at api.github.com call (fake token) — but only gets there if forge detection used upstream
652+
try {
653+
await exec("node", [distPath, "--remote", "upstream", "--release", "patch", "package.json"], {
654+
cwd: tmpDir,
655+
env: {...process.env, GITHUB_TOKEN: "fake-token", ...env},
656+
});
657+
} catch (err: any) {
658+
expect(err).toBeInstanceOf(SubprocessError);
659+
expect(err.exitCode).toEqual(1);
660+
}
661+
662+
// confirm push landed on upstream (not origin)
663+
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
664+
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
665+
}));
666+
667+
test("--branch pushes specified branch", () => withTmpDir(async (tmpDir) => {
668+
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
669+
670+
await initGitRepo(tmpDir);
671+
const env = getIsolatedGitEnv(tmpDir);
672+
const bareDir = await createBareRemote(tmpDir);
673+
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
674+
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
675+
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
676+
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
677+
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
678+
await exec("git", ["checkout", "-b", "release"], {cwd: tmpDir, env: {...process.env, ...env}});
679+
680+
await exec("node", [distPath, "--branch", "release", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});
681+
682+
const {stdout: remoteBranches} = await exec("git", ["branch", "--list"], {cwd: bareDir});
683+
expect(remoteBranches).toContain("release");
684+
}));
685+
565686
test("incrementSemver prerelease", () => {
566687
expect(incrementSemver("1.0.0", "prerelease", "alpha")).toEqual("1.0.1-alpha.0");
567688
expect(incrementSemver("1.0.1-beta.0", "prerelease", "beta")).toEqual("1.0.1-beta.1");

index.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,9 @@ export type RepoInfo = {
263263
type: "github" | "gitea";
264264
};
265265

266-
export async function getRepoInfo(cwd?: string): Promise<RepoInfo | null> {
266+
export async function getRepoInfo(cwd?: string, remote: string = "origin"): Promise<RepoInfo | null> {
267267
try {
268-
const {stdout} = await exec("git", ["remote", "get-url", "origin"], cwd ? {cwd} : undefined);
268+
const {stdout} = await exec("git", ["remote", "get-url", remote], cwd ? {cwd} : undefined);
269269
const url = stdout.trim();
270270

271271
// Parse git URLs: https://host/owner/repo.git or git@host:owner/repo.git
@@ -356,6 +356,9 @@ async function main(): Promise<void> {
356356
version: {short: "v", type: "boolean"},
357357
date: {short: "d", type: "boolean"},
358358
release: {short: "R", type: "boolean"},
359+
"no-push": {short: "n", type: "boolean"},
360+
remote: {short: "o", type: "string"},
361+
branch: {short: "B", type: "string"},
359362
base: {short: "b", type: "string"},
360363
command: {short: "c", type: "string"},
361364
replace: {short: "r", type: "string", multiple: true},
@@ -389,7 +392,10 @@ async function main(): Promise<void> {
389392
-r, --replace <str> Additional replacements in the format "s#regexp#replacement#flags"
390393
-g, --gitless Do not perform any git action like creating commit and tag
391394
-D, --dry Do not create a tag or commit, just print what would be done
392-
-R, --release Create a GitHub or Gitea release, push commit and tag to origin
395+
-R, --release Create a GitHub or Gitea release with the changelog as body
396+
-n, --no-push Skip pushing commit and tag
397+
-o, --remote <name> Git remote to push to. Default is "origin"
398+
-B, --branch <name> Git branch to push. Default is the current branch
393399
-V, --verbose Print verbose output to stderr
394400
-v, --version Print the version
395401
-h, --help Print this help
@@ -412,8 +418,9 @@ async function main(): Promise<void> {
412418
const gitDir = findUp(".git", pwd);
413419
let projectRoot = gitDir ? dirname(gitDir) : null;
414420
if (!projectRoot) projectRoot = pwd;
421+
const pushRemote = typeof args.remote === "string" ? args.remote : "origin";
415422
const releasePrep = (!args.gitless && args.release) ? (() => {
416-
const repoInfo = getRepoInfo();
423+
const repoInfo = getRepoInfo(undefined, pushRemote);
417424
return {
418425
repoInfo,
419426
tokens: repoInfo.then(info => {
@@ -493,6 +500,23 @@ async function main(): Promise<void> {
493500
if (args.gitless && args.release) {
494501
return end(new Error("--gitless and --release are mutually exclusive"));
495502
}
503+
if (args["no-push"] && args.release) {
504+
return end(new Error("--no-push and --release are mutually exclusive"));
505+
}
506+
507+
// resolve push branch early so detached HEAD fails before commit/tag
508+
let pushBranch: string = "";
509+
if (!args.gitless && !args.dry && !args["no-push"]) {
510+
if (typeof args.branch === "string") {
511+
pushBranch = args.branch;
512+
} else {
513+
const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
514+
pushBranch = branchOut.trim();
515+
if (pushBranch === "HEAD") {
516+
return end(new Error("Cannot push from detached HEAD. Pass --branch <name> or --no-push."));
517+
}
518+
}
519+
}
496520

497521
// set new version
498522
const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined);
@@ -592,18 +616,18 @@ async function main(): Promise<void> {
592616
// adding explicit -a here seems to make git no longer sign the tag
593617
writeResult(await exec("git", ["tag", "-f", "-F", "-", tagName], {stdin: {string: tagMsg}}));
594618

619+
// push commit and tag
620+
if (!args["no-push"]) {
621+
writeResult(await exec("git", ["push", pushRemote, pushBranch, tagName]));
622+
}
623+
595624
// create release if requested
596625
if (releasePrep) {
597626
const repoInfo = await releasePrep.repoInfo;
598627
if (!repoInfo) {
599628
throw new Error("Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation.");
600629
}
601630

602-
const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
603-
const branch = branchOut.trim();
604-
if (branch === "HEAD") throw new Error("Cannot create release from detached HEAD");
605-
writeResult(await exec("git", ["push", "origin", branch, tagName]));
606-
607631
const releaseBody = changelog || tagName;
608632
const forgeName = repoInfo.type === "github" ? "GitHub" : "Gitea";
609633
const tokens = await releasePrep.tokens;

0 commit comments

Comments
 (0)