Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 1fde61eb37430c87ac858c99408edec91d8c757c
parent b6abf2b9fd6381add251a5887332ce54bed03e17
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 22 Jun 2026 17:11:34 -0400

git commit when cutting versions

Diffstat:
Acommon/lib/git.test.ts | 34++++++++++++++++++++++++++++++++++
Acommon/lib/git.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/deploy/components/CutReleaseForm.tsx | 11+++++++++++
Meditor/app/deploy/cutReleaseAction.ts | 45+++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 140 insertions(+), 2 deletions(-)

diff --git a/common/lib/git.test.ts b/common/lib/git.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parsePorcelain } from "./git"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test common/lib/git.test.ts +// (or `node_modules/.bin/tsx --test common/lib/git.test.ts` from the repo root) + +test("parsePorcelain returns empty array for clean tree", () => { + assert.deepEqual(parsePorcelain(""), []); + assert.deepEqual(parsePorcelain("\n"), []); +}); + +test("parsePorcelain reads modified and staged paths", () => { + const out = " M editor/CHANGELOG.md\nM common/lib/git.ts\n"; + assert.deepEqual(parsePorcelain(out), [ + "editor/CHANGELOG.md", + "common/lib/git.ts", + ]); +}); + +test("parsePorcelain reads untracked paths", () => { + assert.deepEqual(parsePorcelain("?? export/new-file.md\n"), [ + "export/new-file.md", + ]); +}); + +test("parsePorcelain returns the post-arrow path for renames", () => { + const out = "R old/name.md -> new/name.md\n"; + assert.deepEqual(parsePorcelain(out), ["new/name.md"]); +}); + +test("parsePorcelain tolerates CRLF line endings", () => { + assert.deepEqual(parsePorcelain(" M a.md\r\n?? b.md\r\n"), ["a.md", "b.md"]); +}); diff --git a/common/lib/git.ts b/common/lib/git.ts @@ -0,0 +1,51 @@ +import { execa } from "execa"; + +/** + * Parse `git status --porcelain` (v1) output into repo-relative paths. + * + * Each line is `XY <path>`, where `XY` is the two-character status code. For + * rename/copy entries (`R`/`C`) the path is `old -> new`; we return the new + * path. Pure so it can be unit-tested without a real repository. + */ +export function parsePorcelain(output: string): string[] { + return output + .split("\n") + .map((line) => line.replace(/\r$/, "")) + .filter((line) => line.length > 0) + .map((line) => { + // Strip the "XY " status prefix (status chars + a single space). + const path = line.slice(3); + const arrow = path.indexOf(" -> "); + return arrow === -1 ? path : path.slice(arrow + 4); + }); +} + +/** Repo-relative paths that differ from HEAD (modified, staged, or untracked). */ +export async function listDirtyPaths(repoRoot: string): Promise<string[]> { + const { stdout } = await execa("git", ["status", "--porcelain"], { + cwd: repoRoot, + }); + return parsePorcelain(stdout); +} + +/** + * Create a path-limited commit of a single file. Committing the path directly + * (`git commit -- <path>`) records only that file's working-tree changes, + * regardless of what else is staged. + */ +export async function commitPath( + repoRoot: string, + relPath: string, + message: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const result = await execa( + "git", + ["commit", "-m", message, "--", relPath], + { cwd: repoRoot, reject: false }, + ); + if (result.exitCode === 0) return { ok: true }; + const error = + (result.stderr || result.stdout || "").trim() || + `git commit exited with code ${result.exitCode}`; + return { ok: false, error }; +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **"Cut release" can now create the release commit for you.** After turning `## [Unreleased]` into a dated semver heading, cutting a release used to leave the changelog edit sitting in your working tree to `git commit` by hand. A **Commit changelog** checkbox now sits next to the **Cut release** button (on `/changelog` for the editor and `/deploy` for the export), checked by default — leave it on and the cut is followed by a path-limited `git commit` of just that one CHANGELOG.md, with the message `Release <workspace> <version>` (e.g. `Release export 0.4.1`). To keep the release commit clean it commits *only* the changelog: if the working tree has any *other* uncommitted change, the cut is refused up front (nothing is written) with an error telling you to commit or stash those first — a dirty changelog itself is fine, so uncommitted `[Unreleased]` bullets get folded into the release commit. Uncheck the box to cut without committing, exactly as before. See `editor/app/deploy/cutReleaseAction.ts`, `editor/app/deploy/components/CutReleaseForm.tsx`, and the new `common/lib/git.ts`. - **"Stop & keep progress" no longer mislabels the paused video as a failed transcription.** Using **Stop & keep progress** on a busy parakeet worker (or any partial-capable engine) sends the engine a graceful SIGTERM so it stops after the current window and the video resumes next run. But if the engine took longer than execa's 5-second force-kill window to exit — which a parakeet window routinely does, since finishing/stitching one ~480s window outlasts 5s — execa force-SIGKILLed it and the resulting "Command was killed with SIGTERM … forcefully terminated after 5000 milliseconds" error escaped the pause handling: it was treated as a genuine transcription failure and the video was written to the channel's `failed-transcriptions` file *permanently* (so even though its completed windows were cached for resume, it was skipped as "failed" on every later run). The transcribe path now recognizes that a force-killed **requested pause** is still a pause, not a failure — it returns the `paused` outcome (a skip, not a failure), so nothing lands in `failed-transcriptions` and the next "Transcribe missing" resumes it from the cached windows. Hard **Cancel** and **Drain** were never affected (their abort signal already classifies the kill as a skip). A video wrongly blacklisted by the old behavior won't auto-prune (it has real audio) — clear it with the channel's **Clear failed transcriptions** action to retry. See `common/controller/transcribeOne.ts`. - **New Auto-queue: automatically transcribe (and download) across all channels by a configurable priority policy, instead of running one channel batch at a time.** Previously the only way to process pending work was to manually fire a per-channel batch (e.g. *Transcribe missing* on one channel), and since every transcription job serialized on a single queue, a batch ran to completion before any other channel got a turn — there was no way to say "do cornbreadman first, then fall back to hasanabi." The new **Auto-queue** page (under Pool → Auto-queue) adds two always-on runners, **auto-transcribe** and **auto-download**, each driven by a **policy tree**: order rules top-to-bottom for **strict** priority, or wrap rules in a group set to **round-robin** or **weighted-fair** (smooth weighted round-robin) to *alternate* between rulesets. A rule (leaf) matches a **channel**, a whole **platform**, or **all** channels, optionally narrowed to a snapshot **bucket** (e.g. prioritize `failedListed` retries over fresh `downloadedNoTranscript`), and any rule or group can carry a **max-workers** cap (a saturated subtree falls through to the next-priority sibling, like an HTB ceil). The highest-priority channel with available work claims the **next freed worker slot** — non-destructive, so a higher-priority video never kills an in-flight transcription, it just wins the next slot; when a channel's work runs out the runner falls back automatically. Transcription concurrency is bounded by the worker pool's eligible slots (so policy decides *which* video runs, the pool decides *how many*); downloads have no pool, so the runner gates to **one download per platform at a time**, matching the per-platform serial queue's politeness. Each runner is a real, drainable/cancellable job (visible on the Jobs pages), and the Auto-queue page shows live per-rule pending counts and a recent-pick log. Independent of the sync **Schedule** (which only decides *when* to re-fetch a channel) — manual batches keep working alongside it. Policies live in `settings.json` under `autoQueue` (defensively sanitized like `syncScheduler`); fairness cursors persist in `transcripts/.auto-queue/state.json`. See `common/jobs/autoQueuePolicy.ts` (pure selection engine + unit tests), `common/controller/autoRunner.ts`, `common/controller/transcribeOneFromQueue.ts` (shared per-video gating, also used by the existing whisper batch), and `editor/app/auto-queue/*`. - **Auto-queue refinements:** (1) the auto-transcribe runner now honors disabled workers — it no longer used CPU workers you'd turned off via Workers → *Set as default*. The runner started at boot and called the worker pool's `reconfigure()` before anything triggered the pool's lazy init, which set the pool's `initialized` flag *without* applying the saved `.worker-defaults.json` arrangement, so disabled workers came back enabled. The runner no longer pre-empts that init (it relies on the pool's own first-use init, which applies both settings and the saved default). (2) The runners no longer show up under a generic **"Other"** group on the **Active Jobs** screen — channel-less jobs are now grouped into their own labeled sections ("Auto-transcribe" / "Auto-download") via a shared `jobKindLabel` map (also used for friendlier kind labels in the running-jobs list), and each in-flight item is labeled `channel/videoId` so you can see which channel it's on. See `common/controller/autoRunner.ts`, `editor/app/jobs/jobKindLabels.ts`, and `editor/app/jobs/components/ActiveJobsLive.tsx`. (3) The Auto-queue page now has a **Drain** control (graceful stop: finish in-flight items, start no new ones, then stop) alongside **Stop** (hard stop, aborts in-flight); both the **Drain** and **Cancel** buttons on the **Active Jobs** screen also act on the runner jobs. diff --git a/editor/app/deploy/components/CutReleaseForm.tsx b/editor/app/deploy/components/CutReleaseForm.tsx @@ -53,6 +53,16 @@ export function CutReleaseForm({ > {pending ? "Cutting…" : "Cut release"} </button> + <label className="flex items-center gap-1 text-sm text-zinc-600 dark:text-zinc-300"> + <input + type="checkbox" + name="commit" + value="true" + defaultChecked + disabled={!hasUnreleased || pending} + /> + <span>Commit changelog</span> + </label> </div> <div id={`hint-${workspace}`} className="text-xs text-zinc-500"> {hasUnreleased @@ -73,6 +83,7 @@ export function CutReleaseForm({ className="text-sm text-emerald-700 dark:text-emerald-300" > Cut release {state.version}. + {state.committed ? " Committed." : " (not committed)"} </div> )} </form> diff --git a/editor/app/deploy/cutReleaseAction.ts b/editor/app/deploy/cutReleaseAction.ts @@ -8,9 +8,10 @@ import { cutRelease, CutReleaseError, } from "yt-dlp-transcript-common/lib/changelog"; +import { commitPath, listDirtyPaths } from "yt-dlp-transcript-common/lib/git"; export type CutReleaseState = - | { ok: true; version: string } + | { ok: true; version: string; committed: boolean } | { ok: false; error: string }; function changelogPathFor(workspace: "editor" | "export"): string { @@ -33,19 +34,46 @@ export async function cutReleaseAction( ): Promise<CutReleaseState> { const workspaceRaw = formData.get("workspace"); const versionRaw = formData.get("version"); + const commitRaw = formData.get("commit"); const workspace = workspaceRaw === "editor" || workspaceRaw === "export" ? workspaceRaw : null; const version = typeof versionRaw === "string" ? versionRaw.trim() : ""; + const shouldCommit = commitRaw === "true" || commitRaw === "on"; if (!workspace) { return { ok: false, error: "Invalid workspace." }; } if (!version) { return { ok: false, error: "Version is required." }; } + const paths = getPaths(); const filePath = changelogPathFor(workspace); + const relPath = path.relative(paths.monorepoRoot, filePath); + // Before mutating anything, refuse to commit over an otherwise-dirty tree so + // the release commit contains only the changelog. A dirty changelog itself is + // fine (uncommitted [Unreleased] bullets get folded into the release commit). + if (shouldCommit) { + let dirty: string[]; + try { + dirty = await listDirtyPaths(paths.monorepoRoot); + } catch (err) { + return { + ok: false, + error: `Could not check git status: ${(err as Error).message}`, + }; + } + const others = dirty.filter((p) => p !== relPath); + if (others.length > 0) { + return { + ok: false, + error: `Other uncommitted changes present (${others.join( + ", ", + )}). Commit or stash them before cutting a release.`, + }; + } + } let source: string; try { source = await fs.promises.readFile(filePath, "utf8"); @@ -67,8 +95,21 @@ export async function cutReleaseAction( const tmp = `${filePath}.tmp-${process.pid}`; await fs.promises.writeFile(tmp, next); await fs.promises.rename(tmp, filePath); + if (shouldCommit) { + const result = await commitPath( + paths.monorepoRoot, + relPath, + `Release ${workspace} ${version}`, + ); + if (!result.ok) { + return { + ok: false, + error: `Cut release ${version}, but the commit failed: ${result.error}`, + }; + } + } revalidatePath("/"); revalidatePath("/changelog"); revalidatePath("/deploy"); - return { ok: true, version }; + return { ok: true, version, committed: shouldCommit }; }