Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ All notable changes to **basou** are recorded here. The project follows
before. A record of version 1 or 2 holds one lane id, which the board page
and the difference from the previous record read as a list of that one,
so a lane written either way is not a change.
- **A record's repos name the branch they are compared with, count the
commits ahead of it, and say when it was last fetched
(`record_version: 3`, experimental).** `behind_main` is now `behind`,
counted as before, and `base` names the branch of origin it counts against
(`origin/<branch>`: the one `origin/HEAD` names, or `origin/main` when it
names none; null when there is neither). `ahead` counts the commits HEAD
has that the base does not: not only commits not pushed, but those of a
branch pushed and not merged, and those that came into the base as other
commits (by a squash or a rebase). `fetched_at` is when FETCH_HEAD was
last written, in UTC, when it holds the line of the base's branch fetched
from origin's URL (as git writes it there, without a user or a password)
at the commit the base is at now; it is null when it does not, as after a
fetch that failed, which empties FETCH_HEAD but still moves its time, a
fetch since from another URL or of other branches only, or a push or a
fetch in another worktree that moved the base since. A later fetch with
`--append` moves the time and leaves the line, and a remote whose URL is
origin's writes the same line as origin. `fetched_at` is not in the digest
and not compared with the previous record: a fetch that brings nothing
moves it too. basou still does not fetch. The `repos` method stays 2. A
record of version 1 or 2 keeps `behind_main`, so the first difference from
one shows `behind_main` gone and `base`, `behind` and `ahead` come.
`basou board measure` prints them on each repo's line (or that there is no
base), the board page measured on the spot draws them as columns, and
`basou board guide` says how to read them: `ahead` with `branch` before
judging 04, and both counts as maybe old when `fetched_at` is null or old.
- **A branch a `github_ci` or `github_check` observation names may not have
a part that starts with `.`** (in a board of any version). git takes no
such branch, and GitHub reads `a/./b` in the path of a request as `a/b`,
Expand Down
42 changes: 41 additions & 1 deletion packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
rm,
stat,
symlink,
utimes,
writeFile,
} from "node:fs/promises";
import { devNull, tmpdir } from "node:os";
Expand Down Expand Up @@ -343,7 +344,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 \\(origin/HEAD names no branch, and there is 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+, no base \\(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");
Expand All @@ -365,6 +366,45 @@ describe("basou board measure", () => {
expect(out.join("\n")).toContain(
` . (${name}) detached HEAD at ${stdout.slice(0, 7)}, last commit 2026-10-04T23:30:00+09:00, commits 1, files `,
);

const git = (...args: string[]) => execFileAsync("git", args, { cwd: repo, env: ENV });
// An origin/main one commit past HEAD.
const tip = (
await git(
"-c",
"user.email=t@example.com",
"-c",
"user.name=t",
"commit-tree",
"HEAD^{tree}",
"-p",
"HEAD",
"-m",
"upstream",
)
).stdout.trim();
await git("update-ref", "refs/remotes/origin/main", tip);
out.length = 0;
await runBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
", base origin/main, behind 1, ahead 0, fetched not known\n ../gone not measured\n",
);
await git("remote", "add", "origin", "https://example.com/x/app");
const fetchHead = join(repo, ".git", "FETCH_HEAD");
await writeFile(fetchHead, `${tip}\t\tbranch 'main' of https://example.com/x/app\n`);
const fetched = new Date("2026-10-04T12:00:00.000Z");
await utimes(fetchHead, fetched, fetched);
out.length = 0;
await runBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
", base origin/main, behind 1, ahead 0, fetched 2026-10-04T12:00:00.000Z\n",
);
await git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/elsewhere");
out.length = 0;
await runBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
", base not measured, behind not measured, ahead not measured, fetched not measured\n",
);
});

