fix(repo): "Updated 9d ago" on a repo nothing had touched for 19 days #5586
2 changed files+151−9
Modifiedsrc/__tests__/repo-freshness.test.ts+60−0View fileUnifiedSplit
@@ -61,3 +61,63 @@ describe("reconcileFreshness", () => {
6161 expect(reconcileFreshness(t, new Date(t.getTime())).healed).toBe(false);
6262 });
6363});
64
65describe("a stored push time must be corroborated by a ref write", () => {
66 /**
67 * The mirror image of the bug this module was written for.
68 *
69 * The original fix handled git being NEWER than the column — refs written
70 * straight to disk that the hook never sees. ccantynz/Vapron on 2026-08-30
71 * was the opposite: the column claimed a push on 08-21 while the newest
72 * commit AND the newest object in the bare repo were both 08-11. The page
73 * rendered "Updated 9d ago" directly above its own file list reading 19, 20
74 * and 21 days. Nothing had touched that repository for nineteen days.
75 *
76 * "Whichever is newer" has no defence against a value that was never true.
77 * A real push writes a ref, so that is the thing to ask for.
78 */
79 const commit = new Date("2026-08-11T00:01:00Z");
80 const claimed = new Date("2026-08-21T17:17:41Z");
81
82 it("rejects a stored time with no ref write behind it — the Vapron case", () => {
83 const r = reconcileFreshness(claimed, commit, commit); // refs last touched 08-11
84 expect(r.value).toEqual(commit);
85 expect(r.healed).toBe(true);
86 });
87
88 it("accepts a stored time a ref write corroborates — a force-push to old history", () => {
89 // Legitimate and must keep working: the repo really did change on 08-21,
90 // it just now points at an older commit. The ref write proves it.
91 const r = reconcileFreshness(claimed, commit, claimed);
92 expect(r.value).toEqual(claimed);
93 expect(r.healed).toBe(false);
94 });
95
96 it("allows slack for clock skew and a slow receive", () => {
97 const slightlyBefore = new Date(claimed.getTime() - 60_000);
98 expect(reconcileFreshness(claimed, commit, slightlyBefore).value).toEqual(claimed);
99 // But days is not skew, it is a phantom.
100 const daysBefore = new Date(claimed.getTime() - 3 * 24 * 3600_000);
101 expect(reconcileFreshness(claimed, commit, daysBefore).value).toEqual(commit);
102 });
103
104 it("keeps the previous answer when it could not look", () => {
105 // null means "we could not read the refs", never "nothing was written".
106 // An unreadable filesystem is our problem and must not silently rewrite a
107 // date an operator uses to judge whether a system is alive.
108 const r = reconcileFreshness(claimed, commit, null);
109 expect(r.value).toEqual(claimed);
110 expect(r.healed).toBe(false);
111 });
112
113 it("still lets git win when git is newer, corroboration irrelevant", () => {
114 // The original rule, unchanged: refs written directly to disk bypass the
115 // hook, so a newer commit always beats a stale column.
116 const newerCommit = new Date("2026-08-25T00:00:00Z");
117 for (const refs of [null, commit, claimed]) {
118 const r = reconcileFreshness(claimed, newerCommit, refs);
119 expect(r.value).toEqual(newerCommit);
120 expect(r.healed).toBe(true);
121 }
122 });
123});
Modifiedsrc/lib/repo-freshness.ts+91−9View fileUnifiedSplit
@@ -75,23 +75,104 @@ export async function newestCommitDate(
7575 return Number.isNaN(d.getTime()) ? null : d;
7676}
7777
78/**
79 * When was a ref last WRITTEN to this repo's bare directory?
80 *
81 * The corroboration for a stored push time. Deliberately looks only at refs —
82 * `packed-refs` and the loose files under `refs/` — because that is what a
83 * push actually touches, and it is a handful of stats rather than a walk of
84 * the object store. A repo with a million objects must not make its own
85 * repository page slow to render.
86 *
87 * Returns null when the path cannot be read. Null means "we could not look",
88 * never "nothing was written" — the caller keeps its previous answer rather
89 * than acting on an absence it cannot verify.
90 */
91export async function newestRefWriteAt(
92 owner: string,
93 name: string
94): Promise<Date | null> {
95 try {
96 const root = getRepoPath(owner, name);
97 const { stat, readdir } = await import("node:fs/promises");
98 const { join } = await import("node:path");
99 let newest = 0;
100
101 const consider = async (p: string): Promise<void> => {
102 try {
103 const st = await stat(p);
104 if (st.isDirectory()) {
105 const entries = await readdir(p);
106 // Bounded: refs/ is small on any real repo, and a pathological one
107 // should slow nothing but itself.
108 for (const e of entries.slice(0, 500)) await consider(join(p, e));
109 return;
110 }
111 if (st.mtimeMs > newest) newest = st.mtimeMs;
112 } catch {
113 /* a missing packed-refs is normal, not an error */
114 }
115 };
116
117 await consider(join(root, "packed-refs"));
118 await consider(join(root, "refs"));
119 return newest > 0 ? new Date(newest) : null;
120 } catch (err) {
121 console.warn(`[freshness] ref-write probe failed for ${owner}/${name}:`, err);
122 return null;
123 }
124}
125
126/**
127 * How far apart a claimed push and the ref write that proves it may be.
128 *
129 * A real push writes a ref within the same operation, so these are seconds
130 * apart. Five minutes is slack for clock skew and a slow receive, and is far
131 * short of the days-long gaps that reveal a phantom.
132 */
133const CORROBORATION_SLACK_MS = 5 * 60_000;
134
78135/**
79136 * Pure half, so the precedence rule is testable without a repo or a database.
80137 *
81 * Git wins only when it is NEWER. A repo can legitimately hold commits older
82 * than its last push (a force-push to an earlier commit, an import of old
83 * history), and in those cases the stored push time is the more truthful
84 * answer to "when did something last happen here".
138 * Git wins when it is NEWER — that is the original rule and it stays, because
139 * refs written straight to disk bypass the hook and leave the column behind.
140 *
141 * A stored value NEWER than git is the opposite case and needs a different
142 * question. It is legitimate for a force-push to older history or an import of
143 * old commits: the repository really did change, and git's newest commit date
144 * is genuinely old. But it is also exactly what a phantom looks like — a push
145 * that was rejected, or changed nothing, still bumping the column.
146 *
147 * The two are distinguishable, and the test is cheap: a real push WRITES A
148 * REF. So a stored value may only outrank git when a ref was written at about
149 * the same time. Measured on ccantynz/Vapron 2026-08-30: the column claimed a
150 * push on 08-21 while the newest commit AND the newest object in the bare repo
151 * were both 08-11, so the page said "Updated 9d ago" directly above its own
152 * file list saying 19, 20 and 21 days. Nothing had touched that repository for
153 * nineteen days and the header insisted otherwise.
154 *
155 * `refsWrittenAt` of null means we could not look. That keeps the previous
156 * behaviour rather than inventing a verdict — an unreadable filesystem is our
157 * problem, and it must not silently rewrite a date the operator relies on.
85158 */
86159export function reconcileFreshness(
87160 stored: Date | null,
88 fromGit: Date | null
161 fromGit: Date | null,
162 refsWrittenAt: Date | null = null
89163): { value: Date | null; healed: boolean } {
90164 if (!fromGit) return { value: stored, healed: false };
91165 if (!stored) return { value: fromGit, healed: true };
92 return fromGit.getTime() > stored.getTime()
93 ? { value: fromGit, healed: true }
94 : { value: stored, healed: false };
166 if (fromGit.getTime() > stored.getTime()) {
167 return { value: fromGit, healed: true };
168 }
169 // stored >= fromGit: believe it only if a ref write corroborates it.
170 if (refsWrittenAt === null) return { value: stored, healed: false };
171 const corroborated =
172 refsWrittenAt.getTime() >= stored.getTime() - CORROBORATION_SLACK_MS;
173 return corroborated
174 ? { value: stored, healed: false }
175 : { value: fromGit, healed: true };
95176}
96177
97178/**
@@ -108,7 +189,8 @@ export async function resolvePushedAt(
108189 try {
109190 const { value, healed } = reconcileFreshness(
110191 stored,
111 await newestCommitDate(owner, name)
192 await newestCommitDate(owner, name),
193 await newestRefWriteAt(owner, name)
112194 );
113195 if (healed && value) {
114196 // Fire and forget — the page has its answer either way, and a write
115197
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts