diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 09bc935..b775571 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -65,6 +65,21 @@ only file that spawns. `terminal/` is loaded only with the bundled Terminal plugin (optional dependency). Verify with `./gradlew build buildPlugin` there (JDK 21). +- The changelog is written once: `CHANGELOG.md` at the root is the only + one edited by hand. Its versions are the CLI's — the clients ship it, so + one entry covers all three, back to 0.1.0, which predates them. It is + an end-user document, shipped verbatim in the .vsix: release notes + only, nothing about versioning policy or how the copies are made. + The clients' copies — + `editors/vscode/CHANGELOG.md` (the source verbatim) and the + `` block of the JetBrains `plugin.xml` (HTML, the most + recent releases only) — are placeholders in the repository exactly like + the version, stamped by `packaging/changelog/generate` at package time + and never committed: releasing is one commit to `CHANGELOG.md`. The + `make` targets put the placeholder back around a build; the publish + workflows only stamp. `release.yml` takes a release's GitHub notes from + the matching section. Adding a place that publishes a changelog means + adding a target there, not another file to keep in step. - Logo assets are generated, never hand-edited: `assets/src/logo.svg` (full size) and `assets/src/logo-icon.svg` (adapted for small formats, the source of every icon) are the only files touched by hand. diff --git a/.github/workflows/publish_idea.yml b/.github/workflows/publish_idea.yml index 89cd68a..585a6c7 100644 --- a/.github/workflows/publish_idea.yml +++ b/.github/workflows/publish_idea.yml @@ -71,6 +71,7 @@ jobs: version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$GITHUB_WORKSPACE/src/workforest/__init__.py") [ -n "$version" ] || { echo "cannot read __version__" >&2; exit 1; } echo "PLUGIN_VERSION=$version" >> "$GITHUB_ENV" + "$GITHUB_WORKSPACE/packaging/changelog/generate" ./gradlew --no-daemon -PpluginVersion="$version" build buildPlugin - name: The zip carries every platform's CLI diff --git a/.github/workflows/publish_openvsx.yml b/.github/workflows/publish_openvsx.yml index fe64ab7..3614bcf 100644 --- a/.github/workflows/publish_openvsx.yml +++ b/.github/workflows/publish_openvsx.yml @@ -58,6 +58,7 @@ jobs: version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$GITHUB_WORKSPACE/src/workforest/__init__.py") [ -n "$version" ] || { echo "cannot read __version__" >&2; exit 1; } npm pkg set version="$version" name=workforest displayName=Workforest + "$GITHUB_WORKSPACE/packaging/changelog/generate" echo "packaging $version as $(npm pkg get name | tr -d '\"')" for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do rm -rf bin && mkdir bin diff --git a/.github/workflows/publish_vscode.yml b/.github/workflows/publish_vscode.yml index db87d52..0f2d9ad 100644 --- a/.github/workflows/publish_vscode.yml +++ b/.github/workflows/publish_vscode.yml @@ -98,6 +98,7 @@ jobs: version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$GITHUB_WORKSPACE/src/workforest/__init__.py") [ -n "$version" ] || { echo "cannot read __version__" >&2; exit 1; } npm pkg set version="$version" + "$GITHUB_WORKSPACE/packaging/changelog/generate" echo "packaging $version as $(npm pkg get name | tr -d '\"')" for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do rm -rf bin && mkdir bin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7962f2d..ef2418d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,9 +26,17 @@ jobs: # token do not trigger the release-published publisher workflows. GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | - tag="v${{ steps.version.outputs.version }}" + version="${{ steps.version.outputs.version }}" + tag="v$version" if git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null; then echo "Tag $tag already exists — nothing to release." else - gh release create "$tag" --target "$GITHUB_SHA" --generate-notes + # The release's own section of CHANGELOG.md is its notes; a + # version that has none falls back to the commit list. + notes=$(mktemp) + if packaging/changelog/generate --release-notes "$version" > "$notes"; then + gh release create "$tag" --target "$GITHUB_SHA" --notes-file "$notes" + else + gh release create "$tag" --target "$GITHUB_SHA" --generate-notes + fi fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d22a637 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,115 @@ +# Changelog + +## 0.6.1 + +- The VS Code extension is published to + [Open VSX](https://open-vsx.org/extension/ArkadyBuryakov/workforest) as + well as the Visual Studio Marketplace, so VSCodium, Cursor, Windsurf and + the other forks install it from the registry they use. +- On the Visual Studio Marketplace the extension is `workforest-vscode`, + shown as **Workforest for VS Code** — the plain name is held there by + another publisher. On Open VSX it stays `workforest`. + +## 0.6.0 + +The first release with editor clients: a VS Code extension and a JetBrains +plugin, each on its marketplace. + +Both ship the `workforest` CLI. The package for each platform +(`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`) carries a +self-contained executable, and that is the one the client runs — it was +built with the package, so the two always match. Only where the package +has none does a client fall back to `PATH` and the usual install +directories; neither offers a setting for the path. + +- VS Code: the Workforest sidebar — a header toolbar (create, open, run + script, checkout, delete, refresh, and more under `…`) over two + collapsible sections, Scripts (run and stop with one click) and + Worktrees (the main checkout, then the managed worktrees by recency, + with dirty markers and the worktree this window is in) — the same + commands in the Command Palette, the merged configuration, config + scaffolding, and a status bar item. +- JetBrains: the Workforest tool window over the same two sections, with + tooltips, inline buttons and context menus; create, open, checkout and + delete worktrees; run and stop scripts in a terminal tab; the current + worktree on the status bar. +- Scripts are marked where they run: the row's icon turns light blue in + this window's worktree and orange in the others, with the instance + counts next to the name. The marks are part of the row, so the run and + stop buttons no longer shift as a script starts or stops. +- Checkout and Delete from the header or the Command Palette act on the + worktree this window is in, after confirming it, instead of asking + which; from the main checkout they still ask. +- Any number of instances of a script may run at once, in one worktree or + across several: each keeps a record and a log of its own + (`WORKTREE.PID.log`) and runs its own `cleanup`, and `wf stop NAME` + stops every instance in the worktree. `exclusive` is what holds a script + to one. +- A group member can set `hidden: true`: it is then left out of shell + completion and the clients' script lists, while `wf run` and `wf stop` + still take its name. + +## 0.5.2 + +- `wf list --json` describes the whole forest for programs. +- The project logo. +- Fixes: `wf init`, and foreground openers run from `workforest` rather + than the `wf` shell function. + +## 0.5.1 + +- Script groups: a `bulk` runs its members at once and relays their output + line by line; a `pipeline` runs them in turn and stops at the first + failure. Both are scripts like any other — `background`, `exclusive`, + `cleanup` and `wf stop` apply to the group. +- Packaging fixes for the AUR package and the Homebrew formula. + +## 0.5.0 + +- Scripts grew up: `background` (or `wf run -b`) detaches a script under a + supervisor of its own with its output in a log, `exclusive` holds one to + a single instance per project, `cleanup` runs when it ends, and + `stop_timeout` bounds SIGTERM before SIGKILL. +- Man pages: `workforest(1)` and `workforest(5)`, installed with the + package. +- Opener shortcuts are gone; openers are named in full. + +## 0.4.0 + +- Openers reworked: one config shape for every kind of opener, with the + window command alongside. + +## 0.3.0 + +- Openers and `window_command` are shell-native — they are the command + line you would type, not a template language. +- Better opener completion: an opener whose name overlaps a command is + left out rather than shadowing it. +- Remote branch resolution across several remotes. +- The Claude Code integration is marked experimental. + +## 0.2.3 + +- Virtual environments are scrubbed from a new worktree instead of being + copied into it. +- Packaging moved to templates rendered at release time, so a release no + longer commits back to the repository. + +## 0.2.2 + +- macOS support, via a Homebrew tap. + +## 0.2.0 + +- Opener variables and placeholders reworked. + +## 0.1.1 + +- The AUR package. + +## 0.1.0 + +First release: worktrees created, opened, and deleted in a predictable +place, with the project's own symlinks and setup scripts run for each; the +`wf` shell function and its alias; `wf run` for the project's `scripts`; +shell completion; PyPI packaging. diff --git a/Makefile b/Makefile index f83787f..40d42e4 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,12 @@ PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]')-$(shell uname -m | se VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"$$/\1/p' src/workforest/__init__.py) PLACEHOLDER_VERSION := 0.0.0 +# The changelog the clients publish is a placeholder in the repository too: +# a release is one commit to CHANGELOG.md. Stamp it in for the build and put +# the placeholder back afterwards, whether the build succeeded or not. +STAMP := packaging/changelog/generate > /dev/null +UNSTAMP := packaging/changelog/generate --placeholder > /dev/null + sync: uv sync @@ -75,8 +81,11 @@ vscode-build: binary rm -rf editors/vscode/bin && mkdir -p editors/vscode/bin cp dist/binary/workforest editors/vscode/bin/workforest cd editors/vscode && rm -f *.vsix && npm install --no-audit --no-fund - @cd editors/vscode && npm pkg set version=$(VERSION) && npm run package; \ - status=$$?; npm pkg set version=$(PLACEHOLDER_VERSION); exit $$status + @$(STAMP); \ + (cd editors/vscode && npm pkg set version=$(VERSION) && npm run package); \ + status=$$?; \ + (cd editors/vscode && npm pkg set version=$(PLACEHOLDER_VERSION)); \ + $(UNSTAMP); exit $$status vscode-install: @vsix=$$(ls -t editors/vscode/*.vsix 2>/dev/null | head -1); \ @@ -95,7 +104,9 @@ vscode: vscode-build vscode-install idea-build: binary rm -rf editors/idea/bin && mkdir -p editors/idea/bin/$(PLATFORM) cp dist/binary/workforest editors/idea/bin/$(PLATFORM)/workforest - cd editors/idea && JAVA_HOME="$(IDEA_JAVA_HOME)" ./gradlew --quiet -PpluginVersion=$(VERSION) buildPlugin + @$(STAMP); \ + (cd editors/idea && JAVA_HOME="$(IDEA_JAVA_HOME)" ./gradlew --quiet -PpluginVersion=$(VERSION) buildPlugin); \ + status=$$?; $(UNSTAMP); exit $$status # All four platforms in one zip: what CI publishes, and what the manual # first upload to the JetBrains Marketplace needs. PyInstaller only builds @@ -127,7 +138,9 @@ idea-build-full: cp "$$src" editors/idea/bin/$$target/workforest; \ chmod +x editors/idea/bin/$$target/workforest; \ done - cd editors/idea && JAVA_HOME="$(IDEA_JAVA_HOME)" ./gradlew --quiet -PpluginVersion=$(VERSION) buildPlugin + @$(STAMP); \ + (cd editors/idea && JAVA_HOME="$(IDEA_JAVA_HOME)" ./gradlew --quiet -PpluginVersion=$(VERSION) buildPlugin); \ + status=$$?; $(UNSTAMP); exit $$status @ls -l editors/idea/build/distributions/workforest-idea-*.zip # What "Install Plugin from Disk" does: unpack the zip into the plugins dir. diff --git a/editors/idea/src/main/resources/META-INF/plugin.xml b/editors/idea/src/main/resources/META-INF/plugin.xml index 4ab948b..44d1006 100644 --- a/editors/idea/src/main/resources/META-INF/plugin.xml +++ b/editors/idea/src/main/resources/META-INF/plugin.xml @@ -38,12 +38,8 @@ ]]> -
  • 0.2.0: the workforest CLI ships with the plugin and is the copy it runs — no separate install - needed, and the executable-path setting is gone; running badges on the scripts, one per place with - the instance counts; Delete and Checkout act on this window's worktree.
  • -
  • 0.1.0: create, open, checkout, and delete worktrees; run scripts in the terminal; the Workforest tool window.
  • - + +

    Placeholder: the change notes are stamped in at package time, the way the version is. Until then they live at the repository root.

    ]]>
    com.intellij.modules.platform diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index e30e4c7..8725e08 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,33 +1,9 @@ -# Changelog - -Versions are the `workforest` CLI's: the extension ships that CLI and is -released with it, so its number is stamped in at package time. - -## 0.2.0 + -The `workforest` CLI ships with the extension: the Marketplace package for -each platform (`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`) -carries a self-contained executable, and that is the one the extension -runs — it was built with this .vsix, so the two always match. Only where -the package has none (other platforms, the universal build) does it fall -back to `PATH` and the usual install directories. The -`workforest.executable` setting is gone with it. - -- Scripts are marked where they run: the row's icon turns light blue in - this window's worktree and orange in the others, with the instance - counts next to the name. The marks are part of the row, so the run and - stop buttons no longer shift as a script starts or stops. -- Checkout and Delete from the header or the Command Palette act on the - worktree this window is in, after confirming it, instead of asking - which; from the main checkout they still ask. - -## 0.1.0 +# Changelog -Initial release: the Workforest sidebar — a header toolbar (create, open, -run script, checkout, delete, refresh, and more under `…`) over two -collapsible sections, Scripts (run/stop with one click) and Worktrees -(main checkout, then managed worktrees by recency; dirty markers; the -worktree this window is in) — -create/open/delete/checkout worktrees, run and stop `scripts` in the -integrated terminal, show the merged configuration, scaffold project and -`.vscode/` local configs, status bar item. +Placeholder: the changelog is stamped in at package time, the way the +version is. Until then it lives [at the repository root](https://github.com/ArkadyBuryakov/workforest/blob/main/CHANGELOG.md). diff --git a/packaging/changelog/generate b/packaging/changelog/generate new file mode 100755 index 0000000..3439cc2 --- /dev/null +++ b/packaging/changelog/generate @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Stamp the CHANGELOG.md at the root into the packages that publish it. + +That file is the only changelog written by hand. The two places a +marketplace reads one from have to be inside the package they ship in, so +they are copies, and — like the version — they are copies only for as long +as a package build takes: + +- `editors/vscode/CHANGELOG.md` — what the .vsix carries and the Visual + Studio Marketplace and Open VSX show on their Changelog tab. Markdown, + so it is the source verbatim. +- the `` block of the JetBrains plugin's `plugin.xml` — + HTML, and only the most recent releases, which is what that marketplace + shows next to the version. + +Both hold a placeholder in the repository and are stamped at package time, +so a release is one commit to CHANGELOG.md and nothing else. The Makefile's +editor targets stamp and put the placeholder back around the build; the +publish workflows only stamp, on a checkout they throw away. + + packaging/changelog/generate stamp the changelog in + packaging/changelog/generate --placeholder put the placeholders back + packaging/changelog/generate --release-notes VERSION + print one release's section, + which release.yml gives the + GitHub release as its body +""" + +from __future__ import annotations + +import argparse +import html +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +SOURCE = ROOT / "CHANGELOG.md" +VSCODE = ROOT / "editors" / "vscode" / "CHANGELOG.md" +PLUGIN_XML = ROOT / "editors" / "idea" / "src" / "main" / "resources" / "META-INF" / "plugin.xml" + +# How many releases the JetBrains change notes carry. The Marketplace shows +# them under one version, so it is a "what's new lately", not the history. +CHANGE_NOTES_RELEASES = 3 + +# Where the changelog is when a package carries only the placeholder. +SOURCE_URL = "https://github.com/ArkadyBuryakov/workforest/blob/main/CHANGELOG.md" + +BANNER = ( + "Generated by packaging/changelog/generate from the CHANGELOG.md at the\n" + "repository root. Do not edit." +) + +PLACEHOLDER_MARKDOWN = f"""# Changelog + +Placeholder: the changelog is stamped in at package time, the way the +version is. Until then it lives [at the repository root]({SOURCE_URL}). +""" + +PLACEHOLDER_NOTES = ( + "

    Placeholder: the change notes are stamped in at package time, the way the " + f'version is. Until then they live at the repository root.

    ' +) + +INDENT = " " * 4 + + +@dataclass(slots=True, frozen=True) +class Release: + """One `## VERSION` section of the changelog.""" + + version: str + body: str + + +def parse(text: str) -> list[Release]: + """The releases of a changelog, newest first.""" + parts = re.split(r"^## +(\S+) *$", text, flags=re.MULTILINE) + return [ + Release(version, body.strip("\n")) + for version, body in zip(parts[1::2], parts[2::2], strict=True) + ] + + +def _link(match: re.Match[str]) -> str: + text, url = match.groups() + return f'{text}' + + +def _inline(text: str) -> str: + """The inline markdown of one block, as HTML.""" + out = html.escape(" ".join(text.split()), quote=False) + out = re.sub(r"`([^`]+)`", r"\1", out) + out = re.sub(r"\*\*([^*]+)\*\*", r"\1", out) + return re.sub(r"\[([^]]+)\]\(([^)]+)\)", _link, out) + + +def _bullets(block: str) -> list[str]: + """The items of a `- ` list, each with its continuation lines folded in.""" + items: list[str] = [] + for line in block.splitlines(): + if line.startswith("- "): + items.append(line[2:]) + else: + items[-1] += f" {line.strip()}" + return items + + +def to_html(body: str) -> str: + """A release body — paragraphs and `- ` lists — as HTML lines.""" + out: list[str] = [] + for block in re.split(r"\n{2,}", body.strip("\n")): + if block.startswith("- "): + out.append("") + else: + out.append(f"

    {_inline(block)}

    ") + return "\n".join(out) + + +def change_notes(releases: list[Release]) -> str: + """The JetBrains `` body: the recent releases, as HTML.""" + return "\n".join( + f"

    {release.version}

    \n{to_html(release.body)}" + for release in releases[:CHANGE_NOTES_RELEASES] + ) + + +def write_vscode(markdown: str) -> None: + VSCODE.write_text(f"\n\n{markdown}") + + +def write_change_notes(notes: str) -> None: + body = "\n".join(f"{INDENT}{line}" for line in notes.splitlines()) + block = ( + f"{INDENT}\n" + f"{body}\n" + f"{INDENT}]]>" + ) + patched, count = re.subn( + rf"^{INDENT}.*?", + lambda _: block, + PLUGIN_XML.read_text(), + flags=re.MULTILINE | re.DOTALL, + ) + if count != 1: + sys.exit(f"{PLUGIN_XML}: expected exactly one block, found {count}") + PLUGIN_XML.write_text(patched) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--placeholder", + action="store_true", + help="put the placeholders back, the state the repository keeps", + ) + group.add_argument( + "--release-notes", + metavar="VERSION", + help="print that release's section on stdout instead of writing anything", + ) + args = parser.parse_args() + + if args.release_notes: + for release in parse(SOURCE.read_text()): + if release.version == args.release_notes: + print(release.body) + return + sys.exit(f"{SOURCE}: no section for {args.release_notes}") + + if args.placeholder: + write_vscode(PLACEHOLDER_MARKDOWN) + write_change_notes(PLACEHOLDER_NOTES) + else: + source = SOURCE.read_text() + write_vscode(source) + write_change_notes(change_notes(parse(source))) + + for path in (VSCODE, PLUGIN_XML): + print(path.relative_to(ROOT)) + + +if __name__ == "__main__": + main()