From 798a6279cf1db399633f154f14186d2343a680b4 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sun, 18 Oct 2026 12:30:19 +0900 Subject: [PATCH 1/2] basou: Name the base a board's repos are compared with, count what is ahead of it, and say when it was last fetched In a version 3 record, each repo's behind_main is now behind, counted as before, next to base (origin/: the branch origin/HEAD names, or origin/main when it names none; null when there is neither), ahead (the commits HEAD has that the base does not) and fetched_at (when FETCH_HEAD was last written, only when it holds the line of the base's branch of origin's URL, as git writes it there, at the commit the base is at now). fetched_at is left out of the digest and of the difference from the previous record. The repos method stays 2. basou board measure prints the four on each repo's line, the board page measured on the spot draws them as columns, and the guide says how to read them when judging 04. --- CHANGELOG.md | 23 ++ packages/cli/src/commands/board.test.ts | 42 +- packages/cli/src/commands/board.ts | 15 +- packages/cli/src/commands/view.test.ts | 70 +++- packages/cli/src/lib/board-ui.ts | 13 +- packages/core/src/board/diff.test.ts | 41 ++ packages/core/src/board/diff.ts | 7 +- packages/core/src/board/guide.test.ts | 10 +- packages/core/src/board/guide.ts | 4 +- packages/core/src/board/live.test.ts | 40 +- packages/core/src/board/live.ts | 10 +- packages/core/src/board/measure.test.ts | 489 +++++++++++++++++++++--- packages/core/src/board/measure.ts | 65 ++-- packages/core/src/board/record.ts | 5 +- packages/core/src/board/repos.ts | 238 ++++++++++-- packages/core/src/board/scope.ts | 4 +- packages/core/src/lib/view-strings.ts | 15 +- 17 files changed, 946 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 516855cb..c5957b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,29 @@ 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/`: 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 of another remote or of other branches only, or a push or a + fetch in another worktree that moved the base since. `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 + the four on each repo's line, 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`, diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 9d52bb8c..83e3da55 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -10,6 +10,7 @@ import { rm, stat, symlink, + utimes, writeFile, } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; @@ -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"); @@ -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 () => { diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index fe265589..4f9a8e7a 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -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) => { @@ -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(", ")}`; }); } diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 6d5d27ce..d4b20935 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -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: { @@ -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, @@ -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", @@ -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". diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index bb5298ca..89eda164 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -768,12 +768,14 @@ export const BOARD_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 : '-'; @@ -785,7 +787,10 @@ export const BOARD_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))])]); diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index b238b571..0b42e454 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -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" }], diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index a62b728e..faa9abf9 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -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> = { repos: "path", diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 2e916610..a455a849 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -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/`: 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 this checkout last fetched that branch from origin, null when FETCH_HEAD does not show it at the commit it is at now (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.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 82000a57..8d5c725d 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -194,7 +194,7 @@ export function boardGuide(input: BoardGuideInput): string { "- 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.", "- 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/`: 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 this checkout last fetched that branch from origin, null when FETCH_HEAD does not show it at the commit it is at now (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.", "", ); @@ -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.", diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts index 80f6e0a5..7b2c3000 100644 --- a/packages/core/src/board/live.test.ts +++ b/packages/core/src/board/live.test.ts @@ -1,6 +1,16 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + utimes, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -177,6 +187,15 @@ describe("measureBoardLive", () => { describe("boardLivePage", () => { it("lays the measurement out for the page, under the workspace's name", async () => { await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + // An origin/main one commit behind HEAD, as the last fetch of origin left it. + git("remote", "add", "origin", "https://example.com/x/ws.git"); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + const tip = git("rev-parse", "HEAD").trim(); + const fetched = new Date("2026-10-04T12:00:00.000Z"); + const fetchHead = join(root, ".git", "FETCH_HEAD"); + await writeFile(fetchHead, `${tip}\t\tbranch 'main' of https://example.com/x/ws\n`); + await utimes(fetchHead, fetched, fetched); + git("commit", "-q", "--allow-empty", "-m", "second"); const m = await measure(); const page = boardLivePage("ws", m); const name = basename(root); @@ -194,6 +213,25 @@ describe("boardLivePage", () => { { key: `${name}/web`, kinds: ["manifest"] }, ]); expect(page.repos[0]).not.toHaveProperty("files"); + expect(page.repos[0]).toMatchObject({ + base: "origin/main", + behind: 0, + ahead: 1, + fetched_at: fetched.toISOString(), + }); + expect(Object.keys(page.repos[0] ?? {})).toEqual([ + "path", + "name", + "head", + "branch", + "last_commit", + "commits", + "uncommitted", + "base", + "behind", + "ahead", + "fetched_at", + ]); expect(page.footnotes).toEqual({ not_found: [], measured_with: WITH }); }); }); diff --git a/packages/core/src/board/live.ts b/packages/core/src/board/live.ts index 2911a378..e8adb482 100644 --- a/packages/core/src/board/live.ts +++ b/packages/core/src/board/live.ts @@ -156,7 +156,10 @@ export type BoardLivePage = { last_commit: string | null; commits: number | null; uncommitted: number | null; - behind_main: number | null; + base: string | null; + behind: number | null; + ahead: number | null; + fetched_at: string | null; }[]; trail: BoardTrail; integrity: BoardIntegrity & { @@ -192,7 +195,10 @@ export function boardLivePage(title: string, m: BoardLiveMeasurement): BoardLive last_commit: r.last_commit, commits: r.commits, uncommitted: r.uncommitted, - behind_main: r.behind_main, + base: r.base, + behind: r.behind, + ahead: r.ahead, + fetched_at: r.fetched_at, })), trail: m.trail, integrity: { diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index ff25fa38..4d907dc7 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -13,7 +13,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; import { chainEvents } from "../events/chain.js"; @@ -1410,7 +1410,10 @@ describe("measureBoard: the repos section", () => { commits: null, files: null, uncommitted: null, - behind_main: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, }; // Measure a declaration with no measures, for a manifest that declares `repos`. @@ -1457,6 +1460,8 @@ describe("measureBoard: the repos section", () => { await commit(upstream, { "b.txt": "1\n" }, "upstream moves on"); await commit(upstream, { "b.txt": "2\n" }, "and on"); git(app, "fetch", "-q"); + const fetched = new Date("2026-10-04T12:00:00.000Z"); + await utimes(join(app, ".git", "FETCH_HEAD"), fetched, fetched); await commit(app, { "c.txt": "1\n" }, "local", { GIT_AUTHOR_DATE: "2026-09-01T00:00:00+00:00", GIT_COMMITTER_DATE: "2026-10-04T23:30:00+09:00", @@ -1480,7 +1485,11 @@ describe("measureBoard: the repos section", () => { commits: 1, files: 1, uncommitted: 0, - behind_main: null, // no origin/main + // No origin/HEAD and no origin/main. + base: null, + behind: null, + ahead: null, + fetched_at: null, }, { path: "app", @@ -1494,8 +1503,13 @@ describe("measureBoard: the repos section", () => { files: 5, // a.txt and the two notes. uncommitted: 3, - // The two upstream commits, fetched but not merged (HEAD is one ahead). - behind_main: 2, + base: "origin/main", + // The two upstream commits, fetched but not merged. + behind: 2, + // The local commit. + ahead: 1, + // When the fetch that brought origin/main where it is wrote FETCH_HEAD. + fetched_at: "2026-10-04T12:00:00.000Z", }, ]); expect(Object.keys(m.repos[0] ?? {})).toEqual([ @@ -1507,7 +1521,10 @@ describe("measureBoard: the repos section", () => { "commits", "files", "uncommitted", - "behind_main", + "base", + "behind", + "ahead", + "fetched_at", ]); expect(m.repos[0]?.head).toMatch(/^[0-9a-f]{40}$/); expect(m.not_found).toEqual([]); @@ -1630,7 +1647,10 @@ describe("measureBoard: the repos section", () => { commits: 1, files: 1, uncommitted: 0, - behind_main: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, }, { path: "empty", @@ -1641,7 +1661,10 @@ describe("measureBoard: the repos section", () => { commits: 0, files: 2, uncommitted: 2, - behind_main: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, }, { path: "orphan", @@ -1652,7 +1675,10 @@ describe("measureBoard: the repos section", () => { commits: 0, files: 1, uncommitted: 1, - behind_main: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, }, ]); expect(m.not_found).toEqual([]); @@ -1667,7 +1693,15 @@ describe("measureBoard: the repos section", () => { git(empty, "init", "-q", "-b", "main"); git(empty, "fetch", "-q", upstream, "main:refs/remotes/origin/main"); const m = await measureRepos("empty"); - expect(m.repos[0]).toMatchObject({ head: null, commits: 0, behind_main: 2 }); + // Fetched by its URL, not from a remote origin: no fetch of origin to tell. + expect(m.repos[0]).toMatchObject({ + head: null, + commits: 0, + base: "origin/main", + behind: 2, + ahead: 0, + fetched_at: null, + }); expect(m.complete).toBe(true); }); @@ -1675,7 +1709,7 @@ describe("measureBoard: the repos section", () => { const dir = await repo("app", { "a.txt": "1\n" }); git(dir, "branch", "origin/main"); const m = await measureRepos("app"); - expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.repos[0]).toMatchObject({ base: null, behind: null, ahead: null }); expect(m.not_found).toEqual([]); }); @@ -1693,7 +1727,13 @@ describe("measureBoard: the repos section", () => { "refs/remotes/origin/master", ); const m = await measureRepos("app"); - expect(m.repos[0]).toMatchObject({ branch: "master", behind_main: 2 }); + expect(m.repos[0]).toMatchObject({ + branch: "master", + base: "origin/master", + behind: 2, + ahead: 0, + fetched_at: expect.any(String), + }); expect(m.not_found).toEqual([]); expect(m.complete).toBe(true); }); @@ -1704,7 +1744,7 @@ describe("measureBoard: the repos section", () => { 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.repos[0]).toMatchObject({ base: null, behind: null, ahead: null, fetched_at: null }); expect(m.not_found).toEqual([]); expect(m.complete).toBe(true); }); @@ -1715,12 +1755,17 @@ describe("measureBoard: the repos section", () => { 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(); + // The base is the branch origin/HEAD names; with no commit, no fetch of it is told. + expect(m.repos[0]).toMatchObject({ + base: "origin/gone", + behind: null, + ahead: null, + fetched_at: null, + }); + const reason = "refs/remotes/origin/HEAD names refs/remotes/origin/gone, which is not there"; expect(m.not_found).toEqual([ - { - at: "repos[app].behind_main", - reason: "refs/remotes/origin/HEAD names refs/remotes/origin/gone, which is not there", - }, + { at: "repos[app].behind", reason }, + { at: "repos[app].ahead", reason }, ]); expect(m.complete).toBe(false); }); @@ -1736,12 +1781,16 @@ describe("measureBoard: the repos section", () => { "refs/remotes/origin/main", ); const m = await measureRepos("app"); - expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.repos[0]).toMatchObject({ + base: "origin/main", + behind: null, + ahead: null, + fetched_at: null, + }); + const reason = "refs/remotes/origin/HEAD names refs/remotes/origin/main, which is not there"; expect(m.not_found).toEqual([ - { - at: "repos[app].behind_main", - reason: "refs/remotes/origin/HEAD names refs/remotes/origin/main, which is not there", - }, + { at: "repos[app].behind", reason }, + { at: "repos[app].ahead", reason }, ]); expect(m.complete).toBe(false); }); @@ -1755,7 +1804,10 @@ describe("measureBoard: the repos section", () => { 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.repos.map((r) => [r.base, r.behind, r.ahead])).toEqual([ + [null, null, null], + ["origin/main", 1, 0], + ]); expect(m.not_found).toEqual([]); expect(m.complete).toBe(true); }); @@ -1766,16 +1818,17 @@ describe("measureBoard: the repos section", () => { 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", - }, - ]); + expect(m.repos[0]).toMatchObject({ base: null, behind: null, ahead: null, fetched_at: null }); + const reason = "refs/remotes/origin/HEAD names refs/heads/old, which is not a branch of origin"; + expect(m.not_found).toEqual( + ["base", "behind", "ahead", "fetched_at"].map((field) => ({ + at: `repos[app].${field}`, + reason, + })), + ); }); - it("does not measure behind_main past an origin/HEAD git cannot read, even with an origin/main", async () => { + it("does not measure the base 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]) { @@ -1794,13 +1847,20 @@ describe("measureBoard: the repos section", () => { ]), ); const m = await measureRepos("looped", "odd"); - expect(m.repos.map((r) => r.behind_main)).toEqual([null, null]); + expect(m.repos.map((r) => [r.base, r.behind, r.ahead, r.fetched_at])).toEqual([ + [null, null, null, null], + [null, null, null, null], + ]); + const fields = ["base", "behind", "ahead", "fetched_at"]; expect(m.not_found).toEqual([ - { at: "repos[looped].behind_main", reason: "refs/remotes/origin/HEAD could not be read" }, - { - at: "repos[odd].behind_main", + ...fields.map((field) => ({ + at: `repos[looped].${field}`, + reason: "refs/remotes/origin/HEAD could not be read", + })), + ...fields.map((field) => ({ + at: `repos[odd].${field}`, reason: "the branch refs/remotes/origin/HEAD names is not valid UTF-8", - }, + })), ]); expect(m.complete).toBe(false); }); @@ -1847,12 +1907,17 @@ describe("measureBoard: the repos section", () => { commits: null, files: 3, uncommitted: 0, - behind_main: null, + // The base is told in a shallow clone; a clone writes no FETCH_HEAD. + base: "origin/main", + behind: null, + ahead: null, + fetched_at: null, }); const cut = "the repo is a shallow clone, so its history is cut short"; expect(m.not_found).toEqual([ { at: "repos[app].commits", reason: cut }, - { at: "repos[app].behind_main", reason: cut }, + { at: "repos[app].behind", reason: cut }, + { at: "repos[app].ahead", reason: cut }, { at: "effort.commits[app]", reason: cut }, ]); }); @@ -1863,11 +1928,18 @@ describe("measureBoard: the repos section", () => { const broken = await repo("broken", { "a.txt": "1\n" }); await writeFile(join(broken, ".git", "HEAD"), `${"1234567890".repeat(4)}\n`); const m = await measureRepos("odd", "broken"); - expect(m.repos[0]).toMatchObject({ commits: 1, behind_main: null }); + expect(m.repos[0]).toMatchObject({ + commits: 1, + base: "origin/main", + behind: null, + ahead: null, + fetched_at: null, + }); expect(m.repos[1]).toEqual({ path: "broken", ...NOTHING, name: "broken", files: 1 }); const noCommit = "HEAD does not name a commit"; expect(m.not_found).toEqual([ - { at: "repos[odd].behind_main", reason: "refs/remotes/origin/main is not a commit" }, + { at: "repos[odd].behind", reason: "refs/remotes/origin/main is not a commit" }, + { at: "repos[odd].ahead", reason: "refs/remotes/origin/main is not a commit" }, { at: "repos[broken].head", reason: noCommit }, { at: "repos[broken].last_commit", reason: noCommit }, { at: "repos[broken].commits", reason: noCommit }, @@ -1875,7 +1947,8 @@ describe("measureBoard: the repos section", () => { at: "repos[broken].uncommitted", reason: "the working tree could not be compared by git", }, - { at: "repos[broken].behind_main", reason: noCommit }, + { at: "repos[broken].behind", reason: noCommit }, + { at: "repos[broken].ahead", reason: noCommit }, { at: "effort.commits[broken]", reason: noCommit }, ]); }); @@ -1920,7 +1993,13 @@ describe("measureBoard: the repos section", () => { await writeFile(join(app, "src/f.txt"), "changed\n"); const m = await measureRepos("app"); expect(hasObject(app, old)).toBe(false); - expect(m.repos[0]).toMatchObject({ commits: 2, files: 1, behind_main: 0 }); + expect(m.repos[0]).toMatchObject({ + commits: 2, + files: 1, + base: "origin/main", + behind: 0, + ahead: 0, + }); if (REFUSES_LAZY_FETCH) { expect(m.repos[0]?.uncommitted).toBe(1); expect(m.not_found).toEqual([]); @@ -1955,10 +2034,12 @@ describe("measureBoard: the repos section", () => { git(app, "rev-parse", "HEAD^{tree}").trim(), ); const odd = await measureRepos("app"); - expect(odd.not_found).toContainEqual({ - at: "repos[app].behind_main", - reason: "refs/remotes/origin/main is not a commit, or is not in the repository", - }); + for (const field of ["behind", "ahead"]) { + expect(odd.not_found).toContainEqual({ + at: `repos[app].${field}`, + reason: "refs/remotes/origin/main is not a commit, or is not in the repository", + }); + } }); }, ); @@ -2111,7 +2192,7 @@ describe("measureBoard: the repos section", () => { const dir = await repo("app", { "a.txt": "1\n" }); git(dir, "update-ref", "refs/remotes/origin/main/x", "HEAD"); const m = await measureRepos("app"); - expect(m.repos[0]?.behind_main).toBeNull(); + expect(m.repos[0]).toMatchObject({ base: null, behind: null, ahead: null }); expect(m.not_found).toEqual([]); }); @@ -2121,7 +2202,10 @@ describe("measureBoard: the repos section", () => { const branched = await repo("branched", { "a.txt": "1\n" }); git(branched, "branch", "refs/remotes/origin/main"); const m = await measureRepos("tagged", "branched"); - expect(m.repos.map((r) => r.behind_main)).toEqual([null, null]); + expect(m.repos.map((r) => [r.base, r.behind, r.ahead])).toEqual([ + [null, null, null], + [null, null, null], + ]); expect(m.not_found).toEqual([]); }); @@ -2145,13 +2229,19 @@ describe("measureBoard: the repos section", () => { `${"1234567890".repeat(4)}\n`, ); const m = await measureRepos("broken", "lost"); - expect(m.repos.map((r) => r.behind_main)).toEqual([null, null]); + expect(m.repos.map((r) => [r.base, r.behind, r.ahead, r.fetched_at])).toEqual([ + ["origin/main", null, null, null], + ["origin/main", null, null, null], + ]); + const isBroken = "refs/remotes/origin/main is a broken ref"; + const lostAt = "refs/remotes/origin/main points at an object that is not in the repository"; expect(m.not_found).toEqual([ - { at: "repos[broken].behind_main", reason: "refs/remotes/origin/main is a broken ref" }, - { - at: "repos[lost].behind_main", - reason: "refs/remotes/origin/main points at an object that is not in the repository", - }, + { at: "repos[broken].behind", reason: isBroken }, + { at: "repos[broken].ahead", reason: isBroken }, + // Which commit the base is at is not known: whether it was fetched cannot be told. + { at: "repos[broken].fetched_at", reason: isBroken }, + { at: "repos[lost].behind", reason: lostAt }, + { at: "repos[lost].ahead", reason: lostAt }, ]); }); @@ -2239,8 +2329,11 @@ describe("measureBoard: the repos section", () => { "main:refs/remotes/origin/main", ); const m = await measureRepos("empty"); - expect(m.repos[0]).toMatchObject({ head: null, commits: 0, behind_main: null }); - expect(m.not_found).toEqual([{ at: "repos[empty].behind_main", reason: SHALLOW }]); + expect(m.repos[0]).toMatchObject({ head: null, commits: 0, behind: null, ahead: null }); + expect(m.not_found).toEqual([ + { at: "repos[empty].behind", reason: SHALLOW }, + { at: "repos[empty].ahead", reason: SHALLOW }, + ]); }); it.skipIf(!POSIX)( @@ -2273,11 +2366,12 @@ describe("measureBoard: the repos section", () => { await repo("app", { "a.txt": "1\n" }); await withWrappedGit(NO_SHALLOW_OPTION, async () => { const m = await measureRepos("app"); - expect(m.repos[0]).toMatchObject({ commits: null, behind_main: null, files: 1 }); + expect(m.repos[0]).toMatchObject({ commits: null, behind: null, ahead: null, files: 1 }); const unknown = "could not tell whether the history of the repo is complete"; expect(m.not_found).toEqual([ { at: "repos[app].commits", reason: unknown }, - { at: "repos[app].behind_main", reason: unknown }, + { at: "repos[app].behind", reason: unknown }, + { at: "repos[app].ahead", reason: unknown }, { at: "effort.commits[app]", reason: unknown }, ]); }); @@ -2317,7 +2411,282 @@ describe("measureBoard: the repos section", () => { git(upstream, "merge", "-q", "--no-ff", "-m", "merge", "side"); git(join(root, "app"), "fetch", "-q"); const m = await measureRepos("app"); - expect(m.repos[0]?.behind_main).toBe(3); + expect(m.repos[0]).toMatchObject({ behind: 3, ahead: 0 }); + }); + + it("counts the commits HEAD has that its base does not, merges and all", async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + git(root, "clone", "-q", upstream, "app"); + const app = join(root, "app"); + git(app, "config", "user.email", "test@example.com"); + git(app, "config", "user.name", "test"); + git(app, "checkout", "-q", "-b", "side"); + await commit(app, { "s.txt": "1\n" }, "side"); + git(app, "checkout", "-q", "main"); + await commit(app, { "m.txt": "1\n" }, "main"); + git(app, "merge", "-q", "--no-ff", "-m", "merge", "side"); + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ base: "origin/main", behind: 0, ahead: 3 }); + }); + + describe("when the base was last fetched", () => { + const AT = new Date("2026-10-04T12:00:00.000Z"); + const LATER = new Date("2026-10-04T13:00:00.000Z"); + + // A clone of a new upstream at `name`, with the upstream: origin/HEAD names main. + async function cloned(name: string): Promise<{ upstream: string; dir: string }> { + const upstream = await repo(`${name}-upstream`, { "a.txt": "1\n" }); + git(upstream, "branch", "feature"); + git(root, "clone", "-q", upstream, name); + return { upstream, dir: join(root, name) }; + } + + function fetchHeadOf(dir: string): string { + return join(dir, git(dir, "rev-parse", "--git-path", "FETCH_HEAD").trim()); + } + + it("is when FETCH_HEAD was written by a fetch of the base from origin, merged into HEAD's branch or not", async () => { + const { upstream, dir } = await cloned("app"); + await commit(upstream, { "b.txt": "1\n" }, "upstream moves on"); + // A branch with no upstream: main is fetched as not for merge. + git(dir, "checkout", "-q", "-b", "topic"); + git(dir, "fetch", "-q"); + expect(await readFile(fetchHeadOf(dir), "utf8")).toContain( + "\tnot-for-merge\tbranch 'main' of ", + ); + await utimes(fetchHeadOf(dir), AT, AT); + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ + branch: "topic", + base: "origin/main", + behind: 1, + ahead: 0, + fetched_at: AT.toISOString(), + }); + expect(m.complete).toBe(true); + }); + + it("is null, with no entry, when FETCH_HEAD does not show the base fetched from origin at its commit", async () => { + // Never fetched since the clone, which writes no FETCH_HEAD. + const never = await cloned("never"); + await expect(stat(join(never.dir, ".git", "FETCH_HEAD"))).rejects.toThrow(); + // The last fetch failed: it emptied FETCH_HEAD and moved its time. + const failed = await cloned("failed"); + git(failed.dir, "fetch", "-q"); + expect(() => + execFileSync("git", ["fetch", "-q", "origin", "nosuch"], { + cwd: failed.dir, + env: GIT_ENV, + stdio: "ignore", + }), + ).toThrow(); + expect((await stat(fetchHeadOf(failed.dir))).size).toBe(0); + // Only another branch was fetched since. + const branch = await cloned("branch"); + git(branch.dir, "fetch", "-q"); + git(branch.dir, "fetch", "-q", "origin", "feature"); + expect(await readFile(fetchHeadOf(branch.dir), "utf8")).not.toContain("branch 'main'"); + // Another remote whose main is at the same commit was fetched since. + const other = await cloned("other"); + git(other.dir, "fetch", "-q"); + git(other.dir, "remote", "add", "copy", join(root, "copy")); + git(root, "clone", "-q", "--bare", other.upstream, "copy"); + git(other.dir, "fetch", "-q", "copy"); + const tip = git(other.dir, "rev-parse", "refs/remotes/origin/main").trim(); + expect(await readFile(fetchHeadOf(other.dir), "utf8")).toContain( + `${tip}\tnot-for-merge\tbranch 'main' of `, + ); + // A push moved origin/main since the last fetch. + const bare = join(root, "pushed.git"); + git(root, "clone", "-q", "--bare", other.upstream, bare); + git(root, "clone", "-q", bare, "pushed"); + const pushed = join(root, "pushed"); + git(pushed, "fetch", "-q"); + git(pushed, "config", "user.email", "test@example.com"); + git(pushed, "config", "user.name", "test"); + await commit(pushed, { "p.txt": "1\n" }, "pushed"); + git(pushed, "push", "-q", "origin", "main"); + for (const dir of [failed.dir, branch.dir, other.dir, pushed]) { + await utimes(fetchHeadOf(dir), AT, AT); + } + const m = await measureRepos("never", "failed", "branch", "other", "pushed"); + expect(m.repos.map((r) => [r.base, r.behind, r.fetched_at])).toEqual([ + ["origin/main", 0, null], + ["origin/main", 0, null], + ["origin/main", 0, null], + ["origin/main", 0, null], + ["origin/main", 0, null], + ]); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + }); + + it("reads the FETCH_HEAD of the worktree measured, not a fetch in another worktree", async () => { + const { upstream, dir } = await cloned("app"); + git(dir, "fetch", "-q"); + git(dir, "worktree", "add", "-q", "-b", "side", join(root, "wt")); + await commit(upstream, { "b.txt": "1\n" }, "upstream moves on"); + const wt = join(root, "wt"); + git(wt, "fetch", "-q"); + // Where a linked worktree's FETCH_HEAD is, git gives as an absolute path. + const own = git(wt, "rev-parse", "--git-path", "FETCH_HEAD").trim(); + expect(isAbsolute(own)).toBe(true); + await utimes(own, AT, AT); + await utimes(fetchHeadOf(dir), LATER, LATER); + const m = await measureRepos("app", "wt"); + // The fetch in wt moved origin/main past the commit app's FETCH_HEAD holds. + expect(m.repos.map((r) => [r.base, r.behind, r.fetched_at])).toEqual([ + ["origin/main", 1, null], + ["origin/main", 1, AT.toISOString()], + ]); + expect(m.not_found).toEqual([]); + }); + + it.skipIf(!POSIX)( + "takes origin's URL as git writes it in FETCH_HEAD: without a user, slashes or .git at its end", + async () => { + const upstream = await repo("upstream", { "a.txt": "1\n" }); + // A directory with '@' in its name, under one without. + const at = join(root, "a@b"); + await mkdir(at); + git(at, "clone", "-q", "--bare", upstream, "up.git"); + const bare = join(at, "up.git"); + // An ssh that runs the command it is given here, so nothing is sent. + const ssh = join(root, "fake-ssh"); + await writeFile( + ssh, + '#!/bin/sh\nfor last; do :; done\nPATH="$(git --exec-path):$PATH" exec sh -c "$last"\n', + ); + await chmod(ssh, 0o755); + const env = { ...GIT_ENV, GIT_SSH_COMMAND: ssh, GIT_SSH_VARIANT: "simple" }; + const urls: Record = { + scp: `me@fakehost:${bare}/`, + password: `ssh://me:secret@fakehost${bare}`, + scheme: `git+ssh://me@fakehost${bare}`, + // '@' in the path, after the first '/': kept as it is. + path: `ssh://fakehost${bare}`, + // '@' with no ':' after it: kept as it is. + scpPath: `fakehost:${bare}`, + local: `${bare}/`, + }; + for (const [name, url] of Object.entries(urls)) { + const dir = join(root, name); + await mkdir(dir); + git(dir, "init", "-q", "-b", "main"); + git(dir, "remote", "add", "origin", url); + execFileSync("git", ["fetch", "-q", "origin"], { cwd: dir, env, stdio: "ignore" }); + await utimes(fetchHeadOf(dir), AT, AT); + } + const written = await readFile(fetchHeadOf(join(root, "password")), "utf8"); + expect(written).toContain(`branch 'main' of ssh://fakehost${join(at, "up")}\n`); + const m = await measureRepos(...Object.keys(urls)); + expect(m.repos.map((r) => [r.path, r.base, r.fetched_at])).toEqual( + Object.keys(urls).map((name) => [name, "origin/main", AT.toISOString()]), + ); + expect(m.not_found).toEqual([]); + }, + ); + + it("does not take a FETCH_HEAD line of another URL, or of a tag, for the base's", async () => { + const dir = await repo("app", { "a.txt": "1\n" }); + const tip = git(dir, "rev-parse", "HEAD").trim(); + git(dir, "update-ref", "refs/remotes/origin/main", tip); + git(dir, "remote", "add", "origin", "https://user:token@example.com/x/app.git/"); + const fetchHead = join(dir, ".git", "FETCH_HEAD"); + const measured = async (lines: string[]) => { + await writeFile(fetchHead, lines.map((l) => `${l}\n`).join("")); + await utimes(fetchHead, AT, AT); + return (await measureRepos("app")).repos[0]?.fetched_at; + }; + // As git writes it: the user and the token, the slash and .git left out. + expect(await measured([`${tip}\t\tbranch 'main' of https://example.com/x/app`])).toBe( + AT.toISOString(), + ); + expect( + await measured([`${tip}\t\tbranch 'main' of https://example.com/x/app.git`]), + ).toBeNull(); + expect(await measured([`${tip}\t\ttag 'main' of https://example.com/x/app`])).toBeNull(); + expect( + await measured([`${tip}\tnot-for-merge\tbranch 'main' of https://example.com/x/apps`]), + ).toBeNull(); + // A scheme git would not take as one keeps the user. + git(dir, "remote", "set-url", "origin", "my_scheme://user@example.com/x/app"); + expect( + await measured([`${tip}\t\tbranch 'main' of my_scheme://example.com/x/app`]), + ).toBeNull(); + expect( + await measured([`${tip}\t\tbranch 'main' of my_scheme://user@example.com/x/app`]), + ).toBe(AT.toISOString()); + // Every slash at the end goes before the .git. + git(dir, "remote", "set-url", "origin", "https://example.com/x/app.git//"); + expect(await measured([`${tip}\t\tbranch 'main' of https://example.com/x/app`])).toBe( + AT.toISOString(), + ); + // A local path, with a '/' before its first ':', keeps what has an '@'. + git(dir, "remote", "set-url", "origin", "/srv/a@b:c/app.git"); + expect(await measured([`${tip}\t\tbranch 'main' of /srv/a@b:c/app`])).toBe(AT.toISOString()); + // A name of five characters keeps its .git. + git(dir, "remote", "set-url", "origin", "a.git"); + expect(await measured([`${tip}\t\tbranch 'main' of a`])).toBeNull(); + expect(await measured([`${tip}\t\tbranch 'main' of a.git`])).toBe(AT.toISOString()); + }); + + it.skipIf(!POSIX || ROOT_USER)("says why when FETCH_HEAD cannot be read", async () => { + const locked = await cloned("locked"); + git(locked.dir, "fetch", "-q"); + await chmod(fetchHeadOf(locked.dir), 0o000); + const folder = await cloned("folder"); + await mkdir(join(folder.dir, ".git", "FETCH_HEAD")); + try { + const m = await measureRepos("locked", "folder"); + expect(m.repos.map((r) => [r.base, r.behind, r.fetched_at])).toEqual([ + ["origin/main", 0, null], + ["origin/main", 0, null], + ]); + expect(m.not_found).toEqual([ + { at: "repos[locked].fetched_at", reason: "FETCH_HEAD could not be read (EACCES)" }, + { at: "repos[folder].fetched_at", reason: "FETCH_HEAD could not be read (EISDIR)" }, + ]); + expect(m.complete).toBe(false); + } finally { + await chmod(fetchHeadOf(locked.dir), 0o644); + } + }); + + it.skipIf(!POSIX)("says why when git cannot say where FETCH_HEAD is", async () => { + const { dir } = await cloned("app"); + git(dir, "fetch", "-q"); + await withWrappedGit('for a in "$@"; do [ "$a" = --git-path ] && exit 1; done', async () => { + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ base: "origin/main", behind: 0, fetched_at: null }); + expect(m.not_found).toEqual([ + { at: "repos[app].fetched_at", reason: "where FETCH_HEAD is could not be read" }, + ]); + }); + }); + + it("is left out of the digest: a fetch that brings nothing, or fails, does not move it", async () => { + const { dir } = await cloned("app"); + git(dir, "fetch", "-q"); + await utimes(fetchHeadOf(dir), AT, AT); + const declaration = declare([], {}, ["app"]); + const first = await measure(declaration, NOW, ["app"]); + expect(first.repos[0]?.fetched_at).toBe(AT.toISOString()); + await utimes(fetchHeadOf(dir), LATER, LATER); + const later = await measure(declaration, NOW, ["app"]); + expect(later.repos[0]?.fetched_at).toBe(LATER.toISOString()); + expect(later.digest).toBe(first.digest); + expect(() => + execFileSync("git", ["fetch", "-q", "origin", "nosuch"], { + cwd: dir, + env: GIT_ENV, + stdio: "ignore", + }), + ).toThrow(); + const failed = await measure(declaration, NOW, ["app"]); + expect(failed.repos[0]?.fetched_at).toBeNull(); + expect(failed.digest).toBe(first.digest); + }); }); it("ignores a GIT_DIR in the environment", async () => { diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index 9f0d018f..7aea6685 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -82,11 +82,12 @@ export type BoardRatioValue = { * measured, and then `not_found` says why and `complete` is false: a missing * 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 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 + * `complete` as it is: those in the repos section (a detached HEAD, no commit + * yet, no default branch of origin, no fetch of it on record; 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 * components' kind changes when there is no previous record (see * {@link BoardComponents}), the axis's last review when there is none on * record (see {@link BoardAxis}), and the diff when there is no previous @@ -102,16 +103,17 @@ export type BoardMeasurement = { /** * `sha256:` and the hex digest of the measurement without `measured_at`, * `digest`, `freshness`, `axis` and its not_found entries, `diff`, - * `effort.elapsed_days` and today's row of `effort.daily`, with the - * declaration it was measured by (see {@link boardDigest}), serialized with - * its keys sorted. Two measurements with the same values by the same - * declaration have the same digest whenever they were taken. The freshness - * moves as work goes on, the measuring session's own included, the axis - * rests on the day, the model and the records, the diff is derived from the - * previous record, and the others move with the clock, so they are left - * out. The declaration is in it because a record keeps the declaration it - * was measured by: one changed after the judgement would be kept as if the - * judgement had seen it. + * `effort.elapsed_days`, today's row of `effort.daily` and each repo's + * `fetched_at`, with the declaration it was measured by (see + * {@link boardDigest}), serialized with its keys sorted. Two measurements + * with the same values by the same declaration have the same digest + * whenever they were taken. The freshness moves as work goes on, the + * measuring session's own included, the axis rests on the day, the model + * and the records, the diff is derived from the previous record, a fetch + * that brings nothing still moves `fetched_at`, and the others move with + * the clock, so they are left out. The declaration is in it because a + * record keeps the declaration it was measured by: one changed after the + * judgement would be kept as if the judgement had seen it. */ digest: string; /** @@ -373,17 +375,18 @@ export async function measureBoard(input: MeasureBoardInput): Promise !UNDIGESTED.has(key)) .map(([key, value]) => [ key, - key === "effort" ? withoutClock(value) : key === "not_found" ? notOfAxis(value) : value, + key === "effort" + ? withoutClock(value) + : key === "not_found" + ? notOfAxis(value) + : key === "repos" + ? withoutFetchTimes(value) + : value, ]), ); hashed.declaration = declaration; @@ -413,6 +422,18 @@ function notOfAxis(notFound: unknown): unknown { }); } +// The repos without when each was last fetched, which a fetch that brings +// nothing moves too. Its not_found entries stay: they follow a ref or a file +// that cannot be read, not a fetch. +function withoutFetchTimes(repos: unknown): unknown { + if (!Array.isArray(repos)) return repos; + return repos.map((repo) => { + if (repo === null || typeof repo !== "object") return repo; + const { fetched_at: _fetched, ...rest } = repo as Record; + return rest; + }); +} + // The effort section without what moves with the clock: the days elapsed, and // today's row, which a new day replaces. function withoutClock(effort: unknown): unknown { diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index c4d6dd0e..17f567a1 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -31,7 +31,10 @@ function isRecord(value: unknown): value is Record { * total of other measures and integers (`total`), draw a measure in more * than one lane and name a measure or an observation with '.'; and in 3 a * measure's `lane`, in the measurement and in the declaration, is always a - * list of lane ids, where 1 and 2 hold one id. + * list of lane ids, where 1 and 2 hold one id, and each repo of the + * measurement names the branch it is compared with (`base`), counts what + * is behind it as `behind`, where 1 and 2 have `behind_main`, and what is + * ahead of it (`ahead`), and says when it was last fetched (`fetched_at`). * Every record is written in the newest version, whatever the version of the * board it records, and every version is read. */ diff --git a/packages/core/src/board/repos.ts b/packages/core/src/board/repos.ts index e01b2934..8c856750 100644 --- a/packages/core/src/board/repos.ts +++ b/packages/core/src/board/repos.ts @@ -1,8 +1,11 @@ -import { basename, resolve } from "node:path"; +import { type FileHandle, open } from "node:fs/promises"; +import { basename, isAbsolute, resolve, sep } from "node:path"; import { blockedAmong, + codeReason, type GitResult, gitCanRefuseLazyFetch, + isAbsent, isPartialClone, locateRepoRoot, type RepoScopeResult, @@ -24,11 +27,13 @@ export const BOARD_REPOS_METHOD = 2; * * A `null` means the value could not be measured, and then `not_found` has an * entry at `repos[].`, or at `repos[]` when nothing of the - * repository could be measured. Three values are `null` with a meaning of + * repository could be measured. Some 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` names no branch and there is - * no `refs/remotes/origin/main`. + * `base`, `behind` and `ahead` when `refs/remotes/origin/HEAD` names no + * branch and there is no `refs/remotes/origin/main`; `fetched_at` when + * FETCH_HEAD does not show the base fetched from origin at the commit it is + * at now. */ export type BoardRepo = { /** The path as the manifest writes it. */ @@ -55,13 +60,31 @@ export type BoardRepo = { */ uncommitted: number | null; /** - * 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). + * The branch of origin HEAD is compared with, as `origin/`: the one + * `refs/remotes/origin/HEAD` names (set by a clone, or by + * `git remote set-head origin --auto`), or `origin/main` when it names none + * (it is not set, or is not a symbolic ref). */ - behind_main: number | null; + base: string | null; + /** The commits on `base` HEAD does not have, as of the last fetch. */ + behind: number | null; + /** + * The commits HEAD has that `base` does not, as of the last fetch. Not only + * those not pushed: those of a branch pushed and not merged into `base`, + * and those that came into it as other commits (by a squash or a rebase). + */ + ahead: number | null; + /** + * When FETCH_HEAD (this worktree's) was last written, in UTC, if it holds + * the line of `base`'s branch fetched from origin's URL at the commit + * `base` is at now: when this checkout last fetched it. Null otherwise: no + * fetch since the clone, a fetch that failed (it empties FETCH_HEAD), a + * fetch since of another remote or of other branches only, or `base` + * moved since without a fetch here (a push, or a fetch in another + * worktree). Neither in the digest nor compared with the previous record, + * as a fetch that brings nothing moves it too. + */ + fetched_at: string | null; }; /** @@ -169,6 +192,22 @@ async function defaultBranchOf( return { kind: "error", reason: failure(named, `${ORIGIN_HEAD} could not be read`) }; } +type Base = + | { kind: "none" } + | { kind: "branch"; name: string; ref: Ref } + | { kind: "error"; reason: string }; + +// The branch of origin HEAD is compared with, by its full name, and the ref +// as it was read; `none` when origin/HEAD names no branch and there is no +// origin/main, which is what a null base means. +async function baseOf(cwd: string): Promise { + const target = await defaultBranchOf(cwd); + if (target.kind === "error") return target; + const ref = await readRef(cwd, target.name); + if (ref.kind === "missing" && !target.named) return { kind: "none" }; + return { kind: "branch", name: target.name, ref }; +} + // 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 { @@ -217,7 +256,10 @@ export async function measureRepos( commits: null, files: null, uncommitted: null, - behind_main: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, }; result.repos.push(repo); const located = await locateRepoRoot(resolve(root, path)); @@ -361,47 +403,167 @@ export async function measureRepos( } } - if (history !== undefined) fail("behind_main", history); - else if (head.kind === "error") fail("behind_main", head.reason); - else { - const behind = await behindDefault(cwd, head, partial); - if (behind.ok) repo.behind_main = behind.count; - else fail("behind_main", behind.reason); + const base = await baseOf(cwd); + if (base.kind === "error") fail("base", base.reason); + else if (base.kind === "branch") repo.base = base.name.slice(REMOTES.length); + + const compared = + history !== undefined + ? both({ ok: false, reason: history }) + : head.kind === "error" + ? both({ ok: false, reason: head.reason }) + : await againstBase(cwd, head, partial, base); + for (const field of ["behind", "ahead"] as const) { + const counted = compared[field]; + if (counted.ok) repo[field] = counted.count; + else fail(field, counted.reason); + } + + if (base.kind === "error") fail("fetched_at", base.reason); + else if (base.kind === "branch") { + const fetched = await fetchedAt(cwd, located.root, base.name, base.ref); + if (fetched.ok) repo.fetched_at = fetched.at; + else fail("fetched_at", fetched.reason); } } return result; } -// 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( +const REMOTES = "refs/remotes/"; + +type Counted = { ok: true; count: number | null } | { ok: false; reason: string }; + +const both = (counted: Counted): { behind: Counted; ahead: Counted } => ({ + behind: counted, + ahead: counted, +}); + +// The commits on the base HEAD does not have, and those HEAD has that the +// base does not: null when there is no base, which is what the null means; +// or why they could not be counted. +async function againstBase( 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 }; + base: Base, +): Promise<{ behind: Counted; ahead: Counted }> { + if (base.kind === "error") return both({ ok: false, reason: base.reason }); + if (base.kind === "none") return both({ ok: true, count: null }); + const { name, ref } = base; + if (ref.kind === "error") return both({ ok: false, reason: ref.reason }); + // Missing only when origin/HEAD names it: then there is no origin/main to fall back to. if (ref.kind === "missing") { - return target.named - ? { ok: false, reason: `${ORIGIN_HEAD} names ${target.name}, which is not there` } - : { ok: true, count: null }; + return both({ ok: false, reason: `${ORIGIN_HEAD} names ${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) }; + return both({ ok: false, reason: await notACommit(cwd, 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`), + const tip = line(commit); + if (head.kind === "unborn") { + return { + behind: await countCommits(cwd, tip, `the commits behind ${name} could not be counted`), + ahead: { ok: true, count: 0 }, + }; + } + return { + behind: await countCommits( + cwd, + `${head.oid}..${tip}`, + `the commits behind ${name} could not be counted`, + ), + ahead: await countCommits( + cwd, + `${tip}..${head.oid}`, + `the commits ahead of ${name} could not be counted`, + ), + }; +} + +async function countCommits(cwd: string, range: string, reason: string): Promise { + const listed = await runGit(cwd, ["rev-list", "--count", range, "--"]); + const n = count(listed); + return n === undefined ? { ok: false, reason: failure(listed, reason) } : { ok: true, count: n }; +} + +// When this checkout last fetched the base `name`, at `ref`, from origin: the +// time FETCH_HEAD was last written, when it holds the line git writes for +// that branch of origin's URL at the commit the base is at now. Null when it +// does not (a failed fetch leaves it empty but still moves its time), or +// when there is no such commit or no URL of origin to look for; or why it +// could not be told. +async function fetchedAt( + cwd: string, + root: Buffer, + name: string, + ref: Ref, +): Promise<{ ok: true; at: string | null } | { ok: false; reason: string }> { + if (ref.kind === "error") return { ok: false, reason: ref.reason }; + if (ref.kind === "missing") return { ok: true, at: null }; + const { oid } = ref; + const where = await runGit(cwd, ["rev-parse", "--git-path", "FETCH_HEAD"]); + if (where.code !== 0) { + return { ok: false, reason: failure(where, "where FETCH_HEAD is could not be read") }; + } + const url = await runGit(cwd, ["remote", "get-url", "origin"]); + if (url.spawnError !== undefined) return { ok: false, reason: url.spawnError }; + // origin has no URL (it is not a remote): no line can be from it. git reads + // its config first, so one it cannot read has stopped the commands before. + if (url.code !== 0) return { ok: true, at: null }; + // Relative to the root, except in a linked worktree. + const given = lineBytes(where); + const path = isAbsolute(given.toString("latin1")) + ? given + : Buffer.concat([root, Buffer.from(sep), given]); + let handle: FileHandle; + try { + handle = await open(path, "r"); + } catch (error: unknown) { + return isAbsent(error) + ? { ok: true, at: null } + : { ok: false, reason: `FETCH_HEAD ${codeReason(error)}` }; + } + try { + const written = (await handle.stat()).mtime; + const lines = (await handle.readFile()).toString("latin1").split("\n"); + // As bytes, one character each: a branch name is UTF-8. + const branch = Buffer.from(name.slice(ORIGIN_BRANCHES.length), "utf8").toString("latin1"); + const note = `branch '${branch}' of ${fetchHeadUrl(lineBytes(url).toString("latin1"))}`; + const found = lines.some( + (l) => l === `${oid}\t\t${note}` || l === `${oid}\tnot-for-merge\t${note}`, + ); + return { ok: true, at: found ? written.toISOString() : null }; + } catch (error: unknown) { + return { ok: false, reason: `FETCH_HEAD ${codeReason(error)}` }; + } finally { + await handle.close(); + } +} + +// A URL as git writes it in FETCH_HEAD: without the user (and password) a +// URL with a scheme or one of the form `user@host:path` names, then without +// the slashes at its end, then without one `.git` at its end. +function fetchHeadUrl(url: string): string { + let shown = url; + const at = url.indexOf("@"); + const colon = url.indexOf(":"); + const slash = url.indexOf("/"); + // A local path has no ':', or a '/' before its first one. + if (at !== -1 && colon !== -1 && (slash === -1 || colon < slash)) { + const rest = url.slice(at + 1); + const scheme = url.indexOf("://"); + if (scheme === -1) { + if (rest.includes(":")) shown = rest; + } else { + const path = url.indexOf("/", scheme + 3); + // A scheme of letters, digits, '+', '.' and '-', and the '@' before the path. + if (/^[A-Za-z0-9+.-]*$/.test(url.slice(0, scheme)) && (path === -1 || at < path)) { + shown = url.slice(0, scheme + 3) + rest; } - : { ok: true, count: n }; + } + } + const trimmed = shown.replace(/\/+$/, ""); + return trimmed.length > 5 && trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; } // Why the default branch `name`, which names `oid`, gives no commit. Whether diff --git a/packages/core/src/board/scope.ts b/packages/core/src/board/scope.ts index 96bebeec..7102df23 100644 --- a/packages/core/src/board/scope.ts +++ b/packages/core/src/board/scope.ts @@ -180,14 +180,14 @@ function splitNul(buf: Buffer): string[] { .filter((s) => s.length > 0); } -function codeReason(error: unknown): string { +export function codeReason(error: unknown): string { const code = ["EACCES", "EPERM", "EIO", "ELOOP", "EISDIR", "EMFILE"].find((c) => findErrorCode(error, c), ); return code === undefined ? "could not be read" : `could not be read (${code})`; } -function isAbsent(error: unknown): boolean { +export function isAbsent(error: unknown): boolean { return findErrorCode(error, "ENOENT") || findErrorCode(error, "ENOTDIR"); } diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index e8ef6af5..9f754dec 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1034,7 +1034,10 @@ export type BoardPageStrings = { lastCommit: string; commits: string; uncommitted: string; - behindMain: string; + base: string; + behind: string; + ahead: string; + fetchedAt: string; trail: string; decisions: string; decisionsAll: string; @@ -1205,7 +1208,10 @@ const BOARD_PAGE_EN: BoardPageStrings = { lastCommit: "Last commit", commits: "Commits", uncommitted: "Uncommitted", - behindMain: "Behind origin's default branch", + base: "Base branch", + behind: "Behind", + ahead: "Ahead", + fetchedAt: "Last fetch", trail: "Trail", decisions: "Decisions", decisionsAll: "{n} in all, voided ones included", @@ -1374,7 +1380,10 @@ const BOARD_PAGE_JA: BoardPageStrings = { lastCommit: "最終 commit", commits: "commit 数", uncommitted: "未 commit", - behindMain: "origin の既定 branch からの遅れ", + base: "比べる branch", + behind: "遅れ", + ahead: "進み", + fetchedAt: "最後の fetch", trail: "証跡", decisions: "判断", decisionsAll: "取り消したものを含めて {n}", From 345c41c8209ead55b2dfaa09598c1d7c4ff13b3d Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sun, 18 Oct 2026 12:59:15 +0900 Subject: [PATCH 2/2] basou: Pin down how a board's repos match FETCH_HEAD, and say when fetched_at moves without a fetch of origin Review follow-ups. A git remote get-url that ends with no exit code (it could not be started, or a signal stopped it) is now a gap, not a null that says origin has no URL. The docs, the guide and the changelog say that a later fetch with --append moves fetched_at and leaves origin's line, that a remote at origin's URL writes the same line, and that what is told apart is another URL rather than another remote; the guide no longer calls fetched_at the time of the last fetch outright. New tests pin a branch and a URL that are not ASCII, removing only one .git, the not_found entries of fetched_at staying in the digest, the remote get-url gap, and the two cases above. The changelog entry is rewrapped. --- CHANGELOG.md | 22 ++++--- packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 2 +- packages/core/src/board/measure.test.ts | 87 ++++++++++++++++++++++++- packages/core/src/board/repos.ts | 17 +++-- 5 files changed, 111 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5957b3d..51c5288f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,16 +128,18 @@ All notable changes to **basou** are recorded here. The project follows 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 of another remote or of other branches only, or a push or a - fetch in another worktree that moved the base since. `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 - the four on each repo's line, 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. + 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`, diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index a455a849..3105789f 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -322,7 +322,7 @@ describe("boardGuide: a board declared", () => { 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[].base` is the branch of origin a repo is compared with (`origin/`: 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 this checkout last fetched that branch from origin, null when FETCH_HEAD does not show it at the commit it is at now (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.", + "- `repos[].base` is the branch of origin a repo is compared with (`origin/`: 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.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 8d5c725d..8e3223ad 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -194,7 +194,7 @@ export function boardGuide(input: BoardGuideInput): string { "- 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.", "- 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[].base` is the branch of origin a repo is compared with (`origin/`: 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 this checkout last fetched that branch from origin, null when FETCH_HEAD does not show it at the commit it is at now (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.", + "- `repos[].base` is the branch of origin a repo is compared with (`origin/`: 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.", "", ); diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 4d907dc7..ca0f1387 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -2486,7 +2486,7 @@ describe("measureBoard: the repos section", () => { git(branch.dir, "fetch", "-q"); git(branch.dir, "fetch", "-q", "origin", "feature"); expect(await readFile(fetchHeadOf(branch.dir), "utf8")).not.toContain("branch 'main'"); - // Another remote whose main is at the same commit was fetched since. + // Another remote, at another URL, whose main is at the same commit was fetched since. const other = await cloned("other"); git(other.dir, "fetch", "-q"); git(other.dir, "remote", "add", "copy", join(root, "copy")); @@ -2625,6 +2625,12 @@ describe("measureBoard: the repos section", () => { // A local path, with a '/' before its first ':', keeps what has an '@'. git(dir, "remote", "set-url", "origin", "/srv/a@b:c/app.git"); expect(await measured([`${tip}\t\tbranch 'main' of /srv/a@b:c/app`])).toBe(AT.toISOString()); + // Only one .git goes. + git(dir, "remote", "set-url", "origin", "https://example.com/x/app.git.git"); + expect(await measured([`${tip}\t\tbranch 'main' of https://example.com/x/app`])).toBeNull(); + expect(await measured([`${tip}\t\tbranch 'main' of https://example.com/x/app.git`])).toBe( + AT.toISOString(), + ); // A name of five characters keeps its .git. git(dir, "remote", "set-url", "origin", "a.git"); expect(await measured([`${tip}\t\tbranch 'main' of a`])).toBeNull(); @@ -2653,6 +2659,85 @@ describe("measureBoard: the repos section", () => { } }); + it("matches a branch and a URL of origin that are not ASCII, as git writes them", async () => { + // An upstream under a directory not named in ASCII, whose default branch is not ASCII either. + await mkdir(join(root, "\u4e0a\u6d41")); + const upstream = await repo(join("\u4e0a\u6d41", "up"), { "a.txt": "1\n" }); + git(upstream, "branch", "-m", "main", "d\u00e9v'x"); + git(root, "clone", "-q", upstream, "app"); + const dir = join(root, "app"); + git(dir, "fetch", "-q"); + await utimes(fetchHeadOf(dir), AT, AT); + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ + base: "origin/d\u00e9v'x", + behind: 0, + ahead: 0, + fetched_at: AT.toISOString(), + }); + expect(m.not_found).toEqual([]); + }); + + it("takes a later fetch with --append, which leaves origin's line, for the last fetch", async () => { + const { dir } = await cloned("app"); + git(dir, "fetch", "-q"); + await utimes(fetchHeadOf(dir), AT, AT); + git(dir, "remote", "add", "copy", join(root, "copy")); + git(root, "clone", "-q", "--bare", join(root, "app-upstream"), "copy"); + git(dir, "fetch", "-q", "--append", "copy"); + await utimes(fetchHeadOf(dir), LATER, LATER); + const m = await measureRepos("app"); + expect(m.repos[0]?.fetched_at).toBe(LATER.toISOString()); + }); + + it("takes a fetch from another remote at origin's URL for one from origin", async () => { + const { upstream, dir } = await cloned("app"); + git(dir, "remote", "add", "mirror", upstream); + git(dir, "fetch", "-q", "mirror"); + await utimes(fetchHeadOf(dir), AT, AT); + const m = await measureRepos("app"); + expect(m.repos[0]?.fetched_at).toBe(AT.toISOString()); + }); + + it.skipIf(!POSIX || ROOT_USER)( + "keeps why fetched_at was not measured in the digest", + async () => { + const { dir } = await cloned("app"); + const fetchHead = join(dir, ".git", "FETCH_HEAD"); + const declaration = declare([], {}, ["app"]); + await mkdir(fetchHead); + const folder = await measure(declaration, NOW, ["app"]); + await rm(fetchHead, { recursive: true }); + await writeFile(fetchHead, ""); + await chmod(fetchHead, 0o000); + try { + const locked = await measure(declaration, NOW, ["app"]); + expect(folder.not_found).toEqual([ + { at: "repos[app].fetched_at", reason: "FETCH_HEAD could not be read (EISDIR)" }, + ]); + expect(locked.not_found).toEqual([ + { at: "repos[app].fetched_at", reason: "FETCH_HEAD could not be read (EACCES)" }, + ]); + expect(locked.repos).toEqual(folder.repos); + expect(locked.digest).not.toBe(folder.digest); + } finally { + await chmod(fetchHead, 0o644); + } + }, + ); + + it.skipIf(!POSIX)("says why when git stops before telling the URL of origin", async () => { + const { dir } = await cloned("app"); + git(dir, "fetch", "-q"); + await withWrappedGit('for a in "$@"; do [ "$a" = get-url ] && kill -9 $$; done', async () => { + const m = await measureRepos("app"); + expect(m.repos[0]).toMatchObject({ base: "origin/main", behind: 0, fetched_at: null }); + expect(m.not_found).toEqual([ + { at: "repos[app].fetched_at", reason: "the URL of origin could not be read" }, + ]); + }); + }); + it.skipIf(!POSIX)("says why when git cannot say where FETCH_HEAD is", async () => { const { dir } = await cloned("app"); git(dir, "fetch", "-q"); diff --git a/packages/core/src/board/repos.ts b/packages/core/src/board/repos.ts index 8c856750..fe920f14 100644 --- a/packages/core/src/board/repos.ts +++ b/packages/core/src/board/repos.ts @@ -77,12 +77,14 @@ export type BoardRepo = { /** * When FETCH_HEAD (this worktree's) was last written, in UTC, if it holds * the line of `base`'s branch fetched from origin's URL at the commit - * `base` is at now: when this checkout last fetched it. Null otherwise: no + * `base` is at now: as a rule, when this checkout last fetched it. 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. Null otherwise: no * fetch since the clone, a fetch that failed (it empties FETCH_HEAD), a - * fetch since of another remote or of other branches only, or `base` - * moved since without a fetch here (a push, or a fetch in another - * worktree). Neither in the digest nor compared with the previous record, - * as a fetch that brings nothing moves it too. + * fetch since from another URL or of other branches only, or `base` moved + * since without a fetch here (a push, or a fetch in another worktree). + * Neither in the digest nor compared with the previous record, as a fetch + * that brings nothing moves it too. */ fetched_at: string | null; }; @@ -506,7 +508,10 @@ async function fetchedAt( return { ok: false, reason: failure(where, "where FETCH_HEAD is could not be read") }; } const url = await runGit(cwd, ["remote", "get-url", "origin"]); - if (url.spawnError !== undefined) return { ok: false, reason: url.spawnError }; + // No exit code: git could not be started, or a signal stopped it. + if (url.code === null) { + return { ok: false, reason: failure(url, "the URL of origin could not be read") }; + } // origin has no URL (it is not a remote): no line can be from it. git reads // its config first, so one it cannot read has stopped the commands before. if (url.code !== 0) return { ok: true, at: null };