From 25e5be48a8bf75e013c9fcef7b17571b1417554b Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 00:09:07 +0900 Subject: [PATCH 1/2] basou: Count a board's behind against origin's default branch, name a record by its own time, and say how to hand over a manual observation and write ids in full - board measure: behind_main counts against the branch refs/remotes/origin/HEAD names, and against origin/main when it is not set. A repository whose default branch is master never had a count and the measurement still said it was complete. When origin/HEAD names a branch that is not there, the value is not measured and not_found says why. The repos method is 2. - board record: the record's ULID carries recorded_at, so the record list and the guide give the same time as the record's heading. - board guide: ask for a manual observation as one thing the operator can do as written, and when the operator says to make it, make it and say so in the source. Ask for decision and task ids in full, in the prose and in a milestone's ref; init's comment on milestones says so too. --- CHANGELOG.md | 34 +++++++ packages/cli/src/commands/board.test.ts | 2 +- packages/cli/src/commands/board.ts | 4 +- packages/cli/src/commands/view.test.ts | 2 +- packages/cli/src/lib/board-ui.ts | 2 +- packages/core/src/board/guide.test.ts | 28 +++++- packages/core/src/board/guide.ts | 8 +- packages/core/src/board/live.test.ts | 2 +- packages/core/src/board/measure.test.ts | 54 ++++++++++- packages/core/src/board/measure.ts | 2 +- packages/core/src/board/record.test.ts | 18 +++- packages/core/src/board/record.ts | 9 +- packages/core/src/board/repos.ts | 113 ++++++++++++++++-------- packages/core/src/lib/view-strings.ts | 8 +- 14 files changed, 225 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc51c672..2018c4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Fixed + +- **`basou board measure` (experimental) counts how far HEAD is behind + origin's default branch, not always `origin/main`.** It read only + `refs/remotes/origin/main`, so a repository whose default branch is + `master`, or any other name, never had a `behind_main`, and the + measurement still said it was complete, as it does for a repository with + no origin. It now counts against the branch `refs/remotes/origin/HEAD` + names (a clone sets it, and `git remote set-head origin --auto` sets it + later), and against `origin/main` when that is not set, as before. When + `origin/HEAD` names a branch that is not there, the value is not measured + and `not_found` says why. The `repos` method is now 2, so the next record + marks `behind_main` as measured another way. `board measure` and the + board page say "behind origin's default branch". +- **`basou board record` (experimental) names a record by its own time.** + The record's ULID took the time it was written, after the board was + measured again, while `recorded_at` took the time the command started, so + the board page's list of records and the guide gave a time a second or so + later than the record's heading. The ULID now carries `recorded_at`. + Records written before keep their names. +- **`basou board guide` (experimental) says how to ask for a manual + observation, and what to write when the operator hands it over.** It said + only to ask the operator and not to make it. It now asks for one thing the + operator can do as it is written (the line to run and where to run it, or + the page to open, and what to send back), and, when the operator says to + make it, to make it and say so in the source. +- **`basou board guide` and `basou board init` (experimental) ask for + decision and task ids in full.** A shortened id in a board's declaration + matched two decisions, the first of them voided. The guide asks for all + 26 characters in the prose and in a milestone's `ref`, and the comment + `init` prints on milestones says so. + ## 0.70.0 — 2026-10-16 ### Changed diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index dbf12dde..3fb83f59 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -273,7 +273,7 @@ describe("basou board measure", () => { const unborn = out.join("\n"); expect(unborn).toMatch( new RegExp( - `Repos:\\n \\. \\(${name}\\) main at no commit yet, last commit none, commits 0, files \\d+, uncommitted \\d+, behind origin/main \\(no origin/main\\)\\n \\.\\./gone not measured\\n`, + `Repos:\\n \\. \\(${name}\\) main at no commit yet, last commit none, commits 0, files \\d+, uncommitted \\d+, behind origin's default branch \\(no origin/HEAD or origin/main\\)\\n \\.\\./gone not measured\\n`, ), ); expect(unborn).toContain("repos[../gone]: the repo '../gone' is not on disk"); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 7974c219..c6bab4c2 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1090,7 +1090,7 @@ function shownValue(value: number | string | null): string { // One line per repository. A null the not_found entries do not explain has // the meaning the measurement gives it (a detached HEAD, no commit yet, no -// origin/main). +// origin/HEAD or origin/main). function repoLines(m: BoardMeasurement): string[] { const missing = new Set(m.not_found.map((n) => n.at)); return m.repos.map((repo) => { @@ -1110,7 +1110,7 @@ function repoLines(m: BoardMeasurement): string[] { `commits ${measured("commits", String) ?? "not measured"}`, `files ${measured("files", String) ?? "not measured"}`, `uncommitted ${measured("uncommitted", String) ?? "not measured"}`, - `behind origin/main ${measured("behind_main", String) ?? "(no origin/main)"}`, + `behind origin's default branch ${measured("behind_main", String) ?? "(no origin/HEAD or origin/main)"}`, ]; return ` ${displayPath(repo.path)}${name} ${parts.join(", ")}`; }); diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 0b960ca2..0bc85454 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1910,7 +1910,7 @@ describe("basou view: the board page", () => { const repos = call("liveRepos", LIVE); const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); - // Nulls that mean so: a detached HEAD, no origin/main, a last commit unread. + // Nulls that mean so: a detached HEAD, no origin/HEAD or origin/main, a last commit unread. expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]); // Nulls the measurement could not measure are said to be. const unmeasured = { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index d27b8227..ee7221ea 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -766,7 +766,7 @@ export const BOARD_HTML = ` var rows = l.repos.map(function (r) { var at = 'repos[' + r.path + ']'; // A null the measurement says it could not measure, or one that means - // so (a detached HEAD, no commit yet, no origin/main), drawn as '-'. + // so (a detached HEAD, no commit yet, no origin/HEAD or origin/main), drawn as '-'. var value = function (field, v, show) { if (v !== null) return show(v); return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-'; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index d57d3e72..f9813600 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -286,6 +286,32 @@ describe("boardGuide: a board declared", () => { ); }); + it("says how to ask for a manual observation, and what the source says when the operator hands it over", () => { + const guide = boardGuide(guideInput()); + expect(guide).toContain( + "Ask for one thing they can do as it is written: the line to run and where to run it, or the page to open, and what to send back.", + ); + expect(guide).toContain( + "When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`.", + ); + }); + + it("asks for decision and task ids in full, in the prose and in a milestone's ref", () => { + const guide = boardGuide(guideInput()); + expect(guide).toContain( + "Write a decision or task id in full, all 26 characters after `decision_` or `task_`: basou prints some of them short, and a short one can match more than one, a voided decision among them.", + ); + expect(guide).toContain( + "with `ref` saying where it can be checked (a tag and its commit, or a decision id in full).", + ); + }); + + it("says which branch behind_main counts against", () => { + expect(boardGuide(guideInput())).toContain( + "- `repos[].behind_main` counts the commits on origin's default branch (the branch origin/HEAD names, or origin/main when origin/HEAD is not set) as of the last fetch; measure does not fetch.", + ); + }); + it("leaves the model to be named, in the commands and the template, so neither runs as printed", () => { const guide = boardGuide(guideInput()); const blocks = blocksIn(guide).join("\n"); @@ -553,7 +579,7 @@ describe("boardGuide: an observation not made, the milestones, isolation and wha }), ); const holds = - "under effort.milestones, each `{ date, label, ref }`: only what was reached (a release, a launch), with `ref` saying where it can be checked (a tag and its commit). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml."; + "under effort.milestones, each `{ date, label, ref }`: only what was reached (a release, a launch), with `ref` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml."; expect(boardGuide(guideInput())).toContain(`- Milestones: none declared ${holds}`); expect(boardGuide(guideInput())).toContain( "- Approving a milestone before it is added to board.yaml.", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index ac70b063..10adbce9 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -123,7 +123,7 @@ export function boardGuide(input: BoardGuideInput): string { line(`- Records: board/records/, ${recordsLine(input, recordCount)}`); line(`- Axis: ${axisLine(input)}`); line( - `- Milestones: ${milestonesLine(d)} under effort.milestones, each \`{ date, label, ref }\`: only what was reached (a release, a launch), with \`ref\` saying where it can be checked (a tag and its commit). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml.`, + `- Milestones: ${milestonesLine(d)} under effort.milestones, each \`{ date, label, ref }\`: only what was reached (a release, a launch), with \`ref\` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml.`, ); line( `- Language: write every prose string and reason in ${LANGUAGE_NAMES[input.language]}, the language this board's page is drawn in (Japanese when the manifest declares ja for this repo, English otherwise)`, @@ -189,7 +189,7 @@ export function boardGuide(input: BoardGuideInput): string { "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", - "- `repos[].behind_main` counts from the last fetch; measure does not fetch.", + "- `repos[].behind_main` counts the commits on origin's default branch (the branch origin/HEAD names, or origin/main when origin/HEAD is not set) as of the last fetch; measure does not fetch.", "", ); @@ -245,7 +245,7 @@ export function boardGuide(input: BoardGuideInput): string { "- `measure_digest`: the `digest` of `$W/measure.json`.", '- `observed`: when the board declares observations, one entry per declared observation, under its key, each as step 3 says; nothing else and nothing left out, or record refuses it. An observation made has no `error`; one not made has `"value": null`, an `error` and the `observed_at` of when it was tried. The template leaves each `observed_at` empty: fill every one. With none declared, `observed` may stay empty.', "- `cells`: every lane at every stage. Set each `state` and delete each `previous`: record refuses a cell with an empty state or a `previous`. `blocked`, `shelved` and `unverified` need a `reason`.", - `- \`prose\`: \`summary\`, \`lanes\`, \`operator_turns\` as \`{ "text", "source" }\` and \`footnotes\`, all plain strings (HTML is shown as text; only \`\` \`code\` \`\` is drawn as code). Do not repeat what basou draws (the heading, the tiles, the period and effort, the matrix, the ratios, the axis version and the model). Do not start a lane's prose with its \`about\`: the page draws the about right before it. Give \`lanes\` an entry only for a lane you have something to say about (an empty string is drawn as an empty paragraph); the lane ids are ${d.lanes.map((l) => `\`${l.id}\``).join(", ")}.`, + `- \`prose\`: \`summary\`, \`lanes\`, \`operator_turns\` as \`{ "text", "source" }\` and \`footnotes\`, all plain strings (HTML is shown as text; only \`\` \`code\` \`\` is drawn as code). Do not repeat what basou draws (the heading, the tiles, the period and effort, the matrix, the ratios, the axis version and the model). Do not start a lane's prose with its \`about\`: the page draws the about right before it. Give \`lanes\` an entry only for a lane you have something to say about (an empty string is drawn as an empty paragraph); the lane ids are ${d.lanes.map((l) => `\`${l.id}\``).join(", ")}. Write a decision or task id in full, all 26 characters after \`decision_\` or \`task_\`: basou prints some of them short, and a short one can match more than one, a voided decision among them.`, '- `axis_review`: null, or `{ "triggers": [...], "summary": "..." }` as step 2b says.', "", ); @@ -515,7 +515,7 @@ function observeSteps( line( `Only the operator can make this one: ${displayPath(o.how)}`, "", - "Ask the operator, and do not make it yourself. With no answer this time, write a null, say so as the error, and give the time you asked as `observed_at`. Source: `the operator`.", + "Ask the operator, and do not make it yourself. Ask for one thing they can do as it is written: the line to run and where to run it, or the page to open, and what to send back. With no answer this time, write a null, say so as the error, and give the time you asked as `observed_at`. Source: `the operator`. When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`.", "", ); break; diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts index 54b19120..80f6e0a5 100644 --- a/packages/core/src/board/live.test.ts +++ b/packages/core/src/board/live.test.ts @@ -121,7 +121,7 @@ describe("measureBoardLive", () => { ].sort(), ); expect(m.methods).toEqual({ - repos: 1, + repos: 2, trail: 2, integrity: 2, review_gaps: 2, diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index daea8a8d..559786c9 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -1679,6 +1679,52 @@ describe("measureBoard: the repos section", () => { expect(m.not_found).toEqual([]); }); + it("counts behind the branch origin/HEAD names, so a default branch of master is measured", async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + git(upstream, "branch", "-m", "main", "master"); + git(root, "clone", "-q", upstream, "app"); + const app = join(root, "app"); + await commit(upstream, { "b.txt": "1\n" }, "upstream moves on"); + await commit(upstream, { "b.txt": "2\n" }, "and on"); + git(app, "fetch", "-q"); + // A main that HEAD already has does not stand in for the default branch. + git(app, "update-ref", "refs/remotes/origin/main", "HEAD"); + expect(git(app, "symbolic-ref", "refs/remotes/origin/HEAD").trim()).toBe( + "refs/remotes/origin/master", + ); + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ branch: "master", behind_main: 2 }); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + }); + + it("has no count and no entry when origin/HEAD is not set and there is no origin/main", async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + git(upstream, "branch", "-m", "main", "master"); + const app = await repo("app", { "a.txt": "1\n" }); + git(app, "fetch", "-q", upstream, "master:refs/remotes/origin/master"); + const m = await measureRepos("app"); + expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + }); + + it("says so when origin/HEAD names a branch that is not there, not falling back to origin/main", async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + git(root, "clone", "-q", upstream, "app"); + const app = join(root, "app"); + git(app, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/gone"); + const m = await measureRepos("app"); + expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.not_found).toEqual([ + { + at: "repos[app].behind_main", + reason: "refs/remotes/origin/HEAD names refs/remotes/origin/gone, which is not there", + }, + ]); + expect(m.complete).toBe(false); + }); + it("reports a repo that is not there, not a repository, or not the root of one, once", async () => { await repo("app", { "sub/a.txt": "1\n" }); await mkdir(join(root, "plain")); @@ -1766,7 +1812,7 @@ describe("measureBoard: the repos section", () => { const declaration = declare([], {}, ["app"]); const first = await measure(declaration, NOW, ["app"]); expect(first.methods).toEqual({ - repos: 1, + repos: 2, trail: 2, integrity: 2, review_gaps: 2, @@ -2883,7 +2929,7 @@ describe("measureBoard: the integrity section", () => { const declaration = declare([]); const first = await measure(declaration); expect(first.methods).toEqual({ - repos: 1, + repos: 2, trail: 2, integrity: 2, review_gaps: 2, @@ -3143,7 +3189,7 @@ describe("measureBoard: the review_gaps section", () => { ); const none = await measure(declaration); expect(none.methods).toEqual({ - repos: 1, + repos: 2, trail: 2, integrity: 2, review_gaps: 2, @@ -3263,7 +3309,7 @@ describe("measureBoard: the portfolio section", () => { await register(`workspaces:\n - { path: ${master} }\n`); const bare = await measure(declaration); expect(bare.methods).toEqual({ - repos: 1, + repos: 2, trail: 2, integrity: 2, review_gaps: 2, diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index ec4f4e73..ad30fe8f 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -78,7 +78,7 @@ export type BoardRatioValue = { * repository, revision or file is never counted as zero. The exceptions are * the nulls that mean something of their own, which have no entry and leave * `complete` as it is: three in the repos section (a detached HEAD, no commit - * yet, no origin/main; see {@link BoardRepo}), the portfolio's when there is + * yet, no origin/HEAD or origin/main; see {@link BoardRepo}), the portfolio's when there is * no portfolio config (see {@link BoardPortfolio}), the newest session's when * there is no session that is not archived (see {@link BoardFreshness}), the * Codex time when there is no Codex session (see {@link BoardEffort}), the diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 0009f6c7..4fbcccf0 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -3,16 +3,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; +import { ulid as monotonicUlid } from "../ids/ulid.js"; import { type BoardDeclaration, parseBoardDeclaration } from "./declaration.js"; import type { BoardMeasurement } from "./measure.js"; import { buildRecord, orderAnomalies, parseRecordInput, writeRecord } from "./record.js"; // Pass-through so one test can make the first name a record already has. -vi.mock("../ids/ulid.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("ulid", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, ulid: vi.fn(actual.ulid) }; }); -const { ulid } = await import("../ids/ulid.js"); +const { decodeTime, ulid } = await import("ulid"); // Pass-through so one test can make a write fail after it has begun. vi.mock("node:fs/promises", async (importOriginal) => { @@ -459,6 +460,17 @@ describe("buildRecord and writeRecord", () => { expect(JSON.parse(await readFile(join(records, name), "utf8"))).toEqual(r); }); + it("names a record by a ULID whose time is its recorded_at, not the time it is written", async () => { + // basou's own ULIDs come from a monotonic factory, which, once it has given + // out a later time, gives that time again for an earlier one. + const later = monotonicUlid(); + expect(decodeTime(later)).toBeGreaterThan(Date.parse("2026-10-07T00:00:00.000Z")); + const name = await writeRecord(join(dir, "records"), record()); + expect(new Date(decodeTime(name.replace(/\.json$/, ""))).toISOString()).toBe( + "2026-10-07T00:00:00.000Z", + ); + }); + it("does not keep the measurement's diff, which the previous record derives", () => { const parsed = parseRecordInput(input(), declaration()); if (!parsed.ok) throw new Error(parsed.errors.join("\n")); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index eee69fc2..bb0ca88d 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import { link, lstat, mkdir, rmdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { ulid } from "ulid"; import { z } from "zod"; -import { ulid } from "../ids/ulid.js"; import { findErrorCode } from "../lib/error-codes.js"; import { BOARD_STAGE_IDS, @@ -377,7 +377,8 @@ export function buildRecord(input: { * goes to a file whose name is not a ULID's, then is linked into place (which * fails rather than replace a record of the same name, when another name is * tried). On a failure nothing is left behind, not even a records directory - * made for it. Returns the name written. + * made for it. The ULID carries the record's `recorded_at` as its time, so + * the name and the record say the same time. Returns the name written. */ export async function writeRecord(recordsDir: string, record: BoardRecord): Promise { const made = await mkdir(recordsDir, { recursive: true }); @@ -389,13 +390,15 @@ export async function writeRecord(recordsDir: string, record: BoardRecord): Prom }); } const tmp = join(recordsDir, `.record-${randomUUID()}.tmp`); + // Not the monotonic factory, which keeps a later time it gave out before. + const at = Date.parse(record.recorded_at); try { await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx", }); for (let attempt = 0; ; attempt++) { - const name = `${ulid()}.json`; + const name = `${ulid(Number.isNaN(at) ? undefined : at)}.json`; try { await link(tmp, join(recordsDir, name)); return name; diff --git a/packages/core/src/board/repos.ts b/packages/core/src/board/repos.ts index 557970d8..15322648 100644 --- a/packages/core/src/board/repos.ts +++ b/packages/core/src/board/repos.ts @@ -17,7 +17,7 @@ import { * the section would change for the same repository, so a difference between * two measurements can be told apart from a change in the repository. */ -export const BOARD_REPOS_METHOD = 1; +export const BOARD_REPOS_METHOD = 2; /** * One repository the manifest declares, measured where it is checked out. @@ -27,7 +27,8 @@ export const BOARD_REPOS_METHOD = 1; * repository could be measured. Three values are `null` with a meaning of * their own and no entry: `branch` when HEAD is detached; `head` and * `last_commit` when the repository has no commit yet (`commits` is then 0); - * `behind_main` when there is no `refs/remotes/origin/main`. + * `behind_main` when `refs/remotes/origin/HEAD` is not set and there is no + * `refs/remotes/origin/main`. */ export type BoardRepo = { /** The path as the manifest writes it. */ @@ -53,7 +54,12 @@ export type BoardRepo = { * submodules, as it does for anyone who runs it there. */ uncommitted: number | null; - /** The commits on `refs/remotes/origin/main` HEAD does not have, as of the last fetch. */ + /** + * The commits on origin's default branch HEAD does not have, as of the last + * fetch: the branch `refs/remotes/origin/HEAD` names (set by a clone, or by + * `git remote set-head origin --auto`), or `refs/remotes/origin/main` when + * it is not set. + */ behind_main: number | null; }; @@ -78,6 +84,8 @@ type Head = type Ref = { kind: "missing" } | { kind: "oid"; oid: string } | { kind: "error"; reason: string }; +const ORIGIN_HEAD = "refs/remotes/origin/HEAD"; + const ORIGIN_MAIN = "refs/remotes/origin/main"; const SHALLOW = "the repo is a shallow clone, so its history is cut short"; @@ -134,6 +142,26 @@ async function readRef(cwd: string, name: string): Promise { return { kind: "missing" }; } +// The full name of origin's default branch: the branch origin/HEAD names, +// or origin/main when origin/HEAD is not a symbolic ref (not set at all, as +// after `git remote add` and a fetch). +async function defaultBranchOf( + cwd: string, +): Promise<{ kind: "name"; name: string } | { kind: "error"; reason: string }> { + const named = await runGit(cwd, ["symbolic-ref", "-q", ORIGIN_HEAD]); + if (named.code === 0) { + const name = utf8Name(lineBytes(named)); + return name === undefined + ? { kind: "error", reason: `the branch ${ORIGIN_HEAD} names is not valid UTF-8` } + : { kind: "name", name }; + } + // 1 is a ref that is not symbolic, or not there. + if (named.code === 1 && named.spawnError === undefined) { + return { kind: "name", name: ORIGIN_MAIN }; + } + return { kind: "error", reason: failure(named, `${ORIGIN_HEAD} could not be read`) }; +} + // HEAD's commit; or that there is none yet, when HEAD names a branch that // does not exist (a new repository, or an orphan branch). async function headOf(cwd: string, symbolic: GitResult): Promise { @@ -329,43 +357,58 @@ export async function measureRepos( if (history !== undefined) fail("behind_main", history); else if (head.kind === "error") fail("behind_main", head.reason); else { - const main = await readRef(cwd, ORIGIN_MAIN); - if (main.kind === "error") fail("behind_main", main.reason); - else if (main.kind === "oid") { - const commit = await runGit(cwd, [ - "rev-parse", - "--verify", - "--quiet", - `${main.oid}^{commit}`, - ]); - if (commit.code !== 0) { - fail("behind_main", await notACommit(cwd, main.oid, partial)); - } else { - const range = head.kind === "unborn" ? line(commit) : `${head.oid}..${line(commit)}`; - const behind = await runGit(cwd, ["rev-list", "--count", range, "--"]); - const n = count(behind); - if (n === undefined) { - fail( - "behind_main", - failure(behind, "the commits behind origin/main could not be counted"), - ); - } else { - repo.behind_main = n; - } - } - } - // A missing origin/main is what the null means. + const behind = await behindDefault(cwd, head, partial); + if (behind.ok) repo.behind_main = behind.count; + else fail("behind_main", behind.reason); } } return result; } -// Why origin/main, which names `oid`, gives no commit. Whether the object is -// there is not asked of a partial clone that this git may fetch it for. -async function notACommit(cwd: string, oid: string, partial: boolean): Promise { - if (partial) return `${ORIGIN_MAIN} is not a commit, or is not in the repository`; +// The commits on origin's default branch HEAD does not have: null when +// origin/HEAD is not set and there is no origin/main, which is what the null +// means; or why they could not be counted. +async function behindDefault( + cwd: string, + head: Exclude, + partial: boolean, +): Promise<{ ok: true; count: number | null } | { ok: false; reason: string }> { + const target = await defaultBranchOf(cwd); + if (target.kind === "error") return { ok: false, reason: target.reason }; + const ref = await readRef(cwd, target.name); + if (ref.kind === "error") return { ok: false, reason: ref.reason }; + if (ref.kind === "missing") { + return target.name === ORIGIN_MAIN + ? { ok: true, count: null } + : { ok: false, reason: `${ORIGIN_HEAD} names ${target.name}, which is not there` }; + } + const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref.oid}^{commit}`]); + if (commit.code !== 0) { + return { ok: false, reason: await notACommit(cwd, target.name, ref.oid, partial) }; + } + const range = head.kind === "unborn" ? line(commit) : `${head.oid}..${line(commit)}`; + const behind = await runGit(cwd, ["rev-list", "--count", range, "--"]); + const n = count(behind); + return n === undefined + ? { + ok: false, + reason: failure(behind, `the commits behind ${target.name} could not be counted`), + } + : { ok: true, count: n }; +} + +// Why the default branch `name`, which names `oid`, gives no commit. Whether +// the object is there is not asked of a partial clone that this git may fetch +// it for. +async function notACommit( + cwd: string, + name: string, + oid: string, + partial: boolean, +): Promise { + if (partial) return `${name} is not a commit, or is not in the repository`; const there = await runGit(cwd, ["cat-file", "-e", oid]); return there.code === 0 - ? `${ORIGIN_MAIN} is not a commit` - : `${ORIGIN_MAIN} points at an object that is not in the repository`; + ? `${name} is not a commit` + : `${name} points at an object that is not in the repository`; } diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 7abe1650..15792138 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1205,7 +1205,7 @@ const BOARD_PAGE_EN: BoardPageStrings = { lastCommit: "Last commit", commits: "Commits", uncommitted: "Uncommitted", - behindMain: "Behind origin/main", + behindMain: "Behind origin's default branch", trail: "Trail", decisions: "Decisions", decisionsAll: "{n} in all, voided ones included", @@ -1374,7 +1374,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { lastCommit: "最終 commit", commits: "commit 数", uncommitted: "未 commit", - behindMain: "origin/main からの遅れ", + behindMain: "origin の既定 branch からの遅れ", trail: "証跡", decisions: "判断", decisionsAll: "取り消したものを含めて {n}", @@ -1460,7 +1460,7 @@ const BOARD_INIT_EN: BoardInitStrings = { timeZone: "This host's time zone has no name a board can declare: write effort.time_zone (such as Asia/Tokyo), or the days are counted in an unnamed zone or not at all.", milestones: - "Days marked on the period and effort chart, each { date: YYYY-MM-DD, label, ref }: only what was reached (a release, a launch), with ref saying where it can be checked (a tag and its commit). A board moved from another keeps its milestones here.", + "Days marked on the period and effort chart, each { date: YYYY-MM-DD, label, ref }: only what was reached (a release, a launch), with ref saying where it can be checked (a tag and its commit, or a decision id in full). A board moved from another keeps its milestones here.", }, }; @@ -1497,7 +1497,7 @@ const BOARD_INIT_JA: BoardInitStrings = { timeZone: "このホストの time zone に、盤に書ける名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は名前の無い zone で数えられるか、測られない。", milestones: - "期間と労力の図に印を付ける日。各 { date: YYYY-MM-DD, label, ref }。到達したもの(出荷・公開)だけを書き、ref に確かめられる出所(tag とその commit)を書く。他の盤から移すときは、その節目をここに移す。", + "期間と労力の図に印を付ける日。各 { date: YYYY-MM-DD, label, ref }。到達したもの(出荷・公開)だけを書き、ref に確かめられる出所(tag とその commit、または完全な decision の ID)を書く。他の盤から移すときは、その節目をここに移す。", }, }; From 1e5f38be4cb5fd978134dfe79008b7a1757341f9 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 00:30:03 +0900 Subject: [PATCH 2/2] basou: Apply the review of the board fixes: an origin/HEAD that names a missing or foreign ref, and records written in the same millisecond - behind_main: tell origin/HEAD naming origin/main from no origin/HEAD, so an origin/HEAD left on a pruned origin/main is not measured with a reason instead of read as the meaningful null. An origin/HEAD naming a ref outside refs/remotes/origin/ is not measured either. The descriptions say "names no branch" (not set, or not a symbolic ref). - Tests for an origin/HEAD git cannot read and one whose name is not valid UTF-8, with an origin/main beside it; the CHANGELOG says such a value is not measured. - Record names come from a monotonic factory of their own, so records with the same recorded_at sort in the order written; tests for that, for the time of a retried name, and for init's milestone comment. - CHANGELOG says what the guide says about ids in a milestone's ref. --- CHANGELOG.md | 31 ++++++---- packages/cli/src/commands/board.test.ts | 2 +- packages/cli/src/commands/board.ts | 4 +- packages/cli/src/commands/view.test.ts | 2 +- packages/cli/src/lib/board-ui.ts | 2 +- packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 2 +- packages/core/src/board/init.test.ts | 6 ++ packages/core/src/board/measure.test.ts | 80 +++++++++++++++++++++++++ packages/core/src/board/measure.ts | 2 +- packages/core/src/board/record.test.ts | 28 +++++++-- packages/core/src/board/record.ts | 14 +++-- packages/core/src/board/repos.ts | 43 +++++++------ 13 files changed, 171 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2018c4e0..b6df7e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,19 +12,25 @@ All notable changes to **basou** are recorded here. The project follows `refs/remotes/origin/main`, so a repository whose default branch is `master`, or any other name, never had a `behind_main`, and the measurement still said it was complete, as it does for a repository with - no origin. It now counts against the branch `refs/remotes/origin/HEAD` - names (a clone sets it, and `git remote set-head origin --auto` sets it - later), and against `origin/main` when that is not set, as before. When - `origin/HEAD` names a branch that is not there, the value is not measured - and `not_found` says why. The `repos` method is now 2, so the next record - marks `behind_main` as measured another way. `board measure` and the - board page say "behind origin's default branch". + no origin. It now counts against the branch of origin that + `refs/remotes/origin/HEAD` names (a clone sets it, and + `git remote set-head origin --auto` sets it later), and against + `origin/main` when `origin/HEAD` names none (it is not set, or is not a + symbolic ref), as before. The value is not measured, and `not_found` says + why, when `origin/HEAD` names a branch that is not there (as after the + default branch is renamed and a fetch prunes the old one), names a ref + outside `refs/remotes/origin/`, or cannot be read by git; it then does not + fall back to `origin/main`. The `repos` method is now 2, so the next record + marks `behind_main` as measured another way. `board measure` and the board + page say "behind origin's default branch". - **`basou board record` (experimental) names a record by its own time.** The record's ULID took the time it was written, after the board was measured again, while `recorded_at` took the time the command started, so the board page's list of records and the guide gave a time a second or so - later than the record's heading. The ULID now carries `recorded_at`. - Records written before keep their names. + later than the record's heading. The ULID now carries `recorded_at`, from + a monotonic factory of its own, so records with the same `recorded_at` + still sort in the order they were written. Records written before keep + their names. - **`basou board guide` (experimental) says how to ask for a manual observation, and what to write when the operator hands it over.** It said only to ask the operator and not to make it. It now asks for one thing the @@ -33,9 +39,10 @@ All notable changes to **basou** are recorded here. The project follows make it, to make it and say so in the source. - **`basou board guide` and `basou board init` (experimental) ask for decision and task ids in full.** A shortened id in a board's declaration - matched two decisions, the first of them voided. The guide asks for all - 26 characters in the prose and in a milestone's `ref`, and the comment - `init` prints on milestones says so. + matched two decisions, the first of them voided. The guide asks for + decision and task ids with all 26 characters in the prose, and for a + decision id in full in a milestone's `ref`; the comment `init` prints on + milestones says the same. ## 0.70.0 — 2026-10-16 diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 3fb83f59..aea92d39 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -273,7 +273,7 @@ describe("basou board measure", () => { const unborn = out.join("\n"); expect(unborn).toMatch( new RegExp( - `Repos:\\n \\. \\(${name}\\) main at no commit yet, last commit none, commits 0, files \\d+, uncommitted \\d+, behind origin's default branch \\(no origin/HEAD or origin/main\\)\\n \\.\\./gone not measured\\n`, + `Repos:\\n \\. \\(${name}\\) main at no commit yet, last commit none, commits 0, files \\d+, uncommitted \\d+, behind origin's default branch \\(origin/HEAD names no branch, and there is no origin/main\\)\\n \\.\\./gone not measured\\n`, ), ); expect(unborn).toContain("repos[../gone]: the repo '../gone' is not on disk"); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index c6bab4c2..160b1364 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1090,7 +1090,7 @@ function shownValue(value: number | string | null): string { // One line per repository. A null the not_found entries do not explain has // the meaning the measurement gives it (a detached HEAD, no commit yet, no -// origin/HEAD or origin/main). +// default branch: origin/HEAD names none and there is no origin/main). function repoLines(m: BoardMeasurement): string[] { const missing = new Set(m.not_found.map((n) => n.at)); return m.repos.map((repo) => { @@ -1110,7 +1110,7 @@ function repoLines(m: BoardMeasurement): string[] { `commits ${measured("commits", String) ?? "not measured"}`, `files ${measured("files", String) ?? "not measured"}`, `uncommitted ${measured("uncommitted", String) ?? "not measured"}`, - `behind origin's default branch ${measured("behind_main", String) ?? "(no origin/HEAD or origin/main)"}`, + `behind origin's default branch ${measured("behind_main", String) ?? "(origin/HEAD names no branch, and there is no origin/main)"}`, ]; return ` ${displayPath(repo.path)}${name} ${parts.join(", ")}`; }); diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 0bc85454..1cddc23a 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1910,7 +1910,7 @@ describe("basou view: the board page", () => { const repos = call("liveRepos", LIVE); const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); - // Nulls that mean so: a detached HEAD, no origin/HEAD or origin/main, a last commit unread. + // Nulls that mean so: a detached HEAD, no default branch, a last commit unread. expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]); // Nulls the measurement could not measure are said to be. const unmeasured = { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index ee7221ea..a061f9da 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -766,7 +766,7 @@ export const BOARD_HTML = ` var rows = l.repos.map(function (r) { var at = 'repos[' + r.path + ']'; // A null the measurement says it could not measure, or one that means - // so (a detached HEAD, no commit yet, no origin/HEAD or origin/main), drawn as '-'. + // so (a detached HEAD, no commit yet, no default branch), drawn as '-'. var value = function (field, v, show) { if (v !== null) return show(v); return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-'; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index f9813600..408b6ac6 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -308,7 +308,7 @@ describe("boardGuide: a board declared", () => { it("says which branch behind_main counts against", () => { expect(boardGuide(guideInput())).toContain( - "- `repos[].behind_main` counts the commits on origin's default branch (the branch origin/HEAD names, or origin/main when origin/HEAD is not set) as of the last fetch; measure does not fetch.", + "- `repos[].behind_main` counts the commits on origin's default branch (the branch of origin that origin/HEAD names, or origin/main when origin/HEAD names none) as of the last fetch; measure does not fetch.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 10adbce9..6c2d90df 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -189,7 +189,7 @@ export function boardGuide(input: BoardGuideInput): string { "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", - "- `repos[].behind_main` counts the commits on origin's default branch (the branch origin/HEAD names, or origin/main when origin/HEAD is not set) as of the last fetch; measure does not fetch.", + "- `repos[].behind_main` counts the commits on origin's default branch (the branch of origin that origin/HEAD names, or origin/main when origin/HEAD names none) as of the last fetch; measure does not fetch.", "", ); diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index 9883eb11..739a8c37 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -105,6 +105,12 @@ describe("boardInitText", () => { ); expect(ja.comments.milestones).toContain("{ date: YYYY-MM-DD, label, ref }"); expect(ja.comments.milestones).toContain("他の盤から移すときは、その節目をここに移す。"); + expect(en.comments.milestones).toContain( + "with ref saying where it can be checked (a tag and its commit, or a decision id in full)", + ); + expect(ja.comments.milestones).toContain( + "ref に確かめられる出所(tag とその commit、または完全な decision の ID)を書く。", + ); }); it("keeps a name as written, whatever it holds", () => { diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 559786c9..6dd3abf8 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -1725,6 +1725,86 @@ describe("measureBoard: the repos section", () => { expect(m.complete).toBe(false); }); + it("says so when origin/HEAD names an origin/main that is gone, as after a rename and a prune", async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + git(root, "clone", "-q", upstream, "app"); + const app = join(root, "app"); + git(upstream, "branch", "-m", "main", "master"); + await commit(upstream, { "b.txt": "1\n" }, "upstream moves on"); + git(app, "fetch", "-q", "--prune"); + expect(git(app, "symbolic-ref", "refs/remotes/origin/HEAD").trim()).toBe( + "refs/remotes/origin/main", + ); + const m = await measureRepos("app"); + expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.not_found).toEqual([ + { + at: "repos[app].behind_main", + reason: "refs/remotes/origin/HEAD names refs/remotes/origin/main, which is not there", + }, + ]); + expect(m.complete).toBe(false); + }); + + it("reads an origin/HEAD that is not a symbolic ref as naming no branch", async () => { + const plain = await repo("plain", { "a.txt": "1\n" }); + git(plain, "update-ref", "--no-deref", "refs/remotes/origin/HEAD", "HEAD"); + const withMain = await repo("with-main", { "a.txt": "1\n" }); + await commit(withMain, { "b.txt": "1\n" }, "ahead"); + git(withMain, "update-ref", "refs/remotes/origin/main", "HEAD"); + git(withMain, "reset", "-q", "--hard", "HEAD~1"); + git(withMain, "update-ref", "--no-deref", "refs/remotes/origin/HEAD", "HEAD"); + const m = await measureRepos("plain", "with-main"); + expect(m.repos.map((r) => r.behind_main)).toEqual([null, 1]); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + }); + + it("does not count against an origin/HEAD that names a ref outside origin", async () => { + const dir = await repo("app", { "a.txt": "1\n" }); + git(dir, "branch", "old"); + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + git(dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/old"); + const m = await measureRepos("app"); + expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.not_found).toEqual([ + { + at: "repos[app].behind_main", + reason: "refs/remotes/origin/HEAD names refs/heads/old, which is not a branch of origin", + }, + ]); + }); + + it("does not measure behind_main past an origin/HEAD git cannot read, even with an origin/main", async () => { + const looped = await repo("looped", { "a.txt": "1\n" }); + const odd = await repo("odd", { "a.txt": "1\n" }); + for (const dir of [looped, odd]) { + git(dir, "update-ref", "refs/remotes/origin/main", "HEAD"); + } + await writeFile( + join(looped, ".git", "refs", "remotes", "origin", "HEAD"), + "ref: refs/remotes/origin/HEAD\n", + ); + await writeFile( + join(odd, ".git", "refs", "remotes", "origin", "HEAD"), + Buffer.concat([ + Buffer.from("ref: refs/remotes/origin/x"), + Buffer.from([0xff]), + Buffer.from("\n"), + ]), + ); + const m = await measureRepos("looped", "odd"); + expect(m.repos.map((r) => r.behind_main)).toEqual([null, null]); + expect(m.not_found).toEqual([ + { at: "repos[looped].behind_main", reason: "refs/remotes/origin/HEAD could not be read" }, + { + at: "repos[odd].behind_main", + reason: "the branch refs/remotes/origin/HEAD names is not valid UTF-8", + }, + ]); + expect(m.complete).toBe(false); + }); + it("reports a repo that is not there, not a repository, or not the root of one, once", async () => { await repo("app", { "sub/a.txt": "1\n" }); await mkdir(join(root, "plain")); diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index ad30fe8f..026d13da 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -78,7 +78,7 @@ export type BoardRatioValue = { * repository, revision or file is never counted as zero. The exceptions are * the nulls that mean something of their own, which have no entry and leave * `complete` as it is: three in the repos section (a detached HEAD, no commit - * yet, no origin/HEAD or origin/main; see {@link BoardRepo}), the portfolio's when there is + * yet, no default branch of origin; see {@link BoardRepo}), the portfolio's when there is * no portfolio config (see {@link BoardPortfolio}), the newest session's when * there is no session that is not archived (see {@link BoardFreshness}), the * Codex time when there is no Codex session (see {@link BoardEffort}), the diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 4fbcccf0..9efead7c 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -8,12 +8,19 @@ import { type BoardDeclaration, parseBoardDeclaration } from "./declaration.js"; import type { BoardMeasurement } from "./measure.js"; import { buildRecord, orderAnomalies, parseRecordInput, writeRecord } from "./record.js"; -// Pass-through so one test can make the first name a record already has. +// Pass-through, so one test can make the next name a record already has. +const forced = vi.hoisted(() => ({ names: [] as string[] })); vi.mock("ulid", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, ulid: vi.fn(actual.ulid) }; + return { + ...actual, + monotonicFactory: (...args: Parameters) => { + const real = actual.monotonicFactory(...args); + return (seedTime?: number) => forced.names.shift() ?? real(seedTime); + }, + }; }); -const { decodeTime, ulid } = await import("ulid"); +const { decodeTime } = await import("ulid"); // Pass-through so one test can make a write fail after it has begun. vi.mock("node:fs/promises", async (importOriginal) => { @@ -408,7 +415,7 @@ describe("buildRecord and writeRecord", () => { dir = await mkdtemp(join(tmpdir(), "basou-board-record-")); }); afterEach(async () => { - vi.mocked(ulid).mockReset(); + forced.names.length = 0; await rm(dir, { recursive: true, force: true }); }); @@ -487,12 +494,23 @@ describe("buildRecord and writeRecord", () => { it("tries another name rather than replace a record, and leaves no other file", async () => { const records = join(dir, "records"); const first = await writeRecord(records, record()); - vi.mocked(ulid).mockReturnValueOnce(first.replace(/\.json$/, "")); + forced.names.push(first.replace(/\.json$/, "")); const second = await writeRecord(records, record()); expect(second).not.toBe(first); + expect(forced.names).toEqual([]); + expect(new Date(decodeTime(second.replace(/\.json$/, ""))).toISOString()).toBe( + "2026-10-07T00:00:00.000Z", + ); expect((await readdir(records)).sort()).toEqual([first, second].sort()); }); + it("names records with the same recorded_at in the order they are written", async () => { + const records = join(dir, "records"); + const names: string[] = []; + for (let i = 0; i < 10; i++) names.push(await writeRecord(records, record())); + expect([...names].sort()).toEqual(names); + }); + it("refuses a records path that is not a directory, a link to one included, and writes nothing", async () => { const records = join(dir, "records"); await writeFile(records, "not a directory\n"); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index bb0ca88d..596a0bef 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { link, lstat, mkdir, rmdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { ulid } from "ulid"; +import { monotonicFactory } from "ulid"; import { z } from "zod"; import { findErrorCode } from "../lib/error-codes.js"; import { @@ -372,13 +372,20 @@ export function buildRecord(input: { }; } +// Record names come from a factory of their own: monotonic, so records with +// the same recorded_at sort in the order they are written, and apart from +// basou's other ids, whose later times would otherwise carry over to a +// record's name. +const recordName = monotonicFactory(); + /** * Write a record to `/.json`, all of it or nothing: the text * goes to a file whose name is not a ULID's, then is linked into place (which * fails rather than replace a record of the same name, when another name is * tried). On a failure nothing is left behind, not even a records directory * made for it. The ULID carries the record's `recorded_at` as its time, so - * the name and the record say the same time. Returns the name written. + * the name and the record say the same time (unless a record written before + * it in this process has a later one). Returns the name written. */ export async function writeRecord(recordsDir: string, record: BoardRecord): Promise { const made = await mkdir(recordsDir, { recursive: true }); @@ -390,7 +397,6 @@ export async function writeRecord(recordsDir: string, record: BoardRecord): Prom }); } const tmp = join(recordsDir, `.record-${randomUUID()}.tmp`); - // Not the monotonic factory, which keeps a later time it gave out before. const at = Date.parse(record.recorded_at); try { await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { @@ -398,7 +404,7 @@ export async function writeRecord(recordsDir: string, record: BoardRecord): Prom flag: "wx", }); for (let attempt = 0; ; attempt++) { - const name = `${ulid(Number.isNaN(at) ? undefined : at)}.json`; + const name = `${recordName(Number.isNaN(at) ? undefined : at)}.json`; try { await link(tmp, join(recordsDir, name)); return name; diff --git a/packages/core/src/board/repos.ts b/packages/core/src/board/repos.ts index 15322648..e01b2934 100644 --- a/packages/core/src/board/repos.ts +++ b/packages/core/src/board/repos.ts @@ -27,8 +27,8 @@ export const BOARD_REPOS_METHOD = 2; * repository could be measured. Three values are `null` with a meaning of * their own and no entry: `branch` when HEAD is detached; `head` and * `last_commit` when the repository has no commit yet (`commits` is then 0); - * `behind_main` when `refs/remotes/origin/HEAD` is not set and there is no - * `refs/remotes/origin/main`. + * `behind_main` when `refs/remotes/origin/HEAD` names no branch and there is + * no `refs/remotes/origin/main`. */ export type BoardRepo = { /** The path as the manifest writes it. */ @@ -56,9 +56,10 @@ export type BoardRepo = { uncommitted: number | null; /** * The commits on origin's default branch HEAD does not have, as of the last - * fetch: the branch `refs/remotes/origin/HEAD` names (set by a clone, or by - * `git remote set-head origin --auto`), or `refs/remotes/origin/main` when - * it is not set. + * fetch: the branch of origin `refs/remotes/origin/HEAD` names (set by a + * clone, or by `git remote set-head origin --auto`), or + * `refs/remotes/origin/main` when it names none (it is not set, or is not a + * symbolic ref). */ behind_main: number | null; }; @@ -142,22 +143,28 @@ async function readRef(cwd: string, name: string): Promise { return { kind: "missing" }; } -// The full name of origin's default branch: the branch origin/HEAD names, -// or origin/main when origin/HEAD is not a symbolic ref (not set at all, as -// after `git remote add` and a fetch). +const ORIGIN_BRANCHES = "refs/remotes/origin/"; + +// The full name of origin's default branch: the branch origin/HEAD names +// (`named`), or origin/main when origin/HEAD names none: it is not set, as +// after `git remote add` and a fetch, or is not a symbolic ref. async function defaultBranchOf( cwd: string, -): Promise<{ kind: "name"; name: string } | { kind: "error"; reason: string }> { +): Promise<{ kind: "name"; name: string; named: boolean } | { kind: "error"; reason: string }> { const named = await runGit(cwd, ["symbolic-ref", "-q", ORIGIN_HEAD]); if (named.code === 0) { const name = utf8Name(lineBytes(named)); - return name === undefined - ? { kind: "error", reason: `the branch ${ORIGIN_HEAD} names is not valid UTF-8` } - : { kind: "name", name }; + if (name === undefined) { + return { kind: "error", reason: `the branch ${ORIGIN_HEAD} names is not valid UTF-8` }; + } + // Only a hand-made origin/HEAD names a ref of something else. + return name.startsWith(ORIGIN_BRANCHES) + ? { kind: "name", name, named: true } + : { kind: "error", reason: `${ORIGIN_HEAD} names ${name}, which is not a branch of origin` }; } // 1 is a ref that is not symbolic, or not there. if (named.code === 1 && named.spawnError === undefined) { - return { kind: "name", name: ORIGIN_MAIN }; + return { kind: "name", name: ORIGIN_MAIN, named: false }; } return { kind: "error", reason: failure(named, `${ORIGIN_HEAD} could not be read`) }; } @@ -366,8 +373,8 @@ export async function measureRepos( } // The commits on origin's default branch HEAD does not have: null when -// origin/HEAD is not set and there is no origin/main, which is what the null -// means; or why they could not be counted. +// origin/HEAD names no branch and there is no origin/main, which is what the +// null means; or why they could not be counted. async function behindDefault( cwd: string, head: Exclude, @@ -378,9 +385,9 @@ async function behindDefault( const ref = await readRef(cwd, target.name); if (ref.kind === "error") return { ok: false, reason: ref.reason }; if (ref.kind === "missing") { - return target.name === ORIGIN_MAIN - ? { ok: true, count: null } - : { ok: false, reason: `${ORIGIN_HEAD} names ${target.name}, which is not there` }; + return target.named + ? { ok: false, reason: `${ORIGIN_HEAD} names ${target.name}, which is not there` } + : { ok: true, count: null }; } const commit = await runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref.oid}^{commit}`]); if (commit.code !== 0) {