Repository: atlassian/changesets (ts)

Issue:
Create git tags during version instead of publish
## Affected Packages

`@changesets/assemble-release-plan`
`@changesets/apply-release-plan`

## Problem

For the https://github.com/single-spa/create-single-spa monorepo, I use `changeset version` but use `pnpm publish -r` rather than `changeset publish`, as encouraged by https://pnpm.io/using-changesets.

The problem is that no git tags are created with those two commands:

| command | creates git tags? |
| --------- | ----------------- |
| `changeset version` | ❌  |
| `changeset publish` | ✅  |
| `pnpm version` | ✅  |
| `pnpm publish` | ❌  |

My ideal would be creating a separate git tag for each package in the monorepo - for example `webpack-config-single-spa@3.1.0` and `webpack-config-single-spa-react@2.1.0`

I first proposed a change to pnpm publish, but they thought the best place to implement the tagging would be during changeset version (see https://github.com/pnpm/pnpm/issues/3586).

## Proposed solution

### Option 1

Add a new flag `changeset version --git-tag` that commits and creates git tags. This would more closely match npm, yarn, and pnpm, all of which create git tags during their respective `version` commands.

### Option 2

Add a new command `changeset tag` that can be executed after `changeset version` and commit are completed, as an alternative to `changeset publish` for those using pnpm to publish rather than changeset

Pull request description:
Implement changeset tag and getAllTags. Resolves #612.
See #612. This is my first pull request to this repository - let me know if I missed any steps for contributing.

Patch:
diff --git a/.changeset/nervous-actors-protect.md b/.changeset/nervous-actors-protect.md
new file mode 100644
index 0000000..8eb719f
--- /dev/null
+++ b/.changeset/nervous-actors-protect.md
@@ -0,0 +1,9 @@
+---
+"@changesets/cli": minor
+---
+
+The new `changeset tag` command has been added. It can be used to create git tags for all packages.
+
+This is helpful in situations where a different tool is used to publish packages instead of Changesets. For situations where `changeset publish` is executed, running `changeset tag` is not needed.
+
+Note that it is expected that `changeset version` is run before `changeset tag`, so that the `package.json` versions are updated before the git tags are created. This command also doesn't take any configuration into account - it simply create tags for all packages with whatever version that is currently in their respective `package.json`.
diff --git a/.changeset/poor-wolves-know.md b/.changeset/poor-wolves-know.md
new file mode 100644
index 0000000..56794a1
--- /dev/null
+++ b/.changeset/poor-wolves-know.md
@@ -0,0 +1,5 @@
+---
+"@changesets/git": minor
+---
+
+A new `getAllTags` utility has been added.
diff --git a/docs/command-line-options.md b/docs/command-line-options.md
index 669dbc9..2af0d65 100644
--- a/docs/command-line-options.md
+++ b/docs/command-line-options.md
@@ -8,6 +8,7 @@ The command line for changesets is the main way of interracting with it. There a
 - publish [--otp=code, --tag]
 - status [--since-master --verbose --output=JSON_FILE.json]
 - pre [exit|enter {tag}]
+- tag
 
 The most important commands are `add`, which is used by contributors to add information about their changes, `version` - which is responsible for using the changesets generated by `add` to update package versions and changelogs, and then `publish` which publishes changes to npm.
 
@@ -141,3 +142,15 @@ pre [exit|enter {tag}]
 The pre command enters and exits pre mode. The command does not do any actual versioning, when doing a prerelease, you should run changeset pre enter next(or a different tag, the tag is what is in versions and is the npm dist tag) and then do the normal release process with changeset version and changeset publish. For more information about the pre command, see the prereleases [the prereleases documentation](https://github.com/atlassian/changesets/blob/master/docs/prereleases.md).
 
 > NOTE: pre-releases are a very complicated feature. Many of the safety rails that changesets helps you with will be taken off. We recommend that you have read both [problems publishing in monorepos](./problems-publishing-in-monorepos) as well as be clear on both exiting and entereing pre-releases before using it. You may also prefer using [snapshot releases](./snapshot-releases.md) for a slightly less involved process.
+
+## tag
+
+```
+tag
+```
+
+The tag command creates git tags for the current version of all packages. The tags created are equivalent to those created by [`changeset publish`](#publish), but the `tag` command does not publish anything to npm.
+
+This is helpful in situations where a different tool, such as `pnpm publish -r`, is used to publish packages instead of changeset. For situations where `changeset publish` is executed, running `changeset tag` is not needed.
+
+The git tags in monorepos are created in the format `pkg-name@version-number` and are based on the current version number of the `package.json` for each package. Note that in single-package repositories, the git tag will include `v` before the version number, for example `v1.0.0`. It is expected that [`changeset version`](#version) is run before `changeset tag`, so that the `package.json` versions are updated before the git tags are created.
diff --git a/packages/cli/src/commands/tag/index.ts b/packages/cli/src/commands/tag/index.ts
new file mode 100644
index 0000000..d545033
--- /dev/null
+++ b/packages/cli/src/commands/tag/index.ts
@@ -0,0 +1,23 @@
+import * as git from "@changesets/git";
+import { getPackages } from "@manypkg/get-packages";
+import { log } from "@changesets/logger";
+
+export default async function run(cwd: string) {
+  const { packages, tool } = await getPackages(cwd);
+
+  const allExistingTags = await git.getAllTags(cwd);
+
+  for (const pkg of packages) {
+    const tag =
+      tool !== "root"
+        ? `${pkg.packageJson.name}@${pkg.packageJson.version}`
+        : `v${pkg.packageJson.version}`;
+
+    if (allExistingTags.has(tag)) {
+      log("Skipping tag (already exists): ", tag);
+    } else {
+      log("New tag: ", tag);
+      await git.tag(tag, cwd);
+    }
+  }
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 098d3c4..5247e96 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -15,6 +15,7 @@ const { input, flags } = meow(
     publish [--otp=code]
     status [--since <branch>] [--verbose] [--output=JSON_FILE.json]
     pre <enter|exit> <tag>
+    tag
     `,
   {
     flags: {
diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts
index 38a26f8..161f801 100644
--- a/packages/cli/src/run.ts
+++ b/packages/cli/src/run.ts
@@ -13,6 +13,7 @@ import version from "./commands/version";
 import publish from "./commands/publish";
 import status from "./commands/status";
 import pre from "./commands/pre";
+import tagCommand from "./commands/tag";
 import { CliOptions } from "./types";
 
 export async function run(
@@ -172,6 +173,10 @@ export async function run(
         await status(cwd, { sinceMaster, since, verbose, output }, config);
         return;
       }
+      case "tag": {
+        await tagCommand(cwd);
+        return;
+      }
       case "pre": {
         let command = input[1];
         if (command !== "enter" && command !== "exit") {
diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts
index 92c5bbd..8604828 100644
--- a/packages/git/src/index.ts
+++ b/packages/git/src/index.ts
@@ -26,6 +26,23 @@ async function commit(message: string, cwd: string) {
   return gitCmd.code === 0;
 }
 
+async function getAllTags(cwd: string): Promise<Set<string>> {
+  const gitCmd = await spawn("git", ["tag"], { cwd });
+
+  if (gitCmd.code !== 0) {
+    throw new Error(gitCmd.stderr.toString());
+  }
+
+  const tags = gitCmd.stdout
+    .toString()
+    .trim()
+    .split("\n");
+
+  console.log("gonna return set", new Set(tags));
+
+  return new Set(tags);
+}
+
 // used to create a single tag at a time for the current head only
 async function tag(tagStr: string, cwd: string) {
   // NOTE: it's important we use the -m flag otherwise 'git push --follow-tags' wont actually push
@@ -260,5 +277,6 @@ export {
   commit,
   tag,
   getChangedPackagesSinceRef,
-  getChangedChangesetFilesSinceRef
+  getChangedChangesetFilesSinceRef,
+  getAllTags
 };


Test patch:
diff --git a/packages/cli/src/commands/tag/__tests__/index.test.ts b/packages/cli/src/commands/tag/__tests__/index.test.ts
new file mode 100644
index 0000000..f0a93f3
--- /dev/null
+++ b/packages/cli/src/commands/tag/__tests__/index.test.ts
@@ -0,0 +1,58 @@
+import fixtures from "fixturez";
+import { temporarilySilenceLogs } from "../../../../../test-utils/src";
+import * as git from "@changesets/git";
+import tag from "../index";
+
+const f = fixtures(__dirname);
+
+jest.mock("@changesets/git");
+
+describe("tag command", () => {
+  temporarilySilenceLogs();
+  let cwd: string;
+
+  describe("workspace project", () => {
+    beforeEach(async () => {
+      cwd = await f.copy("simple-project");
+    });
+
+    it("tags all packages", async () => {
+      (git.getAllTags as jest.Mock).mockReturnValue(new Set());
+
+      expect(git.tag).not.toHaveBeenCalled();
+      await tag(cwd);
+      expect(git.tag).toHaveBeenCalledTimes(2);
+      expect((git.tag as jest.Mock).mock.calls[0][0]).toEqual("pkg-a@1.0.0");
+      expect((git.tag as jest.Mock).mock.calls[1][0]).toEqual("pkg-b@1.0.0");
+    });
+
+    it("skips tags that already exist", async () => {
+      (git.getAllTags as jest.Mock).mockReturnValue(
+        new Set([
+          // pkg-a should not be re-tagged
+          "pkg-a@1.0.0"
+        ])
+      );
+
+      expect(git.tag).not.toHaveBeenCalled();
+      await tag(cwd);
+      expect(git.tag).toHaveBeenCalledTimes(1);
+      expect((git.tag as jest.Mock).mock.calls[0][0]).toEqual("pkg-b@1.0.0");
+    });
+  });
+
+  describe("single package repo", () => {
+    beforeEach(async () => {
+      cwd = await f.copy("root-only");
+    });
+
+    it("uses a simplified version-only tag", async () => {
+      (git.getAllTags as jest.Mock).mockReturnValue(new Set());
+
+      expect(git.tag).not.toHaveBeenCalled();
+      await tag(cwd);
+      expect(git.tag).toHaveBeenCalledTimes(1);
+      expect((git.tag as jest.Mock).mock.calls[0][0]).toEqual("v1.0.0");
+    });
+  });
+});
diff --git a/packages/git/src/index.test.ts b/packages/git/src/index.test.ts
index 0a46df4..aa4af29 100644
--- a/packages/git/src/index.test.ts
+++ b/packages/git/src/index.test.ts
@@ -11,7 +11,8 @@ import {
   tag,
   getDivergedCommit,
   getChangedPackagesSinceRef,
-  getChangedChangesetFilesSinceRef
+  getChangedChangesetFilesSinceRef,
+  getAllTags
 } from "./";
 
 const f = fixtures(__dirname);
@@ -164,6 +165,21 @@ describe("git", () => {
     });
   });
 
+  describe("getAllTags", () => {
+    beforeEach(async () => {
+      await add("packages/pkg-a/package.json", cwd);
+      await commit("added packageA package.json", cwd);
+    });
+
+    it("should retrieve all git tags", async () => {
+      await tag("test_tag", cwd);
+      await tag("test_tag2", cwd);
+      const tags = await getAllTags(cwd);
+      expect(tags).toContain("test_tag");
+      expect(tags).toContain("test_tag2");
+    });
+  });
+
   describe("tag", () => {
     beforeEach(async () => {
       await add("packages/pkg-a/package.json", cwd);