it("prints the open tracks in the summary, or that the trail was not measured", async () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/commands/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,8 @@ function shownValue(value: number | string | 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
// default branch: origin/HEAD names none and there is no origin/main).
// base: origin/HEAD names no branch and there is no origin/main, no fetch of
// the base on record).
function repoLines(m: BoardMeasurement): string[] {
const missing = new Set(m.not_found.map((n) => n.at));
return m.repos.map((repo) => {
Expand All @@ -1120,8 +1121,18 @@ 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) ?? "(origin/HEAD names no branch, and there is no origin/main)"}`,
];
const base = measured("base", (b) => displayPath(String(b)));
if (base === undefined) {
parts.push("no base (origin/HEAD names no branch, and there is no origin/main)");
} else {
parts.push(
`base ${base}`,
`behind ${measured("behind", String) ?? "not measured"}`,
`ahead ${measured("ahead", String) ?? "not measured"}`,
`fetched ${measured("fetched_at", String) ?? "not known"}`,
);
}
return ` ${displayPath(repo.path)}${name} ${parts.join(", ")}`;
});
}
Expand Down
70 changes: 66 additions & 4 deletions packages/cli/src/commands/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1900,7 +1900,10 @@ describe("basou view: the board page", () => {
last_commit: null,
commits: 2,
uncommitted: 1,
behind_main: null,
base: null,
behind: null,
ahead: null,
fetched_at: null,
},
],
trail: {
Expand Down Expand Up @@ -1943,8 +1946,49 @@ 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 default branch, a last commit unread.
expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]);
// Nulls that mean so: a detached HEAD, no base, a last commit unread.
expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-", "-", "-", "-"]);
expect(repos.find((n) => n.tag === "th").map((c) => [c.textContent, c.className])).toEqual([
[S.live.repo, ""],
[S.live.branch, ""],
[S.live.head, ""],
[S.live.lastCommit, ""],
[S.live.commits, "n"],
[S.live.uncommitted, "n"],
[S.live.base, ""],
[S.live.behind, "n"],
[S.live.ahead, "n"],
[S.live.fetchedAt, ""],
]);
// A base, the counts against it and when it was fetched.
const compared = {
...LIVE,
repos: [
{
...LIVE.repos[0],
base: "origin/master",
behind: 3,
ahead: 1,
fetched_at: "2026-10-04T12:00:00.000Z",
},
],
};
const comparedTds = call("liveRepos", compared).find((n) => n.tag === "td");
const comparedCells = comparedTds.map((c) => c.textContent);
expect(comparedCells.slice(6, 9)).toEqual(["origin/master", "3", "1"]);
expect(comparedCells[9]).toBe(new Date("2026-10-04T12:00:00.000Z").toLocaleString("en-US"));
expect(comparedTds.map((c) => c.className)).toEqual([
"",
"",
"",
"",
"n",
"n",
"",
"n",
"n",
"",
]);
// Nulls the measurement could not measure are said to be.
const unmeasured = {
...LIVE,
Expand Down Expand Up @@ -1973,7 +2017,7 @@ describe("basou view: the board page", () => {
.slice(1)
.map((row) => row.find((n) => n.tag === "td").map((c) => c.textContent));
expect(rows).toEqual([
["app", "not measured", "0123456", "-", "2", "1", "-"],
["app", "not measured", "0123456", "-", "2", "1", "-", "-", "-", "-"],
[
"../gone",
"not measured",
Expand All @@ -1982,8 +2026,26 @@ describe("basou view: the board page", () => {
"not measured",
"not measured",
"not measured",
"not measured",
"not measured",
"not measured",
],
]);
// Each of the four the measurement could not measure is said to be.
const fields = ["base", "behind", "ahead", "fetched_at"];
const unmeasuredBase = {
...LIVE,
footnotes: {
...LIVE.footnotes,
not_found: fields.map((field) => ({ at: `repos[.].${field}`, reason: "x" })),
},
};
expect(
call("liveRepos", unmeasuredBase)
.find((n) => n.tag === "td")
.map((c) => c.textContent)
.slice(6),
).toEqual(fields.map(() => S.tiles.notMeasured));

const trail = call("liveTrail", LIVE);
// The newest session was not measured: not "no session".
Expand Down
13 changes: 9 additions & 4 deletions packages/cli/src/lib/board-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,12 +768,14 @@ export const BOARD_HTML = `<!doctype html>
}
function liveRepos(l) {
var missing = missingIn(l);
var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.behindMain];
var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: i >= 4 ? 'n' : '', text: label }); }));
var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.base, S.live.behind, S.live.ahead, S.live.fetchedAt];
var numeric = [4, 5, 7, 8];
var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: numeric.indexOf(i) !== -1 ? 'n' : '', text: label }); }));
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 default branch), drawn as '-'.
// so (a detached HEAD, no commit yet, no base, no fetch of it on
// record), drawn as '-'.
var value = function (field, v, show) {
if (v !== null) return show(v);
return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-';
Expand All @@ -785,7 +787,10 @@ export const BOARD_HTML = `<!doctype html>
el('td', { text: value('last_commit', r.last_commit, when) }),
el('td', { class: 'n', text: num(r.commits) }),
el('td', { class: 'n', text: num(r.uncommitted) }),
el('td', { class: 'n', text: value('behind_main', r.behind_main, num) })
el('td', { text: value('base', r.base, String) }),
el('td', { class: 'n', text: value('behind', r.behind, num) }),
el('td', { class: 'n', text: value('ahead', r.ahead, num) }),
el('td', { text: value('fetched_at', r.fetched_at, when) })
]);
});
return section(S.live.repos, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/board/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,47 @@ describe("diffMeasurements", () => {
]);
});

it("does not compare when a repo was last fetched, as the digest does not hold it", () => {
const repo = { path: ".", base: "origin/main", behind: 0, ahead: 1 };
const before = measurement({
repos: [{ ...repo, fetched_at: "2026-10-05T00:00:00.000Z" }],
});
const after = measurement({
repos: [{ ...repo, behind: 2, fetched_at: "2026-10-06T00:00:00.000Z" }],
});
expect(diffMeasurements(before, after, "01M4")).toMatchObject({
values: [{ at: 'repos["."].behind', before: 0, after: 2, delta: 2 }],
added: [],
removed: [],
});
const unfetched = measurement({ repos: [{ ...repo, fetched_at: null }] });
expect(diffMeasurements(before, unfetched, "01M4").values).toEqual([]);
});

it("shows behind_main gone and the base, behind and ahead come, once, against a record of version 1 or 2", () => {
const before = measurement({ repos: [{ path: ".", behind_main: 2 }] });
const after = measurement({
repos: [
{
path: ".",
base: "origin/main",
behind: 2,
ahead: 0,
fetched_at: "2026-10-06T00:00:00.000Z",
},
],
});
expect(diffMeasurements(before, after, "01M4")).toMatchObject({
values: [],
added: [
{ at: 'repos["."].base', value: "origin/main" },
{ at: 'repos["."].behind', value: 2 },
{ at: 'repos["."].ahead', value: 0 },
],
removed: [{ at: 'repos["."].behind_main', value: 2 }],
});
});

it("tells entries apart by what names them, and compares lists of names as sets", () => {
const after = measurement({
not_found: [{ at: "measures.md", reason: "gone" }],
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/board/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ const UNCOMPARED = new Set([
"diff",
]);
// Left out further down.
const SKIPPED = new Set(["effort.elapsed_days", "effort.daily", "components.kind_changed"]);
const SKIPPED = new Set([
"effort.elapsed_days",
"effort.daily",
"repos[].fetched_at",
"components.kind_changed",
]);
// Lists whose entries are told apart by a field of theirs.
const KEYED: Readonly<Record<string, string>> = {
repos: "path",
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/board/guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,15 @@ describe("boardGuide: a board declared", () => {
);
});

it("says which branch behind_main counts against", () => {
it("says which branch a repo is compared with, and that a null or old fetched_at makes the counts old", () => {
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.",
"- `repos[].base` is the branch of origin a repo is compared with (`origin/<branch>`: the one origin/HEAD names, or origin/main when origin/HEAD names none), `behind` counts the commits on it HEAD does not have, and `ahead` those HEAD has that it does not, as of the last fetch; measure does not fetch. `fetched_at` is when FETCH_HEAD was last written, given that it holds that branch fetched from origin at the commit it is at now (a later `git fetch --append` moves it too), and null when it does not (no fetch since the clone, a failed fetch, a fetch of something else since, or a push since). When it is null or old, `behind` and `ahead` may be old too.",
);
});

it("says that ahead above 0 is not only commits not pushed, when judging 04", () => {
expect(boardGuide(guideInput())).toContain(
"2. What is not committed is not 04. Work left in a working tree stops at 03: written is not merged. A repo's `ahead` above 0 is commits not in its `base`, which are not only commits not pushed: a branch pushed and not merged has them, and so has one merged as other commits (a squash or a rebase). Read it with `branch` before judging 04; when `fetched_at` is null or old, it may be old too.",
);
});

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/board/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function boardGuide(input: BoardGuideInput): string {
"- What moved is the point: the top of measure.txt (`Since the previous record <ULID>:`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.",
"- A `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured.",
"- A `file_paths` measure's value is the list of paths it found. `Measures:` in measure.txt gives it as a count: read the paths in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list, and the list whole when only one does.",
"- `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.",
"- `repos[].base` is the branch of origin a repo is compared with (`origin/<branch>`: the one origin/HEAD names, or origin/main when origin/HEAD names none), `behind` counts the commits on it HEAD does not have, and `ahead` those HEAD has that it does not, as of the last fetch; measure does not fetch. `fetched_at` is when FETCH_HEAD was last written, given that it holds that branch fetched from origin at the commit it is at now (a later `git fetch --append` moves it too), and null when it does not (no fetch since the clone, a failed fetch, a fetch of something else since, or a push since). When it is null or old, `behind` and `ahead` may be old too.",
"",
);

Expand Down Expand Up @@ -564,7 +564,7 @@ function judging(d: BoardDeclaration, line: (...lines: string[]) => void): void
"Principles:",
"",
'1. Do not judge a stage generously. "Mostly works" is `part`, not `done`: the board is worth what it says about what has not arrived.',
"2. What is not committed is not 04. Work left in a working tree stops at 03: written is not merged.",
"2. What is not committed is not 04. Work left in a working tree stops at 03: written is not merged. A repo's `ahead` above 0 is commits not in its `base`, which are not only commits not pushed: a branch pushed and not merged has them, and so has one merged as other commits (a squash or a rebase). Read it with `branch` before judging 04; when `fetched_at` is null or old, it may be old too.",
"3. Give a stuck cell its reason in one sentence. A reason you cannot write means the judgement is early.",
"4. Do not paint a shelved cell red. Shelving is a decision; blaming it makes stopping harder and bends judgement.",
"5. Do not mix estimates and measurements. Plans and estimates are not results: name them in a footnote.",
Expand Down
Loading
Loading