diff --git a/CHANGELOG.md b/CHANGELOG.md index cc51c67..b6df7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,47 @@ 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 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`, 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 + 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 + 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 ### Changed diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index dbf12dd..aea92d3 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 \\(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 7974c21..160b136 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). +// 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/main ${measured("behind_main", String) ?? "(no 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 0b960ca..1cddc23 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 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 d27b822..a061f9d 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 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 d57d3e7..408b6ac 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 of origin that origin/HEAD names, or origin/main when origin/HEAD names none) 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 ac70b06..6c2d90d 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 of origin that origin/HEAD names, or origin/main when origin/HEAD names none) 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/init.test.ts b/packages/core/src/board/init.test.ts index 9883eb1..739a8c3 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/live.test.ts b/packages/core/src/board/live.test.ts index 54b1912..80f6e0a 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 daea8a8..6dd3abf 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -1679,6 +1679,132 @@ 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("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")); @@ -1766,7 +1892,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 +3009,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 +3269,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 +3389,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 ec4f4e7..026d13d 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 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 0009f6c..9efead7 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -3,16 +3,24 @@ 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(); - return { ...actual, ulid: vi.fn(actual.ulid) }; +// 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, + monotonicFactory: (...args: Parameters) => { + const real = actual.monotonicFactory(...args); + return (seedTime?: number) => forced.names.shift() ?? real(seedTime); + }, + }; }); -const { ulid } = await import("../ids/ulid.js"); +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) => { @@ -407,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 }); }); @@ -459,6 +467,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")); @@ -475,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 eee69fc..596a0be 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 { monotonicFactory } from "ulid"; import { z } from "zod"; -import { ulid } from "../ids/ulid.js"; import { findErrorCode } from "../lib/error-codes.js"; import { BOARD_STAGE_IDS, @@ -372,12 +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. 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 (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 }); @@ -389,13 +397,14 @@ export async function writeRecord(recordsDir: string, record: BoardRecord): Prom }); } const tmp = join(recordsDir, `.record-${randomUUID()}.tmp`); + 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 = `${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 557970d..e01b293 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` names no branch and there is + * no `refs/remotes/origin/main`. */ export type BoardRepo = { /** The path as the manifest writes it. */ @@ -53,7 +54,13 @@ 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 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; }; @@ -78,6 +85,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 +143,32 @@ async function readRef(cwd: string, name: string): Promise { return { kind: "missing" }; } +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; 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)); + 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, named: false }; + } + 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 +364,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 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, + 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.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) { + 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 7abe165..1579213 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)を書く。他の盤から移すときは、その節目をここに移す。", }, };