diff --git a/CHANGELOG.md b/CHANGELOG.md index 516855c..51c5288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`: 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`, diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 9d52bb8..83e3da5 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 fe26558..4f9a8e7 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 6d5d27c..d4b2093 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 bb5298c..89eda16 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 b238b57..0b42e45 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 a62b728..faa9abf 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 2e91661..3105789 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 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.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 82000a5..8e3223a 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 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.", "", ); @@ -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 80f6e0a..7b2c300 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 2911a37..e8adb48 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 ff25fa3..ca0f138 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,367 @@ 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, 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")); + 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()); + // 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(); + 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("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"); + 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 9f0d018..7aea668 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 c4d6dd0..17f567a 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 e01b293..fe920f1 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,33 @@ 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: 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 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; }; /** @@ -169,6 +194,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 +258,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 +405,170 @@ 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"]); + // 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 }; + // 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 96bebee..7102df2 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 e8ef6af..9f754de 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}",