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:
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 };
}