From ba579293f1c7a4822239a4ae2caf537c0c590e72 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:34:07 -0700 Subject: [PATCH 1/4] fix: derive release-notes tests from the running version and date released headings --- CHANGELOG.md | 10 +++++-- README.md | 4 +-- src/client/shell/tests/startup_overlays.rs | 19 ++++++++++-- src/release_notes.rs | 34 +++++++++++++++++++++- src/ui/release_notes.rs | 34 ++++++++++------------ 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0e6939..ee807d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ Superherdr's release history. Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr); changes inherited from upstream are described in Herdr's releases and ported into the inherited sections at the end of this file. -## [0.9.5.1] - Unreleased +## [0.9.5.2] - Unreleased + +### Fixed +- The installer attached to a release installs that release by default; 0.9.5.1's installer installed 0.9.1.2. +- What's New shows the running release's notes; 0.9.3.1 and 0.9.5.1 left out their own. + +## [0.9.5.1] - 2026-10-15 Based on Herdr 0.9.5. Upstream's 0.9.4 and 0.9.5 release bodies are ported into the inherited sections at the end of this file. @@ -30,7 +36,7 @@ Based on Herdr 0.9.5. Upstream's 0.9.4 and 0.9.5 release bodies are ported into - Typing appears faster, notably in PowerShell, with lower CPU for busy agents and idle shells and less memory for hidden panes. (#5149, #4506, #5083) - macOS `pane run` and `agent start` reject over-long commands during shell startup instead of silently truncating them. (#4950, #4988) -## [0.9.3.1] - Unreleased +## [0.9.3.1] - 2026-10-08 Based on Herdr 0.9.3. Upstream's 0.9.2 and 0.9.3 release bodies are ported into the inherited sections at the end of this file. diff --git a/README.md b/README.md index 16cf8f4a..29c18a5f 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ If the original Homebrew `herdr` formula is linked, unlink it first with `brew u ### Linux x86_64: shell installer -Download the versioned installer, inspect it, then run it: +Download the installer, inspect it, then run it: ```sh -curl -fsSL https://github.com/jsonMartin/superherdr/releases/download/superherdr-v0.9.0.1/install.sh -o install-superherdr.sh +curl -fsSL https://github.com/jsonMartin/superherdr/releases/latest/download/install.sh -o install-superherdr.sh sh install-superherdr.sh herdr ``` diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 88318d18..aa0d5ec9 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -774,8 +774,15 @@ fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() }) .collect::>() .join("\n"); - assert!(text.contains("v0.9.5.1")); - assert!(text.contains("SUPERHERDR 0.9.1.2")); + assert!(text.contains(&format!("v{}", crate::build_info::superherdr_version()))); + let history = crate::release_notes::full_history_body(); + let newest = history + .lines() + .next() + .and_then(|line| line.strip_prefix("### Superherdr ")) + .expect("history starts at the newest superherdr section") + .to_string(); + assert!(text.contains(&format!("SUPERHERDR {newest}"))); assert!(!state.hits.release_notes_scrollbar.is_empty()); let outer = crate::ui::centered_popup_rect( Rect::new(0, 0, 106, 30), @@ -1386,8 +1393,14 @@ fn whats_new_history_opens_without_notes_and_survives_snapshots_without_endpoint }) .collect::>() .join("\n"); + let history = crate::release_notes::full_history_body(); + let newest = history + .lines() + .next() + .and_then(|line| line.strip_prefix("### Superherdr ")) + .expect("history starts at the newest superherdr section"); assert!( - composed_text.contains("SUPERHERDR 0.9.1.2"), + composed_text.contains(&format!("SUPERHERDR {newest}")), "{composed_text}" ); diff --git a/src/release_notes.rs b/src/release_notes.rs index f717a5e6..723aa692 100644 --- a/src/release_notes.rs +++ b/src/release_notes.rs @@ -171,11 +171,15 @@ fn extract_version_section(content: &str, version: &str) -> Option { /// renderer styles them, and inherited sections keep their `### Inherited /// from Herdr ` headings. pub fn full_history_body() -> String { + full_history_body_from(EMBEDDED_CHANGELOG) +} + +pub(crate) fn full_history_body_from(changelog: &str) -> String { let mut lines = Vec::new(); let mut started = false; let mut skipping_unreleased = false; - for line in EMBEDDED_CHANGELOG.lines() { + for line in changelog.lines() { if let Some(rest) = line.strip_prefix("## [") { started = true; skipping_unreleased = rest.contains("Unreleased"); @@ -289,6 +293,34 @@ mod tests { } } + #[test] + fn newest_inherited_section_is_the_herdr_base() { + let base = crate::build_info::BASE_VERSION; + let newest = EMBEDDED_CHANGELOG + .lines() + .find_map(|line| line.strip_prefix("### Inherited from Herdr ")) + .map(str::trim) + .expect("changelog has inherited sections"); + assert_eq!( + newest, base, + "graft Herdr {base}'s release notes into CHANGELOG.md (see CONTRIBUTING.md)", + ); + } + + #[test] + fn changelog_dates_the_current_superherdr_version() { + let version = crate::build_info::superherdr_version(); + let heading = format!("## [{version}] - "); + let dated = EMBEDDED_CHANGELOG + .lines() + .find_map(|line| line.strip_prefix(&heading)) + .expect("date the `## []` CHANGELOG heading; releases embed it"); + assert!( + !dated.contains("Unreleased"), + "date the `## [{version}]` CHANGELOG heading; releases embed it", + ); + } + #[test] fn preserves_headings() { assert_eq!( diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index c6cb6858..db4afbd9 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -373,7 +373,7 @@ mod tests { #[cfg(test)] mod history_tests { use super::*; - use crate::release_notes::{full_history_body, EMBEDDED_CHANGELOG}; + use crate::release_notes::{full_history_body, full_history_body_from, EMBEDDED_CHANGELOG}; fn line_text(line: &Line<'_>) -> String { line.spans @@ -428,26 +428,22 @@ mod history_tests { #[test] fn history_omits_unreleased_sections() { - let unreleased_versions: Vec = EMBEDDED_CHANGELOG - .lines() - .filter_map(|line| { - let rest = line.strip_prefix("## [")?; - rest.contains("Unreleased") - .then(|| rest.split(']').next().unwrap_or(rest).trim().to_string()) - }) - .collect(); + let changelog = "## [9.9.9.2] - Unreleased\n\n### Fixed\n- Preview fix\n\n## [9.9.9.1] - 2026-01-01\n\n### Fixed\n- Released fix\n\n### Inherited from Herdr 9.9.9\n\nFixed:\n- Upstream fix\n"; + let palette = Palette::catppuccin(); + let texts = release_notes_lines(&full_history_body_from(changelog), &palette) + .iter() + .map(|(_, line)| line_text(line)) + .collect::>(); assert!( - !unreleased_versions.is_empty(), - "changelog currently has an unreleased section" + texts + .iter() + .all(|text| !text.contains("SUPERHERDR 9.9.9.2")), + "unreleased section leaked into the view: {texts:?}" + ); + assert!( + texts.iter().any(|text| text.contains("SUPERHERDR 9.9.9.1")), + "released section missing from the view: {texts:?}" ); - let texts = history_line_texts(); - for version in unreleased_versions { - let marker = format!("SUPERHERDR {version}"); - assert!( - texts.iter().all(|text| !text.contains(&marker)), - "unreleased section {version} leaked into the view" - ); - } } #[test] From 3322b5c89a2cf5edffabab1f57394e4eb3812e65 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:47:39 -0700 Subject: [PATCH 2/4] chore(sync): resolve cargo, revision and changelog conflicts mechanically --- scripts/sync_upstream.sh | 196 +++++++++++++++++++++++++++++++++- scripts/test_sync_upstream.py | 166 ++++++++++++++++++++++++++-- 2 files changed, 352 insertions(+), 10 deletions(-) diff --git a/scripts/sync_upstream.sh b/scripts/sync_upstream.sh index b43ba0ad..a1d23193 100755 --- a/scripts/sync_upstream.sh +++ b/scripts/sync_upstream.sh @@ -10,6 +10,9 @@ # Exit status: 0 merged, already merged, or check passed; 1 error; 2 conflicts # remain (the merge is left in progress for manual resolution). # +# Cargo.toml, Cargo.lock, SUPERHERDR_REVISION, and the CHANGELOG release-notes +# graft are resolved mechanically; any other conflict stays for a human. +# # Upstream tags are fetched into refs/upstream-tags/, never refs/tags/, so a # plain `git push` cannot publish them to this repository. set -euo pipefail @@ -113,6 +116,171 @@ while IFS= read -r path; do git add -- "$path" done < <(list_entries .upstream-sync/ours) +# Mechanical release bookkeeping: Cargo.toml, Cargo.lock, SUPERHERDR_REVISION, +# and the CHANGELOG graft. Upstream release tags sit on branches, so the merge +# base often carries an older version than either side; the version line is +# normalized to the tag's version in both merge parents before the 3-way merge +# so the name/version difference never conflicts. Only runs where Superherdr's +# Cargo.toml exists, so minimal fixtures and --check are unaffected. +unresolved=() +resolved=() +graft_ran=0 +if git cat-file -e HEAD:Cargo.toml 2>/dev/null; then + old_base="$(git show HEAD:Cargo.toml | sed -n 's/^version = "\(.*\)"$/\1/p' | head -1 || true)" + new_base="$(git show "$upstream_commit:Cargo.toml" 2>/dev/null | sed -n 's/^version = "\(.*\)"$/\1/p' | head -1 || true)" + old_rev="$(git show HEAD:src/build_info.rs 2>/dev/null | sed -n 's/^pub const SUPERHERDR_REVISION: u32 = \([0-9]\{1,\}\);$/\1/p' | head -1 || true)" + new_version="$new_base.1" + if [ "v$new_base" != "$tag" ]; then + unresolved+=("upstream Cargo.toml version ${new_base:-} does not match $tag") + fi + + set_version() { + awk -v v="$new_base" '!done && /^version = "/ { print "version = \"" v "\""; done = 1; next } { print }' + } + + if [ -n "$(git ls-files -u -- Cargo.toml)" ] && [ -n "$base" ] \ + && git cat-file -e "$base:Cargo.toml" 2>/dev/null \ + && git cat-file -e "$upstream_commit:Cargo.toml" 2>/dev/null; then + tmp="$(mktemp -d)" + git show "$base:Cargo.toml" | set_version > "$tmp/base" + git show "HEAD:Cargo.toml" | set_version > "$tmp/ours" + git show "$upstream_commit:Cargo.toml" > "$tmp/theirs" + if git merge-file -p "$tmp/ours" "$tmp/base" "$tmp/theirs" > "$tmp/out"; then + cat "$tmp/out" > Cargo.toml + git add Cargo.toml + resolved+=("Cargo.toml: merged with the version line normalized to $new_base") + fi + rm -rf "$tmp" + fi + + lock_unmerged=0 + if [ -n "$(git ls-files -u -- Cargo.lock)" ]; then + lock_unmerged=1 + # Upstream's lock has no superherdr entry; take theirs and re-lock the + # workspace members, which rewrites the root package entry. + git checkout --theirs -- Cargo.lock + fi + if [ -z "$(git ls-files -u -- Cargo.toml)" ] \ + && { [ "$new_base" != "$old_base" ] || [ "$lock_unmerged" -eq 1 ]; }; then + if cargo update --workspace --quiet; then + git add Cargo.lock + resolved+=("Cargo.lock: took upstream's and regenerated workspace entries with cargo update --workspace") + else + unresolved+=("Cargo.lock: cargo update --workspace failed") + git checkout -m -- Cargo.lock + fi + fi + + if [ "$new_base" != "$old_base" ]; then + if [ -n "$(git ls-files -u -- src/build_info.rs)" ]; then + unresolved+=("reset SUPERHERDR_REVISION to 1 after resolving src/build_info.rs") + elif [ -f src/build_info.rs ]; then + tmp="$(mktemp)" + sed 's/^pub const SUPERHERDR_REVISION: u32 = [0-9]\{1,\};$/pub const SUPERHERDR_REVISION: u32 = 1;/' src/build_info.rs > "$tmp" + cat "$tmp" > src/build_info.rs + rm -f "$tmp" + git add src/build_info.rs + resolved+=("SUPERHERDR_REVISION reset to 1") + fi + + graft_ran=1 + found_old_base=0 + graft_versions=() + if git cat-file -e "$upstream_commit:CHANGELOG.md" 2>/dev/null; then + while IFS= read -r v; do + if [ "$v" = "$old_base" ]; then + found_old_base=1 + break + fi + graft_versions+=("$v") + done < <({ git show "$upstream_commit:CHANGELOG.md" || true; } | awk '/^## \[/ && !/Unreleased/ { v = $0; sub(/^## \[/, "", v); sub(/\].*/, "", v); print v }') + fi + if [ "$found_old_base" -eq 0 ]; then + unresolved+=("CHANGELOG.md: graft Herdr notes by hand (upstream CHANGELOG lacks $old_base)") + elif [ "${graft_versions[0]:-}" != "$new_base" ]; then + unresolved+=("CHANGELOG.md: graft Herdr notes by hand (upstream CHANGELOG lacks $new_base)") + else + inherited="" + grafted_list="" + for v in "${graft_versions[@]}"; do + grep -qxF "### Inherited from Herdr $v" CHANGELOG.md && continue + # git show must survive awk's early exit on the section's end. + body="$({ git show "$upstream_commit:CHANGELOG.md" || true; } | awk -v want="$v" ' + BEGIN { on = 0; n = 0 } + /^## / { + if (on) { exit } + v = $0 + sub(/^## \[/, "", v) + sub(/\].*/, "", v) + if (v == want) { on = 1 } + next + } + on { + if (match($0, /^#+[ \t]+/)) { t = substr($0, RLENGTH + 1); $0 = toupper(substr(t, 1, 1)) tolower(substr(t, 2)) ":" } + gsub(/\*\*/, "") + while (match($0, /\[[^]]*\]\([^)]*\)/)) { + l = substr($0, RSTART, RLENGTH) + sub(/\]\(.*/, "", l) + $0 = substr($0, 1, RSTART - 1) substr(l, 2) substr($0, RSTART + RLENGTH) + } + lines[++n] = $0 + } + END { + while (n > 0 && lines[n] == "") n-- + started = 0 + for (i = 1; i <= n; i++) { if (lines[i] != "" || started) { started = 1; print lines[i] } } + }')" + inherited+="### Inherited from Herdr $v"$'\n\n'"$body"$'\n\n' + grafted_list="${grafted_list:+$grafted_list, }$v" + done + if [ -n "$inherited" ]; then + tmp="$(mktemp)" + line="$(grep -n '^### Inherited from Herdr ' CHANGELOG.md | head -1 | cut -d: -f1 || true)" + if [ -n "$line" ]; then + head -n $((line - 1)) CHANGELOG.md > "$tmp" + printf '%s' "$inherited" >> "$tmp" + tail -n +"$line" CHANGELOG.md >> "$tmp" + else + cat CHANGELOG.md > "$tmp" + printf '\n%s' "$inherited" >> "$tmp" + fi + cat "$tmp" > CHANGELOG.md + rm -f "$tmp" + resolved+=("Herdr release notes grafted from the tagged upstream CHANGELOG: $grafted_list") + fi + + based="Based on Herdr $new_base (release notes: https://github.com/herdrdev/herdr/releases/tag/$tag)." + new_heading="## [$new_version] - $(date -u +%Y-%m-%d)" + tmp="$(mktemp)" + first="$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1 || true)" + if [ -n "$first" ]; then + heading_line="$(sed -n "${first}p" CHANGELOG.md)" + heading_version="$(printf '%s' "$heading_line" | sed 's/^## \[//; s/\].*//')" + case "$heading_line" in + *"Unreleased"*) + if [ "$heading_version" != "$old_base.$old_rev" ]; then + # The pending section was never released; it becomes + # this sync's release, keeping its entries. + { printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +$((first + 1)) CHANGELOG.md; } > "$tmp" + else + { head -n $((first - 1)) CHANGELOG.md; printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +"$first" CHANGELOG.md; } > "$tmp" + fi + ;; + *) + { head -n $((first - 1)) CHANGELOG.md; printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +"$first" CHANGELOG.md; } > "$tmp" + ;; + esac + else + { cat CHANGELOG.md; printf '\n%s\n\n%s\n\n' "$new_heading" "$based"; } > "$tmp" + fi + cat "$tmp" > CHANGELOG.md + rm -f "$tmp" + resolved+=("dated Superherdr section $new_heading") + fi + git add CHANGELOG.md + fi +fi + conflicts="$(git diff --name-only --diff-filter=U)" added="$(git diff --cached --name-only --diff-filter=A HEAD || true)" @@ -121,10 +289,24 @@ write_report() { echo echo "Upstream release: https://github.com/herdrdev/herdr/releases/tag/$tag" echo - if [ -n "$conflicts" ]; then + if [ -n "$conflicts" ] || [ "${#unresolved[@]}" -gt 0 ]; then echo "### Conflicts to resolve" echo - echo "$conflicts" | sed 's/^/- `/; s/$/`/' + if [ -n "$conflicts" ]; then + echo "$conflicts" | sed 's/^/- `/; s/$/`/' + echo + fi + if [ "${#unresolved[@]}" -gt 0 ]; then + printf -- '- %s\n' "${unresolved[@]}" + echo + fi + fi + if [ "${#resolved[@]}" -gt 0 ]; then + echo "### Resolved automatically" + echo + printf -- '- %s\n' "${resolved[@]}" + echo + echo "Superherdr version after merge: $new_version" echo fi if [ "${#dropped[@]}" -gt 0 ]; then @@ -136,7 +318,13 @@ write_report() { if [ "${#ported[@]}" -gt 0 ]; then echo "### Upstream changes to Superherdr-owned files (review and port by hand)" echo - printf -- '- `%s`\n' "${ported[@]}" + for path in "${ported[@]}"; do + if [ "$path" = "CHANGELOG.md" ] && [ "$graft_ran" -eq 1 ]; then + echo "- \`CHANGELOG.md\` (inherited sections grafted automatically)" + else + printf -- '- `%s`\n' "$path" + fi + done echo echo "Inspect with \`git diff $base $upstream_commit -- \`." echo @@ -154,7 +342,7 @@ if [ -n "$report" ]; then fi write_report -if [ -n "$conflicts" ]; then +if [ -n "$conflicts" ] || [ "${#unresolved[@]}" -gt 0 ]; then echo "Conflicts remain. Resolve them, run 'git add', then 'git commit'." >&2 exit 2 fi diff --git a/scripts/test_sync_upstream.py b/scripts/test_sync_upstream.py index 2acc927b..2825be95 100644 --- a/scripts/test_sync_upstream.py +++ b/scripts/test_sync_upstream.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import subprocess import tempfile import unittest @@ -31,6 +32,8 @@ def setUp(self) -> None: root = Path(self.temp.name) self.upstream = root / "upstream" self.fork = root / "fork" + self.stub_bin = root / "stub-bin" + self.cargo_log = root / "cargo.log" self.upstream.mkdir() git(self.upstream, "init", "-q", "-b", "master") self._identity(self.upstream) @@ -42,6 +45,19 @@ def setUp(self) -> None: git(self.upstream, "commit", "-q", "-m", "base") git(self.upstream, "tag", "v1.0.0") + self._build_fork() + self._write_cargo_stub() + + def tearDown(self) -> None: + self.temp.cleanup() + + def _identity(self, repo: Path) -> None: + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.com") + git(repo, "config", "commit.gpgsign", "false") + + def _build_fork(self) -> None: + root = Path(self.temp.name) git(root, "clone", "-q", str(self.upstream), str(self.fork)) self._identity(self.fork) git(self.fork, "rm", "-q", "SPONSORS.md") @@ -55,13 +71,41 @@ def setUp(self) -> None: git(self.fork, "commit", "-q", "-m", "fork") git(self.fork, "tag", "-d", "v1.0.0") - def tearDown(self) -> None: - self.temp.cleanup() + def _with_superherdr_files(self) -> None: + """Give both sides packaging files, in the merge base, like the real fork. - def _identity(self, repo: Path) -> None: - git(repo, "config", "user.name", "Test") - git(repo, "config", "user.email", "test@example.com") - git(repo, "config", "commit.gpgsign", "false") + The upstream v1.0.0 commit is amended so Cargo.toml and friends are in + the merge base, then the fork is rebuilt with its Superherdr variants. + """ + write(self.upstream, "Cargo.toml", '[package]\nname = "herdr"\nversion = "1.0.0"\nedition = "2021"\n\n[dependencies]\na = "1"\n') + write(self.upstream, "Cargo.lock", '[[package]]\nname = "herdr"\nversion = "1.0.0"\n') + write(self.upstream, "src/build_info.rs", 'pub const BASE_VERSION: &str = "1.0.0";\n') + write(self.upstream, "CHANGELOG.md", "## Unreleased\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n- base fix\n") + git(self.upstream, "add", "-A") + git(self.upstream, "commit", "-q", "--amend", "-m", "base") + git(self.upstream, "tag", "-f", "v1.0.0") + + shutil.rmtree(self.fork) + self._build_fork() + write(self.fork, "Cargo.toml", '[package]\nname = "superherdr"\n# Herdr base version; the Superherdr revision lives in src/build_info.rs.\nversion = "1.0.0"\nedition = "2021"\n\n[dependencies]\na = "1"\n') + write(self.fork, "Cargo.lock", '[[package]]\nname = "superherdr"\nversion = "1.0.0"\n') + write(self.fork, "src/build_info.rs", "pub const SUPERHERDR_REVISION: u32 = 3;\n") + write(self.fork, "CHANGELOG.md", "Fork changelog intro.\n\n## [1.0.0.3] - 2026-01-02\n\n### Fixed\n- fork fix\n\n### Inherited from Herdr 1.0.0\n\nFixed:\n- base fix\n") + write(self.fork, ".upstream-sync/ours", "README.md\nCHANGELOG.md\n") + git(self.fork, "add", "-A") + git(self.fork, "commit", "-q", "-m", "fork superherdr files") + + def _write_cargo_stub(self) -> None: + """Stub cargo: log its arguments, re-lock the fork's package name.""" + self.stub_bin.mkdir() + stub = self.stub_bin / "cargo" + stub.write_text( + "#!/bin/sh\n" + 'printf \'%s\\n\' "$*" >> "$CARGO_STUB_LOG"\n' + 'sed "s/name = \\"herdr\\"/name = \\"superherdr\\"/" Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock\n', + encoding="utf-8", + ) + stub.chmod(0o755) def _release(self, tag: str, files: dict[str, str]) -> None: for rel, text in files.items(): @@ -72,6 +116,9 @@ def _release(self, tag: str, files: dict[str, str]) -> None: def _sync(self, *args: str) -> subprocess.CompletedProcess[str]: env = {**os.environ, "SUPERHERDR_UPSTREAM_URL": str(self.upstream)} + if self.stub_bin.exists(): + env["PATH"] = f"{self.stub_bin}:{env['PATH']}" + env["CARGO_STUB_LOG"] = str(self.cargo_log) return subprocess.run( ["scripts/sync_upstream.sh", *args], cwd=self.fork, @@ -144,6 +191,113 @@ def test_latest_picks_highest_semver_release(self) -> None: self.assertEqual(self._sync("--latest").stdout.strip(), "v1.10.0") + def _release_superherdr_version(self, tag: str, changelog: str) -> None: + self._release( + tag, + { + "Cargo.toml": f'[package]\nname = "herdr"\nversion = "{tag[1:]}"\nedition = "2021"\n\n[dependencies]\na = "1"\nb = "2"\n', + "Cargo.lock": f'[[package]]\nname = "herdr"\nversion = "{tag[1:]}"\n', + "CHANGELOG.md": changelog, + }, + ) + + def test_release_bookkeeping_is_mechanical(self) -> None: + self._with_superherdr_files() + self._release_superherdr_version( + "v1.1.0", + "## [1.1.0] - 2026-02-01\n\n### Breaking Changes\n- See the [guide](https://x.test/guide) for details.\n\n**Note.** things changed.\n\n## [1.0.1] - 2026-01-15\n\n### Fixed\n- patch fix\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n- base fix\n", + ) + + result = self._sync("v1.1.0") + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertEqual(git(self.fork, "log", "-1", "--format=%s"), "chore(upstream): merge herdr v1.1.0") + toml = (self.fork / "Cargo.toml").read_text() + self.assertIn('name = "superherdr"', toml) + self.assertIn("# Herdr base version", toml) + self.assertIn('version = "1.1.0"', toml) + self.assertIn('b = "2"', toml) + self.assertNotIn("<<<<<<<", toml) + self.assertIn("update --workspace", self.cargo_log.read_text()) + self.assertIn( + "SUPERHERDR_REVISION: u32 = 1", + (self.fork / "src/build_info.rs").read_text(), + ) + changelog = (self.fork / "CHANGELOG.md").read_text() + self.assertRegex(changelog, r"## \[1\.1\.0\.1\] - \d{4}-\d{2}-\d{2}") + self.assertLess(changelog.index("## [1.1.0.1]"), changelog.index("## [1.0.0.3]")) + self.assertIn("Based on Herdr 1.1.0", changelog) + self.assertLess( + changelog.index("### Inherited from Herdr 1.1.0"), + changelog.index("### Inherited from Herdr 1.0.1"), + ) + self.assertLess( + changelog.index("### Inherited from Herdr 1.0.1"), + changelog.index("### Inherited from Herdr 1.0.0"), + ) + section = changelog[changelog.index("### Inherited from Herdr 1.1.0") : changelog.index("### Inherited from Herdr 1.0.1")] + self.assertIn("Breaking changes:", section) + self.assertIn("guide", section) + self.assertNotIn("](", section) + self.assertNotIn("**", section) + self.assertNotIn("### ", section.split("\n", 1)[1]) + + def test_unreleased_section_becomes_the_release(self) -> None: + self._with_superherdr_files() + write(self.fork, "CHANGELOG.md", "Fork changelog intro.\n\n## [1.0.0.4] - Unreleased\n\n### Added\n- feature\n\n### Inherited from Herdr 1.0.0\n\nFixed:\n- base fix\n") + git(self.fork, "add", "-A") + git(self.fork, "commit", "-q", "-m", "pending release") + self._release_superherdr_version( + "v1.1.0", + "## [1.1.0] - 2026-02-01\n\n### Fixed\n- new fix\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n- base fix\n", + ) + + result = self._sync("v1.1.0") + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + changelog = (self.fork / "CHANGELOG.md").read_text() + self.assertRegex(changelog, r"## \[1\.1\.0\.1\] - \d{4}-\d{2}-\d{2}") + self.assertNotIn("Unreleased", changelog) + self.assertIn("- feature", changelog) + self.assertLess( + changelog.index("- feature"), + changelog.index("### Inherited from Herdr 1.1.0"), + ) + + def test_missing_upstream_notes_leave_a_draft(self) -> None: + self._with_superherdr_files() + self._release_superherdr_version( + "v1.1.0", + "## [1.1.0] - 2026-02-01\n\n### Fixed\n- new fix\n", + ) + + result = self._sync("v1.1.0") + + self.assertEqual(result.returncode, 2, result.stderr + result.stdout) + self.assertIn("CHANGELOG.md", result.stdout) + self.assertNotIn("<<<<<<<", (self.fork / "Cargo.toml").read_text()) + + def test_source_conflict_still_resolves_cargo(self) -> None: + self._with_superherdr_files() + self._release_superherdr_version( + "v1.2.0", + "## [1.2.0] - 2026-03-01\n\n### Fixed\n- fix\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n- base fix\n", + ) + write(self.upstream, "src/shared.rs", "upstream line\n") + git(self.upstream, "add", "-A") + git(self.upstream, "commit", "-q", "--amend", "-m", "v1.2.0") + git(self.upstream, "tag", "-f", "v1.2.0") + + result = self._sync("v1.2.0", "--report", "report.md") + + self.assertEqual(result.returncode, 2, result.stderr + result.stdout) + self.assertIn("src/shared.rs", (self.fork / "report.md").read_text()) + self.assertNotIn("<<<<<<<", (self.fork / "Cargo.toml").read_text()) + + self.assertEqual(result.returncode, 2, result.stderr + result.stdout) + self.assertIn("src/shared.rs", (self.fork / "report.md").read_text()) + self.assertNotIn("<<<<<<<", (self.fork / "Cargo.toml").read_text()) + if __name__ == "__main__": unittest.main() From a0956c3a9dfbb07597f4fd2026601eed829d7eaf Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:48:10 -0700 Subject: [PATCH 3/4] docs: describe mechanical upstream sync --- AGENTS.md | 4 ++-- CONTRIBUTING.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8df9f1e3..15814762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ The client endpoint generation is independent from the private same-install prot Superherdr merges upstream Herdr releases with `just sync-upstream `, and a daily workflow opens a sync pull request when Herdr publishes a new release tag. The script applies `.upstream-sync/exclude` (paths removed from the fork on purpose, which must not come back) and `.upstream-sync/ours` (files Superherdr rewrote, whose upstream changes are shown for manual porting). CI runs `scripts/sync_upstream.sh --check` to reject reintroduced exclusions. Resolve genuine conflicts case by case. Merge sync pull requests with a merge commit, never squash or rebase, so the upstream history stays an ancestor and later syncs do not re-conflict. Never push upstream tags to this repository. -Every upstream release is expected to conflict in `Cargo.toml` and `Cargo.lock`, because both sides change the package name and version. Keep Superherdr's `name` and `version`, take upstream's dependency changes, then run `cargo update -p superherdr --offline` to regenerate the lock entry. +`just sync-upstream` resolves the `Cargo.toml` and `Cargo.lock` conflicts mechanically: it keeps Superherdr's `name`, takes upstream's `version` and dependency changes, regenerates the lock with `cargo update --workspace`, resets `SUPERHERDR_REVISION` to 1, grafts the upstream release notes, and adds the dated `## [.1]` section. Any other conflict leaves a draft pull request. - Prefer keeping inherited code, even when Superherdr does not use it, so upstream merges stay simple. Guard it rather than deleting it. - Keep Superherdr-specific changes in clearly separated modules or small, well-named edits to inherited files. @@ -114,7 +114,7 @@ When updating libghostty-vt, check every active patch. If the new upstream commi Superherdr-specific user documentation lives in `README.md` and `docs/next/`. Inherited behavior is documented by [Herdr's documentation](https://herdr.dev/docs/); do not copy it here. Update documentation in the same pull request as a user-facing change. `skills/herdr/SKILL.md` describes the CLI for agents; it is inherited from Herdr; keep it accurate when commands, IDs, or agent lifecycle semantics change. -Add user-facing entries to `CHANGELOG.md` under an Unreleased heading when a change is user-visible. Do not add entries for documentation-only, CI, or repository-maintenance changes. +Add user-facing entries to `CHANGELOG.md` under `## [] - Unreleased` when a change is user-visible. Do not add entries for documentation-only, CI, or repository-maintenance changes. Put local planning notes under `.local/`, which is ignored. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe9a3c54..ad3de8ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr), a terminal r Most of Superherdr's runtime comes from Herdr, and upstream releases are merged regularly. If a bug also exists in Herdr and is not caused by Superherdr's changes, consider reporting it [upstream](https://github.com/herdrdev/herdr) so both projects benefit. Superherdr-specific behavior (Focus, Snooze, the Top-level Agents view, installation, and compatibility rules) belongs here. See the [behavior baseline](openspec/README.md). -Every upstream sync must graft the merged release bodies into `CHANGELOG.md` as `### Inherited from Herdr ` sections at the end of the file, newest first. The What's New view renders this file in installed builds, so a skipped graft leaves upgraded installs without the new release's notes. Keep each section free of `##`-level headings, `####` headings, emphasis markers, and markdown links: the release-notes renderer styles only `###` headings and renders everything else literally. The full-history tests fail when the newest inherited section is missing, which is the reminder for this step. +Every upstream sync grafts the merged release notes into `CHANGELOG.md` as `### Inherited from Herdr ` sections at the end of the file, newest first; `just sync-upstream` does this from upstream's tagged `CHANGELOG.md`. The What's New view renders this file in installed builds, so a skipped graft leaves upgraded installs without the new release's notes. Keep each section free of `##`-level headings, `####` headings, emphasis markers, and markdown links: the release-notes renderer styles only `###` headings and renders everything else literally. The full-history tests fail when the newest inherited section is not the Herdr base version or the current version's heading is undated, which is the reminder for this step. ## Choose the contribution path From c0ef55d8cf01f9dc47783871264ec9c2285a659d Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:55:34 -0700 Subject: [PATCH 4/4] fix(sync): keep the changelog intro when releasing an unreleased section --- scripts/sync_upstream.sh | 2 +- scripts/test_sync_upstream.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/sync_upstream.sh b/scripts/sync_upstream.sh index a1d23193..0c1e57e1 100755 --- a/scripts/sync_upstream.sh +++ b/scripts/sync_upstream.sh @@ -261,7 +261,7 @@ if git cat-file -e HEAD:Cargo.toml 2>/dev/null; then if [ "$heading_version" != "$old_base.$old_rev" ]; then # The pending section was never released; it becomes # this sync's release, keeping its entries. - { printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +$((first + 1)) CHANGELOG.md; } > "$tmp" + { head -n $((first - 1)) CHANGELOG.md; printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +$((first + 1)) CHANGELOG.md; } > "$tmp" else { head -n $((first - 1)) CHANGELOG.md; printf '%s\n\n%s\n\n' "$new_heading" "$based"; tail -n +"$first" CHANGELOG.md; } > "$tmp" fi diff --git a/scripts/test_sync_upstream.py b/scripts/test_sync_upstream.py index 2825be95..312396ef 100644 --- a/scripts/test_sync_upstream.py +++ b/scripts/test_sync_upstream.py @@ -256,6 +256,7 @@ def test_unreleased_section_becomes_the_release(self) -> None: self.assertEqual(result.returncode, 0, result.stderr + result.stdout) changelog = (self.fork / "CHANGELOG.md").read_text() + self.assertIn("Fork changelog intro.", changelog) self.assertRegex(changelog, r"## \[1\.1\.0\.1\] - \d{4}-\d{2}-\d{2}") self.assertNotIn("Unreleased", changelog) self.assertIn("- feature", changelog)