diff --git a/dist/commands/doctor/checks/transport-notes-refspec.js b/dist/commands/doctor/checks/transport-notes-refspec.js index fedaf5f..89a44e7 100644 --- a/dist/commands/doctor/checks/transport-notes-refspec.js +++ b/dist/commands/doctor/checks/transport-notes-refspec.js @@ -4,7 +4,7 @@ * It owns the reversible fetch-configuration diagnosis and fix because no * sibling check may alter transport configuration on its behalf. */ -import { NOTES_REF, NOTES_REFSPEC, coversNotes, forcesNotes, listRemotes, fetchRefspecs } from '../../../core/notes.js'; +import { NOTES_REF, NOTES_REFSPEC, coversNotes, forcesNotes, listRemotes, fetchRefspecs, notesAbsenceEvidenceKey, } from '../../../core/notes.js'; import { check, evidenceKey, gitOptions } from '../model.js'; const EXACT_NOTES_REFSPEC = `+${NOTES_REF}:${NOTES_REF}`; const EXACT_NOTES_REFSPEC_PATTERN = `^\\${EXACT_NOTES_REFSPEC}$`; @@ -17,6 +17,23 @@ const EXACT_NOTES_REFSPEC_PATTERN = `^\\${EXACT_NOTES_REFSPEC}$`; * in place beside a new one. */ const escapeConfigValuePattern = (value) => value.replace(/[\\.*+?[\]^$(){}|]/g, (character) => `\\${character}`); +const firstLine = (output) => output.trim().split('\n')[0] ?? ''; +/** A stale absence observation must never survive an unsuccessful verification. */ +const clearAbsenceEvidence = (remote, ctx) => ctx.git(['config', '--local', '--unset-all', notesAbsenceEvidenceKey(remote)], gitOptions(ctx.opts)).code === 0; +/** + * Store precisely what made an absent-mirror answer safe: this remote name was + * checked while it resolved to this URL, and it advertised no notes ref. + */ +const recordAbsenceEvidence = (remote, ctx) => { + const url = ctx.git(['config', '--get', `remote.${remote}.url`], gitOptions(ctx.opts)); + if (url.code !== 0 || url.stdout.trim() === '') + return false; + const key = notesAbsenceEvidenceKey(remote); + const current = ctx.git(['config', '--local', '--get', key], gitOptions(ctx.opts)); + if (current.code === 0 && current.stdout.trim() === url.stdout.trim()) + return false; + return ctx.git(['config', '--local', '--replace-all', key, url.stdout.trim()], gitOptions(ctx.opts)).code === 0; +}; export const checkRefspec = (ctx) => { const { opts, git } = ctx; const title = 'notes fetch refspec'; @@ -66,6 +83,10 @@ export const checkRefspec = (ctx) => { .map((remote) => ({ remote, result: git(['fetch', '--dry-run', remote], gitOptions(opts)) })) .filter(({ result }) => result.code !== 0); if (failed.length > 0) { + // A previous observation says nothing about a remote that cannot be + // verified now. `--fix` removes it so the read path returns to fail-closed. + if (opts.fix === true) + failed.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); return check('notes-refspec', 'transport', title, 'warn', `could not verify (${failed .map(({ remote, result }) => `${remote}: ${result.stderr.trim().split('\n')[0] ?? 'git fetch failed'}`) .join('; ')})`, failed.map(({ remote }) => `git fetch ${remote}`).join('\n'), fixed, undefined, { @@ -78,13 +99,53 @@ export const checkRefspec = (ctx) => { }, }); } - // A refspec written by `--fix` has not been fetched through yet, and this - // check is the last thing the operator reads before believing the mirror is - // sorted. Without the second sentence `ok` plus `fixed by --fix` reads as - // "repaired", while every query still answers from a mirror that was never - // retrieved -- the configuration is right and the records are still missing. - return check('notes-refspec', 'transport', title, 'ok', fixed - ? `${NOTES_REF} is now covered for ${remotes.join(', ')} — nothing has been fetched through it yet` - : `git fetch succeeds for ${remotes.join(', ')} and covers ${NOTES_REF}`, fixed ? `git fetch ${remotes[0] ?? 'origin'}` : null, fixed, undefined, { evidence: remoteEvidence }); + const local = git(['rev-parse', '--verify', '--quiet', NOTES_REF], gitOptions(opts)); + if (local.code === 0) { + return check('notes-refspec', 'transport', title, 'ok', `git fetch succeeds for ${remotes.join(', ')} and covers ${NOTES_REF}`, null, fixed, undefined, { evidence: { ...remoteEvidence, local_sha: local.stdout.trim() || 'unknown' } }); + } + const advertised = remotes.map((remote) => ({ + remote, + result: git(['ls-remote', remote, NOTES_REF], gitOptions(opts)), + })); + const unavailable = advertised.filter(({ result }) => result.code !== 0); + if (unavailable.length > 0) { + if (opts.fix === true) + unavailable.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check('notes-refspec', 'transport', title, 'warn', `could not verify whether ${NOTES_REF} exists upstream (${unavailable + .map(({ remote, result }) => `${remote}: ${firstLine(result.stderr) || 'git ls-remote failed'}`) + .join('; ')})`, unavailable.map(({ remote }) => `git fetch ${remote}`).join('\n'), fixed, undefined, { + evidence: { + ...remoteEvidence, + ...Object.fromEntries(unavailable.map(({ remote, result }) => [ + `ls_remote_exit_code_${evidenceKey(remote)}`, + String(result.code), + ])), + }, + }); + } + const withNotes = advertised.filter(({ result }) => result.stdout.trim() !== ''); + if (withNotes.length > 0) { + if (opts.fix === true) + withNotes.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check('notes-refspec', 'transport', title, 'warn', `${withNotes.map(({ remote }) => remote).join(', ')} advertises ${NOTES_REF}, but it is not fetched here`, withNotes.map(({ remote }) => `git fetch ${remote}`).join('\n'), fixed, undefined, { + evidence: { + ...remoteEvidence, + ...Object.fromEntries(withNotes.map(({ remote, result }) => [ + `remote_sha_${evidenceKey(remote)}`, + result.stdout.trim().split(/\s+/)[0] ?? 'unknown', + ])), + }, + }); + } + let recorded = false; + if (opts.fix === true) { + recorded = remotes.map((remote) => recordAbsenceEvidence(remote, ctx)).some(Boolean); + fixed = fixed || recorded; + } + // The remote probe found no mirror. Only `--fix` stores that fact for query + // routes, which must remain read-only and must not perform this probe. + return check('notes-refspec', 'transport', title, 'ok', opts.fix === true + ? `${remotes.join(', ')} advertises no ${NOTES_REF}; there is nothing to fetch` + : `${remotes.join(', ')} advertises no ${NOTES_REF}; run commitlore doctor --fix to record that for queries`, opts.fix === true ? null : 'commitlore doctor --fix', fixed, undefined, { evidence: { ...remoteEvidence, remote_advertises: 'false' } }); }; //# sourceMappingURL=transport-notes-refspec.js.map \ No newline at end of file diff --git a/dist/commands/doctor/checks/transport-notes-refspec.js.map b/dist/commands/doctor/checks/transport-notes-refspec.js.map index 4553dda..789542d 100644 --- a/dist/commands/doctor/checks/transport-notes-refspec.js.map +++ b/dist/commands/doctor/checks/transport-notes-refspec.js.map @@ -1 +1 @@ -{"version":3,"file":"transport-notes-refspec.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/transport-notes-refspec.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"transport-notes-refspec.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/transport-notes-refspec.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/commitlore.mjs b/dist/commitlore.mjs index 94942ba..3de5da0 100755 --- a/dist/commitlore.mjs +++ b/dist/commitlore.mjs @@ -11385,6 +11385,7 @@ var listRecordShas = (opts = {}) => { return object3; }).filter((object3) => object3.length > 0); }; +var notesAbsenceEvidenceKey = (remote) => `commitlore.notesabsence.r${Buffer.from(remote, "utf8").toString("hex")}`; var listRemotes = (opts) => { const result = execGit(["remote"], gitOptions(opts)); if (result.code !== 0) return []; @@ -11395,6 +11396,12 @@ var fetchRefspecs = (remote, opts) => { if (result.code !== 0) return []; return result.stdout.split("\n").filter((line2) => line2.length > 0); }; +var hasNotesAbsenceEvidence = (remote, opts = {}) => { + const url = execGit(["config", "--get", `remote.${remote}.url`], gitOptions(opts)); + if (url.code !== 0 || url.stdout.trim() === "") return false; + const observed = execGit(["config", "--local", "--get", notesAbsenceEvidenceKey(remote)], gitOptions(opts)); + return observed.code === 0 && observed.stdout.trim() === url.stdout.trim(); +}; var coversNotes = (refspec) => { const [, destination = ""] = refspec.replace(/^\+/, "").split(":"); if (destination === NOTES_REF) return true; @@ -11407,7 +11414,8 @@ var notesAvailability = (opts = {}) => { const remotes = listRemotes(opts); if (remotes.length === 0) return "absent"; const uncovered = remotes.filter((remote) => !fetchRefspecs(remote, opts).some(coversNotes)); - return uncovered.length > 0 ? "unfetched" : "absent"; + if (uncovered.length > 0) return "unfetched"; + return remotes.every((remote) => hasNotesAbsenceEvidence(remote, opts)) ? "absent" : "unfetched"; }; // src/core/backfill.ts @@ -17382,10 +17390,10 @@ import { spawnSync as spawnSync3 } from "node:child_process"; var PROBE_MESSAGE = "commitlore doctor probe\n\nLimit: probe\nBlast: local\n"; var gitOptions2 = (opts) => opts.cwd === void 0 ? {} : { cwd: opts.cwd }; var boundedExcerpt = (output) => { - const [firstLine5 = ""] = (output ?? "").split(/\r?\n/, 1); + const [firstLine6 = ""] = (output ?? "").split(/\r?\n/, 1); return { - firstLine: firstLine5.slice(0, 200), - truncated: firstLine5.length > 200 ? "true" : "false" + firstLine: firstLine6.slice(0, 200), + truncated: firstLine6.length > 200 ? "true" : "false" }; }; var streamEvidence = (stream, output) => { @@ -29212,6 +29220,16 @@ var checkPush = (ctx) => { var EXACT_NOTES_REFSPEC = `+${NOTES_REF}:${NOTES_REF}`; var EXACT_NOTES_REFSPEC_PATTERN = `^\\${EXACT_NOTES_REFSPEC}$`; var escapeConfigValuePattern = (value) => value.replace(/[\\.*+?[\]^$(){}|]/g, (character) => `\\${character}`); +var firstLine2 = (output) => output.trim().split("\n")[0] ?? ""; +var clearAbsenceEvidence = (remote, ctx) => ctx.git(["config", "--local", "--unset-all", notesAbsenceEvidenceKey(remote)], gitOptions2(ctx.opts)).code === 0; +var recordAbsenceEvidence = (remote, ctx) => { + const url = ctx.git(["config", "--get", `remote.${remote}.url`], gitOptions2(ctx.opts)); + if (url.code !== 0 || url.stdout.trim() === "") return false; + const key = notesAbsenceEvidenceKey(remote); + const current = ctx.git(["config", "--local", "--get", key], gitOptions2(ctx.opts)); + if (current.code === 0 && current.stdout.trim() === url.stdout.trim()) return false; + return ctx.git(["config", "--local", "--replace-all", key, url.stdout.trim()], gitOptions2(ctx.opts)).code === 0; +}; var checkRefspec = (ctx) => { const { opts, git: git2 } = ctx; const title = "notes fetch refspec"; @@ -29287,6 +29305,7 @@ var checkRefspec = (ctx) => { } const failed = remotes.map((remote) => ({ remote, result: git2(["fetch", "--dry-run", remote], gitOptions2(opts)) })).filter(({ result }) => result.code !== 0); if (failed.length > 0) { + if (opts.fix === true) failed.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); return check( "notes-refspec", "transport", @@ -29309,16 +29328,87 @@ var checkRefspec = (ctx) => { } ); } + const local = git2(["rev-parse", "--verify", "--quiet", NOTES_REF], gitOptions2(opts)); + if (local.code === 0) { + return check( + "notes-refspec", + "transport", + title, + "ok", + `git fetch succeeds for ${remotes.join(", ")} and covers ${NOTES_REF}`, + null, + fixed, + void 0, + { evidence: { ...remoteEvidence, local_sha: local.stdout.trim() || "unknown" } } + ); + } + const advertised = remotes.map((remote) => ({ + remote, + result: git2(["ls-remote", remote, NOTES_REF], gitOptions2(opts)) + })); + const unavailable = advertised.filter(({ result }) => result.code !== 0); + if (unavailable.length > 0) { + if (opts.fix === true) unavailable.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check( + "notes-refspec", + "transport", + title, + "warn", + `could not verify whether ${NOTES_REF} exists upstream (${unavailable.map(({ remote, result }) => `${remote}: ${firstLine2(result.stderr) || "git ls-remote failed"}`).join("; ")})`, + unavailable.map(({ remote }) => `git fetch ${remote}`).join("\n"), + fixed, + void 0, + { + evidence: { + ...remoteEvidence, + ...Object.fromEntries( + unavailable.map(({ remote, result }) => [ + `ls_remote_exit_code_${evidenceKey(remote)}`, + String(result.code) + ]) + ) + } + } + ); + } + const withNotes = advertised.filter(({ result }) => result.stdout.trim() !== ""); + if (withNotes.length > 0) { + if (opts.fix === true) withNotes.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check( + "notes-refspec", + "transport", + title, + "warn", + `${withNotes.map(({ remote }) => remote).join(", ")} advertises ${NOTES_REF}, but it is not fetched here`, + withNotes.map(({ remote }) => `git fetch ${remote}`).join("\n"), + fixed, + void 0, + { + evidence: { + ...remoteEvidence, + ...Object.fromEntries(withNotes.map(({ remote, result }) => [ + `remote_sha_${evidenceKey(remote)}`, + result.stdout.trim().split(/\s+/)[0] ?? "unknown" + ])) + } + } + ); + } + let recorded = false; + if (opts.fix === true) { + recorded = remotes.map((remote) => recordAbsenceEvidence(remote, ctx)).some(Boolean); + fixed = fixed || recorded; + } return check( "notes-refspec", "transport", title, "ok", - fixed ? `${NOTES_REF} is now covered for ${remotes.join(", ")} \u2014 nothing has been fetched through it yet` : `git fetch succeeds for ${remotes.join(", ")} and covers ${NOTES_REF}`, - fixed ? `git fetch ${remotes[0] ?? "origin"}` : null, + opts.fix === true ? `${remotes.join(", ")} advertises no ${NOTES_REF}; there is nothing to fetch` : `${remotes.join(", ")} advertises no ${NOTES_REF}; run commitlore doctor --fix to record that for queries`, + opts.fix === true ? null : "commitlore doctor --fix", fixed, void 0, - { evidence: remoteEvidence } + { evidence: { ...remoteEvidence, remote_advertises: "false" } } ); }; @@ -30096,7 +30186,7 @@ var register10 = (program3) => { // src/commands/hooks.ts var messageOf3 = (error2) => error2 instanceof Error ? error2.message : String(error2); -var firstLine2 = (text) => (text.trim().split("\n")[0] ?? "").trim(); +var firstLine3 = (text) => (text.trim().split("\n")[0] ?? "").trim(); var failure3 = (message) => ({ code: 2, stdout: "", @@ -30113,7 +30203,7 @@ var success2 = (status, lines) => ({ var resolveHooksDir = (cwd) => { const result = execGit(["rev-parse", "--git-path", "hooks"], { cwd }); if (result.code !== 0) { - throw new Error(`not a git repository (${firstLine2(result.stderr)})`); + throw new Error(`not a git repository (${firstLine3(result.stderr)})`); } return resolve14(cwd, result.stdout.trim()); }; @@ -31849,7 +31939,7 @@ var PREFIX4 = "commitlore:"; var USAGE = "usage: commitlore squash-preserve .. [--target ] [--message-file ] [--json] [--force]"; var SHORT_SHA = 8; var messageOf5 = (error2) => error2 instanceof Error ? error2.message : String(error2); -var firstLine3 = (text) => (text.trim().split("\n")[0] ?? "").trim(); +var firstLine4 = (text) => (text.trim().split("\n")[0] ?? "").trim(); var shortSha6 = (sha) => sha.length > SHORT_SHA ? sha.slice(0, SHORT_SHA) : sha; var usageError = (message) => ({ code: 2, @@ -31865,7 +31955,7 @@ var countCommits = (range, cwd) => { cwd === void 0 ? {} : { cwd } ); if (result.code !== 0) { - throw new Error(`cannot walk range ${JSON.stringify(range)}: ${firstLine3(result.stderr)}`); + throw new Error(`cannot walk range ${JSON.stringify(range)}: ${firstLine4(result.stderr)}`); } return Number(result.stdout.trim()); }; @@ -32048,13 +32138,13 @@ ${USAGE2} checks: [] }); var messageOf6 = (error2) => error2 instanceof Error ? error2.message : String(error2); -var firstLine4 = (text) => (text.trim().split("\n")[0] ?? "").trim(); +var firstLine5 = (text) => (text.trim().split("\n")[0] ?? "").trim(); var stripCr = (line2) => line2.endsWith("\r") ? line2.slice(0, -1) : line2; var CONTINUATION = /^[ \t]/; var LEADING_WHITESPACE = /^[ \t]+/; var isComment = (line2) => line2.startsWith("#"); var MERGE_TITLE = /^Merge (pull request #\d+ from \S+|branch '[^']+'|remote-tracking branch '[^']+'|tag '[^']+')(?: into \S+)?$/; -var looksLikeMergeTitle = (message) => MERGE_TITLE.test(firstLine4(message)); +var looksLikeMergeTitle = (message) => MERGE_TITLE.test(firstLine5(message)); var matchTrailersAt = (lines, start, trailers) => { const found = []; let cursor = start; @@ -32208,21 +32298,21 @@ var locateReferenceViolations = (source, trailers, violations) => { var resolveCommit2 = (ref, cwd) => { const result = execGit(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd }); if (result.code !== 0) { - throw new Error(`cannot resolve commit ${JSON.stringify(ref)}: ${firstLine4(result.stderr)}`); + throw new Error(`cannot resolve commit ${JSON.stringify(ref)}: ${firstLine5(result.stderr)}`); } return result.stdout.trim(); }; var readCommitSource = (sha, cwd) => { const result = execGit(["log", "-1", "--format=%B", sha, "--"], { cwd }); if (result.code !== 0) { - throw new Error(`cannot read commit ${sha}: ${firstLine4(result.stderr)}`); + throw new Error(`cannot read commit ${sha}: ${firstLine5(result.stderr)}`); } return { sha, message: result.stdout }; }; var readRange = (range, cwd) => { const result = execGit(["rev-list", "--reverse", "--end-of-options", range, "--"], { cwd }); if (result.code !== 0) { - throw new Error(`cannot walk range ${JSON.stringify(range)}: ${firstLine4(result.stderr)}`); + throw new Error(`cannot walk range ${JSON.stringify(range)}: ${firstLine5(result.stderr)}`); } return result.stdout.split("\n").filter((sha) => sha.length > 0).map((sha) => readCommitSource(sha, cwd)); }; @@ -32287,7 +32377,7 @@ var recordsFor = (source, cwd) => { var reachableShas = (revision, cwd) => { const result = execGit(["rev-list", revision], { cwd }); if (result.code !== 0) { - throw new Error(firstLine4(result.stderr) || `cannot walk revision ${revision}`); + throw new Error(firstLine5(result.stderr) || `cannot walk revision ${revision}`); } return new Set(result.stdout.trim().split("\n").filter(Boolean)); }; @@ -32383,7 +32473,7 @@ var checkReferences = (input, sources, cwd) => { check: { class: "reference", status: "not-checked", - reason: `repository scan failed: ${firstLine4(messageOf6(error2))}` + reason: `repository scan failed: ${firstLine5(messageOf6(error2))}` }, violations: [] }; diff --git a/dist/core/notes.d.ts b/dist/core/notes.d.ts index d36071a..f8c95b1 100644 --- a/dist/core/notes.d.ts +++ b/dist/core/notes.d.ts @@ -95,10 +95,11 @@ export declare const listRecordShas: (opts?: NotesOptions) => string[]; * What a consumer route can conclude from an empty answer. * * - `present` — the mirror ref exists here; an empty answer means empty - * - `absent` — no mirror ref, and every remote already fetches one, so - * nobody has written records: an empty answer means empty - * - `unfetched` — no mirror ref, and a remote exists that does not fetch it. - * Records may exist upstream. An empty answer means *unknown*. + * - `absent` — no mirror ref, and local doctor evidence says every + * configured remote advertised none: an empty answer means empty + * - `unfetched` — no mirror ref, and the remote state is not established + * locally. Records may exist upstream. An empty answer means + * *unknown*. * * The third case is the one that matters and the reason this exists. `git fetch` * does not fetch notes by default, so a plain `git clone` of a repository full @@ -109,8 +110,21 @@ export declare const listRecordShas: (opts?: NotesOptions) => string[]; * is what an agent runs. */ export type NotesAvailability = 'present' | 'absent' | 'unfetched'; +/** + * A `doctor --fix` observation is scoped to one remote name and value-bound to + * its configured URL. Remote names may contain punctuation that is not valid + * in a git-config variable, so encode their UTF-8 bytes instead of interpolating + * the name into the key. + */ +export declare const notesAbsenceEvidenceKey: (remote: string) => string; export declare const listRemotes: (opts: NotesOptions) => string[]; export declare const fetchRefspecs: (remote: string, opts: NotesOptions) => string[]; +/** + * Whether `doctor --fix` last established, for this exact configured remote, + * that it advertised no notes mirror. This is deliberately a local-config + * lookup: consumer routes must not put a network round trip before an edit. + */ +export declare const hasNotesAbsenceEvidence: (remote: string, opts?: NotesOptions) => boolean; /** * Whether a configured refspec lands the mirror where we read it. * @@ -129,17 +143,10 @@ export declare const forcesNotes: (refspec: string) => boolean; /** * Whether this repository can answer for the notes mirror, and if not, why. * - * Reads git config only — no network, no fetch. A repository with no remote at - * all reports `absent`: there is nowhere for unseen records to be, so an empty - * answer is a true empty. - * - * A configured refspec that has never been fetched through is indistinguishable - * here from one that was fetched and found nothing, and the difference matters: - * `doctor --fix` writes the refspec and fetches nothing, so the state it leaves - * looks exactly like an upstream with no records. Reporting `unfetched` for both - * was tried and rejected — it fires on every repository whose refspec was added - * after cloning, and `incomplete` changes `guard`'s exit code. The honest fix - * lives in `doctor`, which now says a fetch is still owed instead of letting - * `ok` read as repaired. + * Reads local refs and config only — no network, no fetch. Config describes + * what this clone intends to fetch; it does not prove what a remote advertised. + * `doctor --fix` records a URL-bound absence observation after its remote probe. + * Without that observation (including no configured remote), absence is not + * evidence that there is nothing upstream, so the answer stays incomplete. */ export declare const notesAvailability: (opts?: NotesOptions) => NotesAvailability; diff --git a/dist/core/notes.js b/dist/core/notes.js index fa73bfa..7bca922 100644 --- a/dist/core/notes.js +++ b/dist/core/notes.js @@ -155,6 +155,13 @@ export const listRecordShas = (opts = {}) => { }) .filter((object) => object.length > 0); }; +/** + * A `doctor --fix` observation is scoped to one remote name and value-bound to + * its configured URL. Remote names may contain punctuation that is not valid + * in a git-config variable, so encode their UTF-8 bytes instead of interpolating + * the name into the key. + */ +export const notesAbsenceEvidenceKey = (remote) => `commitlore.notesabsence.r${Buffer.from(remote, 'utf8').toString('hex')}`; export const listRemotes = (opts) => { const result = execGit(['remote'], gitOptions(opts)); if (result.code !== 0) @@ -168,6 +175,18 @@ export const fetchRefspecs = (remote, opts) => { return []; return result.stdout.split('\n').filter((line) => line.length > 0); }; +/** + * Whether `doctor --fix` last established, for this exact configured remote, + * that it advertised no notes mirror. This is deliberately a local-config + * lookup: consumer routes must not put a network round trip before an edit. + */ +export const hasNotesAbsenceEvidence = (remote, opts = {}) => { + const url = execGit(['config', '--get', `remote.${remote}.url`], gitOptions(opts)); + if (url.code !== 0 || url.stdout.trim() === '') + return false; + const observed = execGit(['config', '--local', '--get', notesAbsenceEvidenceKey(remote)], gitOptions(opts)); + return observed.code === 0 && observed.stdout.trim() === url.stdout.trim(); +}; /** * Whether a configured refspec lands the mirror where we read it. * @@ -191,27 +210,28 @@ export const forcesNotes = (refspec) => refspec.startsWith('+') && coversNotes(r /** * Whether this repository can answer for the notes mirror, and if not, why. * - * Reads git config only — no network, no fetch. A repository with no remote at - * all reports `absent`: there is nowhere for unseen records to be, so an empty - * answer is a true empty. - * - * A configured refspec that has never been fetched through is indistinguishable - * here from one that was fetched and found nothing, and the difference matters: - * `doctor --fix` writes the refspec and fetches nothing, so the state it leaves - * looks exactly like an upstream with no records. Reporting `unfetched` for both - * was tried and rejected — it fires on every repository whose refspec was added - * after cloning, and `incomplete` changes `guard`'s exit code. The honest fix - * lives in `doctor`, which now says a fetch is still owed instead of letting - * `ok` read as repaired. + * Reads local refs and config only — no network, no fetch. Config describes + * what this clone intends to fetch; it does not prove what a remote advertised. + * `doctor --fix` records a URL-bound absence observation after its remote probe. + * Without that observation (including no configured remote), absence is not + * evidence that there is nothing upstream, so the answer stays incomplete. */ export const notesAvailability = (opts = {}) => { const ref = execGit(['rev-parse', '--verify', '--quiet', NOTES_REF], gitOptions(opts)); if (ref.code === 0) return 'present'; + // No remote is not "unverified"; it is verified. There is nowhere for an + // unseen record to be, so an empty answer here is a true empty. Reporting + // `unfetched` would warn on every query about an upstream that does not + // exist, and nothing could ever clear it — there is no remote to probe, so + // `doctor --fix` cannot record evidence about one. That is the incoherence + // #512 is about, arriving from the other side. const remotes = listRemotes(opts); if (remotes.length === 0) return 'absent'; const uncovered = remotes.filter((remote) => !fetchRefspecs(remote, opts).some(coversNotes)); - return uncovered.length > 0 ? 'unfetched' : 'absent'; + if (uncovered.length > 0) + return 'unfetched'; + return remotes.every((remote) => hasNotesAbsenceEvidence(remote, opts)) ? 'absent' : 'unfetched'; }; //# sourceMappingURL=notes.js.map \ No newline at end of file diff --git a/dist/core/notes.js.map b/dist/core/notes.js.map index c305365..b88a739 100644 --- a/dist/core/notes.js.map +++ b/dist/core/notes.js.map @@ -1 +1 @@ -{"version":3,"file":"notes.js","sourceRoot":"","sources":["../../src/core/notes.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"notes.js","sourceRoot":"","sources":["../../src/core/notes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/commands/doctor/checks/transport-notes-refspec.ts b/src/commands/doctor/checks/transport-notes-refspec.ts index 2d3c683..0235426 100644 --- a/src/commands/doctor/checks/transport-notes-refspec.ts +++ b/src/commands/doctor/checks/transport-notes-refspec.ts @@ -5,7 +5,15 @@ * sibling check may alter transport configuration on its behalf. */ -import { NOTES_REF, NOTES_REFSPEC, coversNotes, forcesNotes, listRemotes, fetchRefspecs } from '../../../core/notes.js'; +import { + NOTES_REF, + NOTES_REFSPEC, + coversNotes, + forcesNotes, + listRemotes, + fetchRefspecs, + notesAbsenceEvidenceKey, +} from '../../../core/notes.js'; import { check, evidenceKey, gitOptions, type DoctorCheck, type DoctorContext } from '../model.js'; const EXACT_NOTES_REFSPEC = `+${NOTES_REF}:${NOTES_REF}`; @@ -22,6 +30,27 @@ const EXACT_NOTES_REFSPEC_PATTERN = `^\\${EXACT_NOTES_REFSPEC}$`; const escapeConfigValuePattern = (value: string): string => value.replace(/[\\.*+?[\]^$(){}|]/g, (character) => `\\${character}`); +const firstLine = (output: string): string => output.trim().split('\n')[0] ?? ''; + +/** A stale absence observation must never survive an unsuccessful verification. */ +const clearAbsenceEvidence = (remote: string, ctx: DoctorContext): boolean => + ctx.git(['config', '--local', '--unset-all', notesAbsenceEvidenceKey(remote)], gitOptions(ctx.opts)).code === 0; + +/** + * Store precisely what made an absent-mirror answer safe: this remote name was + * checked while it resolved to this URL, and it advertised no notes ref. + */ +const recordAbsenceEvidence = (remote: string, ctx: DoctorContext): boolean => { + const url = ctx.git(['config', '--get', `remote.${remote}.url`], gitOptions(ctx.opts)); + if (url.code !== 0 || url.stdout.trim() === '') return false; + + const key = notesAbsenceEvidenceKey(remote); + const current = ctx.git(['config', '--local', '--get', key], gitOptions(ctx.opts)); + if (current.code === 0 && current.stdout.trim() === url.stdout.trim()) return false; + + return ctx.git(['config', '--local', '--replace-all', key, url.stdout.trim()], gitOptions(ctx.opts)).code === 0; +}; + export const checkRefspec = (ctx: DoctorContext): DoctorCheck => { const { opts, git } = ctx; const title = 'notes fetch refspec'; @@ -108,6 +137,9 @@ export const checkRefspec = (ctx: DoctorContext): DoctorCheck => { .map((remote) => ({ remote, result: git(['fetch', '--dry-run', remote], gitOptions(opts)) })) .filter(({ result }) => result.code !== 0); if (failed.length > 0) { + // A previous observation says nothing about a remote that cannot be + // verified now. `--fix` removes it so the read path returns to fail-closed. + if (opts.fix === true) failed.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); return check( 'notes-refspec', 'transport', title, @@ -132,21 +164,92 @@ export const checkRefspec = (ctx: DoctorContext): DoctorCheck => { ); } - // A refspec written by `--fix` has not been fetched through yet, and this - // check is the last thing the operator reads before believing the mirror is - // sorted. Without the second sentence `ok` plus `fixed by --fix` reads as - // "repaired", while every query still answers from a mirror that was never - // retrieved -- the configuration is right and the records are still missing. + const local = git(['rev-parse', '--verify', '--quiet', NOTES_REF], gitOptions(opts)); + if (local.code === 0) { + return check( + 'notes-refspec', 'transport', + title, + 'ok', + `git fetch succeeds for ${remotes.join(', ')} and covers ${NOTES_REF}`, + null, + fixed, + undefined, + { evidence: { ...remoteEvidence, local_sha: local.stdout.trim() || 'unknown' } }, + ); + } + + const advertised = remotes.map((remote) => ({ + remote, + result: git(['ls-remote', remote, NOTES_REF], gitOptions(opts)), + })); + const unavailable = advertised.filter(({ result }) => result.code !== 0); + if (unavailable.length > 0) { + if (opts.fix === true) unavailable.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check( + 'notes-refspec', 'transport', + title, + 'warn', + `could not verify whether ${NOTES_REF} exists upstream (${unavailable + .map(({ remote, result }) => `${remote}: ${firstLine(result.stderr) || 'git ls-remote failed'}`) + .join('; ')})`, + unavailable.map(({ remote }) => `git fetch ${remote}`).join('\n'), + fixed, + undefined, + { + evidence: { + ...remoteEvidence, + ...Object.fromEntries( + unavailable.map(({ remote, result }) => [ + `ls_remote_exit_code_${evidenceKey(remote)}`, + String(result.code), + ]), + ), + }, + }, + ); + } + + const withNotes = advertised.filter(({ result }) => result.stdout.trim() !== ''); + if (withNotes.length > 0) { + if (opts.fix === true) withNotes.forEach(({ remote }) => clearAbsenceEvidence(remote, ctx)); + return check( + 'notes-refspec', 'transport', + title, + 'warn', + `${withNotes.map(({ remote }) => remote).join(', ')} advertises ${NOTES_REF}, but it is not fetched here`, + withNotes.map(({ remote }) => `git fetch ${remote}`).join('\n'), + fixed, + undefined, + { + evidence: { + ...remoteEvidence, + ...Object.fromEntries(withNotes.map(({ remote, result }) => [ + `remote_sha_${evidenceKey(remote)}`, + result.stdout.trim().split(/\s+/)[0] ?? 'unknown', + ])), + }, + }, + ); + } + + let recorded = false; + if (opts.fix === true) { + recorded = remotes.map((remote) => recordAbsenceEvidence(remote, ctx)).some(Boolean); + fixed = fixed || recorded; + } + + // The remote probe found no mirror. Only `--fix` stores that fact for query + // routes, which must remain read-only and must not perform this probe. return check( 'notes-refspec', 'transport', title, 'ok', - fixed - ? `${NOTES_REF} is now covered for ${remotes.join(', ')} — nothing has been fetched through it yet` - : `git fetch succeeds for ${remotes.join(', ')} and covers ${NOTES_REF}`, - fixed ? `git fetch ${remotes[0] ?? 'origin'}` : null, + opts.fix === true + ? `${remotes.join(', ')} advertises no ${NOTES_REF}; there is nothing to fetch` + : `${remotes.join(', ')} advertises no ${NOTES_REF}; run commitlore doctor --fix to record that for queries`, + opts.fix === true ? null : 'commitlore doctor --fix', fixed, undefined, - { evidence: remoteEvidence }, + { evidence: { ...remoteEvidence, remote_advertises: 'false' } }, ); }; diff --git a/src/core/notes.ts b/src/core/notes.ts index 169a873..fdd2c07 100644 --- a/src/core/notes.ts +++ b/src/core/notes.ts @@ -218,10 +218,11 @@ export const listRecordShas = (opts: NotesOptions = {}): string[] => { * What a consumer route can conclude from an empty answer. * * - `present` — the mirror ref exists here; an empty answer means empty - * - `absent` — no mirror ref, and every remote already fetches one, so - * nobody has written records: an empty answer means empty - * - `unfetched` — no mirror ref, and a remote exists that does not fetch it. - * Records may exist upstream. An empty answer means *unknown*. + * - `absent` — no mirror ref, and local doctor evidence says every + * configured remote advertised none: an empty answer means empty + * - `unfetched` — no mirror ref, and the remote state is not established + * locally. Records may exist upstream. An empty answer means + * *unknown*. * * The third case is the one that matters and the reason this exists. `git fetch` * does not fetch notes by default, so a plain `git clone` of a repository full @@ -233,6 +234,15 @@ export const listRecordShas = (opts: NotesOptions = {}): string[] => { */ export type NotesAvailability = 'present' | 'absent' | 'unfetched'; +/** + * A `doctor --fix` observation is scoped to one remote name and value-bound to + * its configured URL. Remote names may contain punctuation that is not valid + * in a git-config variable, so encode their UTF-8 bytes instead of interpolating + * the name into the key. + */ +export const notesAbsenceEvidenceKey = (remote: string): string => + `commitlore.notesabsence.r${Buffer.from(remote, 'utf8').toString('hex')}`; + export const listRemotes = (opts: NotesOptions): string[] => { const result = execGit(['remote'], gitOptions(opts)); if (result.code !== 0) return []; @@ -246,6 +256,19 @@ export const fetchRefspecs = (remote: string, opts: NotesOptions): string[] => { return result.stdout.split('\n').filter((line) => line.length > 0); }; +/** + * Whether `doctor --fix` last established, for this exact configured remote, + * that it advertised no notes mirror. This is deliberately a local-config + * lookup: consumer routes must not put a network round trip before an edit. + */ +export const hasNotesAbsenceEvidence = (remote: string, opts: NotesOptions = {}): boolean => { + const url = execGit(['config', '--get', `remote.${remote}.url`], gitOptions(opts)); + if (url.code !== 0 || url.stdout.trim() === '') return false; + + const observed = execGit(['config', '--local', '--get', notesAbsenceEvidenceKey(remote)], gitOptions(opts)); + return observed.code === 0 && observed.stdout.trim() === url.stdout.trim(); +}; + /** * Whether a configured refspec lands the mirror where we read it. * @@ -271,26 +294,27 @@ export const forcesNotes = (refspec: string): boolean => /** * Whether this repository can answer for the notes mirror, and if not, why. * - * Reads git config only — no network, no fetch. A repository with no remote at - * all reports `absent`: there is nowhere for unseen records to be, so an empty - * answer is a true empty. - * - * A configured refspec that has never been fetched through is indistinguishable - * here from one that was fetched and found nothing, and the difference matters: - * `doctor --fix` writes the refspec and fetches nothing, so the state it leaves - * looks exactly like an upstream with no records. Reporting `unfetched` for both - * was tried and rejected — it fires on every repository whose refspec was added - * after cloning, and `incomplete` changes `guard`'s exit code. The honest fix - * lives in `doctor`, which now says a fetch is still owed instead of letting - * `ok` read as repaired. + * Reads local refs and config only — no network, no fetch. Config describes + * what this clone intends to fetch; it does not prove what a remote advertised. + * `doctor --fix` records a URL-bound absence observation after its remote probe. + * Without that observation (including no configured remote), absence is not + * evidence that there is nothing upstream, so the answer stays incomplete. */ export const notesAvailability = (opts: NotesOptions = {}): NotesAvailability => { const ref = execGit(['rev-parse', '--verify', '--quiet', NOTES_REF], gitOptions(opts)); if (ref.code === 0) return 'present'; + // No remote is not "unverified"; it is verified. There is nowhere for an + // unseen record to be, so an empty answer here is a true empty. Reporting + // `unfetched` would warn on every query about an upstream that does not + // exist, and nothing could ever clear it — there is no remote to probe, so + // `doctor --fix` cannot record evidence about one. That is the incoherence + // #512 is about, arriving from the other side. const remotes = listRemotes(opts); if (remotes.length === 0) return 'absent'; const uncovered = remotes.filter((remote) => !fetchRefspecs(remote, opts).some(coversNotes)); - return uncovered.length > 0 ? 'unfetched' : 'absent'; + if (uncovered.length > 0) return 'unfetched'; + + return remotes.every((remote) => hasNotesAbsenceEvidence(remote, opts)) ? 'absent' : 'unfetched'; }; diff --git a/test/doctor-invariants.test.ts b/test/doctor-invariants.test.ts index 2739397..51a6f7a 100644 --- a/test/doctor-invariants.test.ts +++ b/test/doctor-invariants.test.ts @@ -266,9 +266,10 @@ describe('#461 doctor invariants', () => { } }); - it('writes only remote fetch refspecs under --fix', () => { - // #63: --fix has already shipped one defect through this surface. A second - // write surface growing here is what this pins. + it('writes only remote fetch refspecs and notes-absence evidence under --fix', () => { + // #63: --fix has already shipped one defect through this surface. The + // URL-bound absence evidence is the one additional local fact queries may + // rely on; a third write surface must still fail this fence. const repo = populatedRepo('fix'); const configBefore = localConfig(repo); const before = inventory(repo); @@ -279,8 +280,8 @@ describe('#461 doctor invariants', () => { .split('\n') .filter((line) => line.trim() !== '' && !configBefore.includes(line)); for (const line of added) { - expect(line, `--fix wrote a config key outside remote..fetch: ${line}`).toMatch( - /^remote\.[^.]+\.fetch=/, + expect(line, `--fix wrote an undocumented config key: ${line}`).toMatch( + /^(remote\.[^.]+\.fetch|commitlore\.notesabsence\.r[0-9a-f]+)=/, ); } diff --git a/test/guard.test.ts b/test/guard.test.ts index 007712d..f5f0098 100644 --- a/test/guard.test.ts +++ b/test/guard.test.ts @@ -42,6 +42,7 @@ import { } from '../src/commands/guard.js'; import { execGitOrThrow } from '../src/core/git.js'; import { DEFAULT_THRESHOLD, guard } from '../src/core/guard.js'; +import { NOTES_REFSPEC, notesAbsenceEvidenceKey } from '../src/core/notes.js'; import { runQuery } from '../src/core/query.js'; import { RECORD_ID_RE } from '../src/core/types.js'; import { createTestRepo } from './git-fixtures.js'; @@ -168,6 +169,9 @@ const makeRepo = (seed: readonly RecordFixture[]): string => { const dir = mkdtempSync(join(tmpdir(), 'commitlore-guard-')); temporaries.push(dir); createTestRepo({ path: dir }); + execGitOrThrow(['remote', 'add', 'origin', '.'], { cwd: dir }); + execGitOrThrow(['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC], { cwd: dir }); + execGitOrThrow(['config', '--local', notesAbsenceEvidenceKey('origin'), '.'], { cwd: dir }); for (const record of seed) commitFixture(dir, record); return dir; }; diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 82c1a62..9a5d08f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { execGitOrThrow } from '../src/core/git.js'; +import { NOTES_REFSPEC, notesAbsenceEvidenceKey } from '../src/core/notes.js'; import { createTestRepo } from './git-fixtures.js'; const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url)); @@ -136,7 +137,11 @@ const temporaries: string[] = []; const makeRepo = (): string => { const dir = mkdtempSync(join(tmpdir(), 'commitlore-mcp-')); temporaries.push(dir); - return createTestRepo({ path: dir }); + createTestRepo({ path: dir }); + execGitOrThrow(['remote', 'add', 'origin', '.'], { cwd: dir }); + execGitOrThrow(['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC], { cwd: dir }); + execGitOrThrow(['config', '--local', notesAbsenceEvidenceKey('origin'), '.'], { cwd: dir }); + return dir; }; const commitAt = ( diff --git a/test/notes-availability.test.ts b/test/notes-availability.test.ts index 21519f4..1c0f763 100644 --- a/test/notes-availability.test.ts +++ b/test/notes-availability.test.ts @@ -17,6 +17,7 @@ import { Command } from 'commander'; import { afterAll, describe, expect, it } from 'vitest'; import { register as registerIndex } from '../src/commands/index-cmd.js'; +import { runDoctor } from '../src/commands/doctor.js'; import { notesAvailability, coversNotes, @@ -72,8 +73,10 @@ describe('notesAvailability', () => { expect(notesAvailability({ cwd: originWithRecords() })).toBe('present'); }); - it('reports absent for a repository with no mirror and no remote', () => { - // Nowhere for unseen records to be, so an empty answer is a true empty. + it('treats a repository with no remote as a true empty', () => { + // Fail-closed answers the question "could there be records I cannot see". + // With no remote there is nowhere for one to be, and no probe that could + // ever settle it, so warning would be permanent and about nothing. expect(notesAvailability({ cwd: makeRepo() })).toBe('absent'); }); @@ -81,14 +84,36 @@ describe('notesAvailability', () => { expect(notesAvailability({ cwd: clone(originWithRecords()) })).toBe('unfetched'); }); - it('reports absent once the refspec covers the mirror, even before fetching', () => { - // The distinction is "could this repository have missed records", not - // "does it have them": a configured clone that fetched nothing found nothing. + it('keeps a configured but unverified mirror incomplete', () => { const dir = clone(makeRepo()); git(dir, ['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC]); + expect(notesAvailability({ cwd: dir })).toBe('unfetched'); + }); + + it('reports absent after doctor verifies that every remote has no notes mirror', () => { + const dir = clone(makeRepo()); + + const report = runDoctor({ cwd: dir, fix: true }); + const check = report.checks.find((entry) => entry.id === 'notes-refspec'); + + expect(check).toMatchObject({ status: 'ok', detail: expect.stringContaining('there is nothing to fetch') }); expect(notesAvailability({ cwd: dir })).toBe('absent'); }); + it('keeps warning after doctor fixes the refspec when records do exist upstream', () => { + const dir = clone(originWithRecords()); + + const report = runDoctor({ cwd: dir, fix: true }); + const check = report.checks.find((entry) => entry.id === 'notes-refspec'); + + expect(check).toMatchObject({ status: 'warn', detail: expect.stringContaining('advertises') }); + expect(notesAvailability({ cwd: dir })).toBe('unfetched'); + expect(runQuery({ cwd: dir, noIndex: true }).diagnostics.join(' ')).toContain('may be missing records that exist upstream'); + + git(dir, ['fetch', '-q', 'origin']); + expect(notesAvailability({ cwd: dir })).toBe('present'); + }); + it('reports present after the refspec is added and the notes are fetched', () => { const dir = clone(originWithRecords()); git(dir, ['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC]); @@ -116,7 +141,9 @@ describe('coversNotes', () => { describe('an unfetched mirror does not read as an empty repository', () => { it('is the same records and the same empty count, and a different state', () => { const unfetched = runQuery({ cwd: clone(originWithRecords()), noIndex: true }); - const trulyEmpty = runQuery({ cwd: makeRepo(), noIndex: true }); + const empty = clone(makeRepo()); + runDoctor({ cwd: empty, fix: true }); + const trulyEmpty = runQuery({ cwd: empty, noIndex: true }); expect(unfetched.records).toEqual([]); expect(trulyEmpty.records).toEqual([]); @@ -133,10 +160,19 @@ describe('an unfetched mirror does not read as an empty repository', () => { }); it('stays quiet when there is nothing to warn about', () => { - expect(runQuery({ cwd: makeRepo(), noIndex: true }).diagnostics).toEqual([]); + const empty = clone(makeRepo()); + runDoctor({ cwd: empty, fix: true }); + expect(runQuery({ cwd: empty, noIndex: true }).diagnostics).toEqual([]); expect(runQuery({ cwd: originWithRecords(), noIndex: true }).diagnostics).toEqual([]); }); + it('does not warn when no remote is configured, because there is no upstream', () => { + const result = runQuery({ cwd: makeRepo(), noIndex: true }); + + expect(result.notes).toBe('absent'); + expect(result.diagnostics.join(' ')).not.toContain('may be missing records that exist upstream'); + }); + it('finds the records once they are fetched, and stops warning', () => { const dir = clone(originWithRecords()); git(dir, ['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC]); @@ -249,9 +285,22 @@ describe('a build over an unfetched mirror says so', () => { expect(run.code).toBe(0); }); - it('stays quiet when the mirror is present or there is nothing to fetch', () => { + it('stays quiet when the mirror is present or doctor verified there is nothing to fetch', () => { + const empty = clone(makeRepo()); + runDoctor({ cwd: empty, fix: true }); + expect(runIndexCommand(originWithBothSources(), ['index', '--rebuild']).stderr).toBe(''); - expect(runIndexCommand(makeRepo(), ['index', '--rebuild']).stderr).toBe(''); + expect(runIndexCommand(empty, ['index', '--rebuild']).stderr).toBe(''); + }); + + it('keeps index diagnostics when a configured remote cannot be verified', () => { + const origin = makeRepo(); + const dir = clone(origin); + git(dir, ['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC]); + rmSync(origin, { recursive: true, force: true }); + + const run = runIndexCommand(dir, ['index', '--rebuild']); + expect(run.stderr).toContain('may be missing records that exist upstream'); }); it('stops saying it once the mirror is fetched, and indexes the record', () => { diff --git a/test/notes.test.ts b/test/notes.test.ts index 0340096..eac450e 100644 --- a/test/notes.test.ts +++ b/test/notes.test.ts @@ -195,7 +195,8 @@ describe('notes mirror', () => { const report = runDoctor({ cwd: cloneB, fix: true }); const refspec = report.checks.find((entry) => entry.id === 'notes-refspec'); - expect(refspec?.status).toBe('ok'); + expect(refspec?.status).toBe('warn'); + expect(refspec?.detail).toContain('advertises'); expect(refspec?.fixed).toBe(true); git(cloneB, ['fetch', '--quiet', 'origin']); diff --git a/test/query.test.ts b/test/query.test.ts index a986c36..abe92f5 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -27,7 +27,7 @@ import { buildReport, collectRecords } from '../src/commands/stale.js'; import { execGitOrThrow } from '../src/core/git.js'; import { buildInjection } from '../src/core/inject.js'; import { closeIndex, ensureIndex, indexDbPath, scanTrailers } from '../src/core/index-db.js'; -import { writeRecord } from '../src/core/notes.js'; +import { NOTES_REFSPEC, notesAbsenceEvidenceKey, writeRecord } from '../src/core/notes.js'; import { runQuery, valuesOf, type GradedRecord, type QueryOptions } from '../src/core/query.js'; import { loadFixtures } from './fixtures.js'; import { createTestRepo } from './git-fixtures.js'; @@ -57,7 +57,14 @@ afterAll(() => { const makeRepo = (): string => { const dir = mkdtempSync(join(tmpdir(), 'commitlore-query-')); temporaries.push(dir); - return createTestRepo({ path: dir }); + createTestRepo({ path: dir }); + // Most fixtures here test query semantics rather than transport setup. Give + // them the same local absence evidence that `doctor --fix` leaves after + // checking an empty remote, so their answers are complete by construction. + execGitOrThrow(['remote', 'add', 'origin', '.'], { cwd: dir }); + execGitOrThrow(['config', '--add', 'remote.origin.fetch', NOTES_REFSPEC], { cwd: dir }); + execGitOrThrow(['config', '--local', notesAbsenceEvidenceKey('origin'), '.'], { cwd: dir }); + return dir; }; const cloneRepo = (origin: string): string => { @@ -1228,10 +1235,24 @@ describe('the four commands', () => { expect(runCommand(cloneRepo(dir), [command, AT, PINNED]).code).toBe(3); }); - it.each(commands)('%s exits 0 in a readable repository with no records', (command) => { + it.each(commands)('%s exits 0 in a readable repository known to have no notes', (command) => { expect(runCommand(makeRepo(), [command, AT, PINNED]).code).toBe(0); }); + // A repository with no remote has nowhere for an unseen record to be, so an + // empty answer is a true empty. Warning here would point at an upstream that + // does not exist, and nothing could clear it: there is no remote to probe, so + // `doctor --fix` can never record evidence about one. + it.each(commands)('%s exits 0 and says nothing about upstream when there is no remote', (command) => { + const noRemote = mkdtempSync(join(tmpdir(), 'commitlore-query-no-remote-')); + temporaries.push(noRemote); + createTestRepo({ path: noRemote }); + + const run = runCommand(noRemote, [command, AT, PINNED]); + expect(run.code).toBe(0); + expect(run.stderr).not.toContain('may be missing records that exist upstream'); + }); + it.each(commands)('%s exits 2 when git cannot answer at all', (command) => { const run = withPath(brokenGitPath(), () => runCommand(dir, [command, AT, PINNED, '--no-index']), @@ -1511,7 +1532,7 @@ describe('--json', () => { // Commit history was read, so this is ready rather than empty. history: 'ready', counts: { records: 0, limits: 0, ruledOut: 0, warnings: 0, other: 0 }, - // No remote, so an empty answer here is a true empty and says so. + // The fixture carries the local doctor evidence for an empty remote. notes: 'absent', diagnostics: [], records: [], diff --git a/test/shallow-history.test.ts b/test/shallow-history.test.ts index 55f6da2..27fb311 100644 --- a/test/shallow-history.test.ts +++ b/test/shallow-history.test.ts @@ -9,6 +9,7 @@ import { runDoctor } from '../src/commands/doctor.js'; import { hookResult } from '../src/commands/inject.js'; import { guard } from '../src/core/guard.js'; import { buildInjection } from '../src/core/inject.js'; +import { notesAbsenceEvidenceKey } from '../src/core/notes.js'; import { runQuery } from '../src/core/query.js'; import { createTestRepo } from './git-fixtures.js'; @@ -42,6 +43,17 @@ const shallowClone = (): string => { ); createTestRepo({ path: clone, source: `file://${origin}`, depth: 1 }); git(clone, ['config', '--add', 'remote.origin.fetch', '+refs/notes/commitlore:refs/notes/commitlore']); + // A refspec says what this clone would fetch, never what the remote has, so + // it alone leaves notes availability unknown and every answer incomplete + // (#512). `doctor --fix` records the probe; the fixture records the same + // evidence directly, because this suite is about the shallow caveat and an + // unrelated incompleteness would mask it. + git(clone, [ + 'config', + '--local', + notesAbsenceEvidenceKey('origin'), + git(clone, ['config', '--get', 'remote.origin.url']).trim(), + ]); return clone; }; diff --git a/test/validate.test.ts b/test/validate.test.ts index 29f2bd4..a1915a6 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -15,7 +15,22 @@ import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; import { CHECK_CLASS_NEEDS, runValidate } from '../src/commands/validate.js'; -import { writeRecord } from '../src/core/notes.js'; +import { notesAbsenceEvidenceKey, writeRecord } from '../src/core/notes.js'; + +/** + * A refspec says what a clone would fetch, never what the remote has, so on its + * own it leaves notes availability unknown and every reference check reads + * `not-checked` for a reason unrelated to what these cases measure (#512). + * `doctor --fix` records the remote probe; a fixture records the same evidence. + */ +const recordNotesProbe = (cwd: string): void => { + const url = execFileSync('git', ['config', '--get', 'remote.origin.url'], { + cwd, + env: GIT_ENV, + encoding: 'utf8', + }).trim(); + execFileSync('git', ['config', '--local', notesAbsenceEvidenceKey('origin'), url], { cwd, env: GIT_ENV }); +}; import { loadFixtures } from './fixtures.js'; import { createTestRepo } from './git-fixtures.js'; @@ -504,6 +519,7 @@ describe('validate — check classes and reference integrity', () => { ['config', '--add', 'remote.origin.fetch', '+refs/notes/commitlore:refs/notes/commitlore'], { cwd: shallow, env: GIT_ENV }, ); + recordNotesProbe(shallow); const shallowMessage = join(shallow, 'message.txt'); writeFileSync(shallowMessage, message); @@ -516,6 +532,7 @@ describe('validate — check classes and reference integrity', () => { ['config', '--add', 'remote.origin.fetch', '+refs/notes/commitlore:refs/notes/commitlore'], { cwd: full, env: GIT_ENV }, ); + recordNotesProbe(full); const fullMessage = join(full, 'message.txt'); writeFileSync(fullMessage, message);