projectkeeper 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document versions (Spec §1.16 row 2; CKC-22 AC-3, AC-15): every version of every document in the history (documents
|
|
3
|
+
* as `isDocumentPath` of sources/history.ts defines them) with the commit and time it lives at; adds, renames, copies and
|
|
4
|
+
* deletions; which Markdown sections (by heading path) the version added, removed or changed against the version before
|
|
5
|
+
* it; the cleanup commits that delete many documents at once, with the document catalogue before and after; and for a
|
|
6
|
+
* deleted document the commit whose tree still has its full text.
|
|
7
|
+
*
|
|
8
|
+
* "The version before it" is the content the commit changed — its first parent's version of the same document (for a
|
|
9
|
+
* rename, of the old path) — so the section diff is exactly what that commit did, on whichever branch it was made.
|
|
10
|
+
* Contents are stored once per distinct content (`blob:<repo>:<id>` in the searchable texts).
|
|
11
|
+
*
|
|
12
|
+
* Import-style root commit (AC-15), the rule: a commit without parents that adds at least `IMPORT_MIN_DOCS` documents, and
|
|
13
|
+
* either one of those documents states a full date earlier than the commit's own day (so the content existed before it
|
|
14
|
+
* entered version control), or its message says it puts existing material under version control. Its content is not dated
|
|
15
|
+
* by the commit: see `occurredOfLine`.
|
|
16
|
+
*/
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
20
|
+
import { isDocumentPath } from '../sources/history.ts';
|
|
21
|
+
import { splitMarkdown } from '../sources/files.ts';
|
|
22
|
+
import { blobTextKey, cleanupKey, delKey, docKey, getText, putText, tx } from './schema.ts';
|
|
23
|
+
import { catBlobs, isText, treeFiles } from './git-read.ts';
|
|
24
|
+
import { dateContext, dayOf, fullDatesIn, materialTime, occurred, undated, type DateContext, type Occurred } from './time.ts';
|
|
25
|
+
import type { RepoHandle } from './repo-scan.ts';
|
|
26
|
+
|
|
27
|
+
/** A cleanup commit deletes at least this many documents in one commit. */
|
|
28
|
+
export const CLEANUP_MIN_DOCS = 5;
|
|
29
|
+
/** An import-style root commit adds at least this many documents. */
|
|
30
|
+
export const IMPORT_MIN_DOCS = 5;
|
|
31
|
+
/** Words of a message that says existing material is being put under version control. */
|
|
32
|
+
export const IMPORT_MESSAGE = /\b(import(?:ed|ing)?|under version control|into (?:git|version control)|initial import|existing (?:files|material|documents))\b|纳入(?:版本管理|git|版本控制)|导入|迁入|入库/i;
|
|
33
|
+
/** Content larger than this is recorded as a version (size, sections unknown) without its text. */
|
|
34
|
+
export const MAX_DOC_BYTES = 4_000_000;
|
|
35
|
+
|
|
36
|
+
export const IMPORT_RULE = `A root commit (no parents) that adds at least ${IMPORT_MIN_DOCS} documents, where one of them states a full date earlier than the commit's own day, or whose message says it puts existing material under version control. Its content is dated by the date written in the text (the line's own date, else its entry's 日期/date line, else the entry heading's date, else the document's stated date); else by the file's time in the working tree (File time, a weak basis: copying and moving change it); else Undated · first seen at the import commit.`;
|
|
37
|
+
export const CLEANUP_RULE = `One commit that deletes at least ${CLEANUP_MIN_DOCS} documents (a move is not a deletion).`;
|
|
38
|
+
|
|
39
|
+
export interface DocScanStats {
|
|
40
|
+
readonly versions: number;
|
|
41
|
+
readonly versionsAdded: number;
|
|
42
|
+
readonly withoutText: number;
|
|
43
|
+
/** The versions this scan recorded without their text (over `MAX_DOC_BYTES`, or not text), so the rebuild says which. */
|
|
44
|
+
readonly withoutTextPaths?: readonly { readonly path: string; readonly commit: string; readonly bytes: number }[];
|
|
45
|
+
readonly deleted: number;
|
|
46
|
+
readonly cleanups: number;
|
|
47
|
+
readonly importRoots: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHANGE: Record<string, string> = { A: 'Added', M: 'Modified', R: 'Renamed', C: 'Copied', T: 'Type changed' };
|
|
51
|
+
|
|
52
|
+
/** The key of a section by its heading path, before a heading path used twice is told apart (`sectionMap`). */
|
|
53
|
+
export const sectionBase = (headingPath: readonly string[]): string => (headingPath.length ? headingPath.join(' › ') : '(before the first heading)');
|
|
54
|
+
|
|
55
|
+
/** One section of a document with the key `sectionMap` and `diffSections` give it, and where it stands. */
|
|
56
|
+
export interface KeyedSection {
|
|
57
|
+
readonly key: string;
|
|
58
|
+
readonly base: string;
|
|
59
|
+
readonly headingPath: readonly string[];
|
|
60
|
+
readonly lineStart: number;
|
|
61
|
+
readonly lineEnd: number;
|
|
62
|
+
/** The section as written, heading line included. */
|
|
63
|
+
readonly text: string;
|
|
64
|
+
/** What `sectionMap` compares: the section without its heading line, trimmed. */
|
|
65
|
+
readonly body: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A document's sections in order, keyed as `sectionMap` keys them (a heading path used twice gets ` #2`). */
|
|
69
|
+
export function keyedSections(text: string): KeyedSection[] {
|
|
70
|
+
const out: KeyedSection[] = [];
|
|
71
|
+
const used = new Set<string>();
|
|
72
|
+
for (const s of splitMarkdown(text)) {
|
|
73
|
+
const base = sectionBase(s.headingPath);
|
|
74
|
+
let key = base;
|
|
75
|
+
for (let n = 2; used.has(key); n++) key = `${base} #${n}`;
|
|
76
|
+
used.add(key);
|
|
77
|
+
const body = s.text.split(/\r?\n/).slice(s.headingPath.length ? 1 : 0).join('\n').trim();
|
|
78
|
+
out.push({ key, base, headingPath: s.headingPath, lineStart: s.lineStart, lineEnd: s.lineEnd, text: s.text, body });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Heading path → section text; a heading path used twice in one document is told apart by its position (` #2`). */
|
|
84
|
+
export function sectionMap(text: string): Map<string, string> {
|
|
85
|
+
return new Map(keyedSections(text).map((s) => [s.key, s.body]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function diffSections(before: string | null, after: string): { added: string[]; removed: string[]; changed: string[] } {
|
|
89
|
+
const a = before === null ? new Map<string, string>() : sectionMap(before);
|
|
90
|
+
const b = sectionMap(after);
|
|
91
|
+
const added: string[] = [];
|
|
92
|
+
const removed: string[] = [];
|
|
93
|
+
const changed: string[] = [];
|
|
94
|
+
for (const [k, v] of b) {
|
|
95
|
+
if (!a.has(k)) added.push(k);
|
|
96
|
+
else if (a.get(k) !== v) changed.push(k);
|
|
97
|
+
}
|
|
98
|
+
for (const k of a.keys()) if (!b.has(k)) removed.push(k);
|
|
99
|
+
return { added, removed, changed };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lineCount = (text: string): number => (text.length === 0 ? 0 : text.split(/\r?\n/).length - (text.endsWith('\n') ? 1 : 0));
|
|
103
|
+
|
|
104
|
+
// ───────────────────────── when a line of a document happened ─────────────────────────
|
|
105
|
+
|
|
106
|
+
export interface VersionTime {
|
|
107
|
+
readonly commit: string;
|
|
108
|
+
readonly commitAt: string;
|
|
109
|
+
readonly importRoot: boolean;
|
|
110
|
+
readonly path: string;
|
|
111
|
+
readonly firstSeen: string;
|
|
112
|
+
/** The file's time in the working tree, read for import-root content only (`versionTime`). */
|
|
113
|
+
readonly fileTime: string | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What dates the lines of one version: its commit, and for import-root content the file's time now. */
|
|
117
|
+
export function versionTime(repoPath: string, path: string, commit: string, commitAt: string, importRoot: boolean, firstSeen: string): VersionTime {
|
|
118
|
+
return { commit, commitAt, importRoot, path, firstSeen, fileTime: importRoot ? fileTimeOf(repoPath, path) : null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* When a line of a document version happened (§2.11, AC-15). A version made by an ordinary commit: the commit's author
|
|
123
|
+
* time (`Commit`), with the date written in the text kept as `other` (the table of §2.11: "另记"). With `line` null, the
|
|
124
|
+
* date the document states for itself (rule 4 of time.ts) is the one written in the text. Content of an import-style
|
|
125
|
+
* root commit: the written date (`Written in text`) with the import commit as `other`; else the file's time in the working
|
|
126
|
+
* tree (`File time`); else `Undated · first seen`.
|
|
127
|
+
*/
|
|
128
|
+
export function occurredOfLine(v: VersionTime, dates: DateContext, line: number | null): Occurred {
|
|
129
|
+
const anchor = `${v.path}@${v.commit.slice(0, 10)}${line ? `:${line}` : ''}`;
|
|
130
|
+
const written = line === null ? dates.document : dates.at(line);
|
|
131
|
+
const commitAt = materialTime(v.commitAt) ?? v.commitAt;
|
|
132
|
+
if (!v.importRoot) {
|
|
133
|
+
return occurred(commitAt, 'Commit', anchor, written ? { at: written.at, basis: 'Written in text', anchor: `${v.path}:${written.line}` } : null);
|
|
134
|
+
}
|
|
135
|
+
const imported = { at: commitAt, basis: 'Commit' as const, anchor: `${v.commit.slice(0, 10)} (import commit)` };
|
|
136
|
+
if (written) return { at: written.at, basis: 'Written in text', anchor: `${v.path}:${written.line}`, other: imported };
|
|
137
|
+
if (v.fileTime) return { at: v.fileTime, basis: 'File time', anchor: v.path, other: imported };
|
|
138
|
+
return { ...undated(v.firstSeen, anchor), other: imported };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fileTimeOf(root: string, rel: string): string | null {
|
|
142
|
+
try {
|
|
143
|
+
const st = statSync(join(root, ...rel.split('/')));
|
|
144
|
+
return st.isFile() ? st.mtime.toISOString() : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ───────────────────────── the scan ─────────────────────────
|
|
151
|
+
|
|
152
|
+
interface Change {
|
|
153
|
+
readonly hash: string; readonly status: string; readonly path: string; readonly old_path: string | null;
|
|
154
|
+
readonly old_blob: string | null; readonly new_blob: string | null;
|
|
155
|
+
readonly author_at: string; readonly author_ms: number; readonly parents: string; readonly import_root: number; readonly subject: string; readonly body: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Decide import-style root commits among the repository's root commits not decided yet (the rule above). */
|
|
159
|
+
function decideImportRoots(db: DatabaseSync, repo: RepoHandle, contentOf: (blob: string) => string | null): number {
|
|
160
|
+
const decided = db.prepare("SELECT value FROM state WHERE key = ?").get(`importRoots:${repo.id}`) as { value: string } | undefined;
|
|
161
|
+
const done = new Set<string>(decided ? (JSON.parse(decided.value) as string[]) : []);
|
|
162
|
+
const roots = db.prepare("SELECT hash, author_at, subject, body FROM commits WHERE repo = ? AND parents = ''").all(repo.id) as { hash: string; author_at: string; subject: string; body: string }[];
|
|
163
|
+
let n = 0;
|
|
164
|
+
for (const r of roots) {
|
|
165
|
+
if (done.has(r.hash)) continue;
|
|
166
|
+
done.add(r.hash);
|
|
167
|
+
const adds = (db.prepare("SELECT path, new_blob FROM commit_files WHERE repo = ? AND hash = ? AND status = 'A'").all(repo.id, r.hash) as { path: string; new_blob: string | null }[])
|
|
168
|
+
.filter((f) => isDocumentPath(f.path) && f.new_blob);
|
|
169
|
+
if (adds.length < IMPORT_MIN_DOCS) continue;
|
|
170
|
+
// The author's own day (the commit time as git wrote it, with its offset).
|
|
171
|
+
const day = dayOf(r.author_at) ?? '';
|
|
172
|
+
const earlier = adds.some((f) => { const t = contentOf(f.new_blob!); return t !== null && fullDatesIn(t).some((d) => d < day); });
|
|
173
|
+
if (earlier || IMPORT_MESSAGE.test(`${r.subject}\n${r.body}`)) {
|
|
174
|
+
db.prepare('UPDATE commits SET import_root = 1 WHERE repo = ? AND hash = ?').run(repo.id, r.hash);
|
|
175
|
+
n += 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
db.prepare('INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)').run(`importRoots:${repo.id}`, JSON.stringify([...done]));
|
|
179
|
+
return n;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function scanDocs(db: DatabaseSync, repo: RepoHandle, now: string): DocScanStats {
|
|
183
|
+
const changes = (db.prepare(`
|
|
184
|
+
SELECT cf.hash, cf.status, cf.path, cf.old_path, cf.old_blob, cf.new_blob, c.author_at, c.author_ms, c.parents, c.import_root, c.subject, c.body
|
|
185
|
+
FROM commit_files cf JOIN commits c ON c.repo = cf.repo AND c.hash = cf.hash
|
|
186
|
+
WHERE cf.repo = ? AND (
|
|
187
|
+
(cf.status <> 'D' AND NOT EXISTS (SELECT 1 FROM docs d WHERE d.repo = cf.repo AND d.path = cf.path AND d.commit_hash = cf.hash))
|
|
188
|
+
OR (cf.status = 'D' AND NOT EXISTS (SELECT 1 FROM deleted_docs x WHERE x.repo = cf.repo AND x.path = cf.path AND x.deleted_commit = cf.hash)))
|
|
189
|
+
ORDER BY c.author_ms, cf.hash`).all(repo.id) as unknown as Change[])
|
|
190
|
+
.filter((ch) => isDocumentPath(ch.path));
|
|
191
|
+
|
|
192
|
+
// Contents: what the searchable texts already hold is not read again.
|
|
193
|
+
const wantedBlobs = new Set<string>();
|
|
194
|
+
const have = (blob: string) => db.prepare('SELECT 1 FROM texts WHERE key = ?').get(blobTextKey(repo.id, blob)) !== undefined;
|
|
195
|
+
for (const ch of changes) {
|
|
196
|
+
if (ch.new_blob && ch.status !== 'D' && !have(ch.new_blob)) wantedBlobs.add(ch.new_blob);
|
|
197
|
+
if (ch.old_blob && (ch.status === 'M' || ch.status === 'R' || ch.status === 'C' || ch.status === 'T') && !have(ch.old_blob)) wantedBlobs.add(ch.old_blob);
|
|
198
|
+
}
|
|
199
|
+
// Root commits' added documents, for the import rule, are among the new blobs already.
|
|
200
|
+
const fetched = catBlobs(repo.path, [...wantedBlobs]);
|
|
201
|
+
const texts = new Map<string, string | null>();
|
|
202
|
+
const contentOf = (blob: string | null): string | null => {
|
|
203
|
+
if (!blob) return null;
|
|
204
|
+
if (texts.has(blob)) return texts.get(blob)!;
|
|
205
|
+
const stored = getText(db, blobTextKey(repo.id, blob));
|
|
206
|
+
if (stored !== null) { texts.set(blob, stored); return stored; }
|
|
207
|
+
const buf = fetched.get(blob);
|
|
208
|
+
const t = buf && buf.length <= MAX_DOC_BYTES && isText(buf) ? buf.toString('utf8') : null;
|
|
209
|
+
texts.set(blob, t);
|
|
210
|
+
return t;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const importRoots = tx(db, () => decideImportRoots(db, repo, contentOf));
|
|
214
|
+
const isImport = new Set((db.prepare('SELECT hash FROM commits WHERE repo = ? AND import_root = 1').all(repo.id) as { hash: string }[]).map((r) => r.hash));
|
|
215
|
+
|
|
216
|
+
const insDoc = db.prepare(`INSERT OR IGNORE INTO docs (key, repo, path, commit_hash, blob, prev_blob, prev_path, at, at_ms, change, sections, bytes, lines, import_root, added, removed, changed)
|
|
217
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
218
|
+
const insDel = db.prepare(`INSERT OR IGNORE INTO deleted_docs (key, repo, path, deleted_commit, deleted_at, deleted_ms, readable_at, blob, cleanup)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`);
|
|
220
|
+
let versionsAdded = 0;
|
|
221
|
+
let withoutText = 0;
|
|
222
|
+
const withoutTextPaths: { path: string; commit: string; bytes: number }[] = [];
|
|
223
|
+
let deleted = 0;
|
|
224
|
+
const deletionsByCommit = new Map<string, number>();
|
|
225
|
+
tx(db, () => {
|
|
226
|
+
for (const ch of changes) {
|
|
227
|
+
const at = materialTime(ch.author_at) ?? ch.author_at;
|
|
228
|
+
if (ch.status === 'D') {
|
|
229
|
+
const readable = ch.parents.split(' ').filter(Boolean)[0] ?? null;
|
|
230
|
+
if (insDel.run(delKey(ch.path, ch.hash), repo.id, ch.path, ch.hash, at, ch.author_ms, readable, ch.old_blob).changes > 0) {
|
|
231
|
+
deleted += 1;
|
|
232
|
+
deletionsByCommit.set(ch.hash, (deletionsByCommit.get(ch.hash) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!ch.new_blob) continue;
|
|
237
|
+
const text = contentOf(ch.new_blob);
|
|
238
|
+
if (text !== null) putText(db, blobTextKey(repo.id, ch.new_blob), 'doc', repo.id, text);
|
|
239
|
+
else {
|
|
240
|
+
withoutText += 1;
|
|
241
|
+
withoutTextPaths.push({ path: ch.path, commit: ch.hash, bytes: fetched.get(ch.new_blob)?.length ?? 0 });
|
|
242
|
+
}
|
|
243
|
+
const prevBlob = ch.status === 'A' ? null : ch.old_blob;
|
|
244
|
+
const prevPath = ch.status === 'R' || ch.status === 'C' ? ch.old_path : ch.status === 'A' ? null : ch.path;
|
|
245
|
+
const before = prevBlob ? contentOf(prevBlob) : null;
|
|
246
|
+
if (prevBlob && before !== null) putText(db, blobTextKey(repo.id, prevBlob), 'doc', repo.id, before);
|
|
247
|
+
const diff = text === null ? null : diffSections(before, text);
|
|
248
|
+
const w = insDoc.run(docKey(ch.path, ch.hash), repo.id, ch.path, ch.hash, ch.new_blob, prevBlob, prevPath, at, ch.author_ms,
|
|
249
|
+
CHANGE[ch.status] ?? 'Modified', text === null ? 0 : splitMarkdown(text).length, fetched.get(ch.new_blob)?.length ?? Buffer.byteLength(text ?? ''),
|
|
250
|
+
text === null ? 0 : lineCount(text), isImport.has(ch.hash) ? 1 : 0,
|
|
251
|
+
diff ? JSON.stringify(diff.added) : null, diff ? JSON.stringify(diff.removed) : null, diff ? JSON.stringify(diff.changed) : null);
|
|
252
|
+
if (w.changes > 0) versionsAdded += 1;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Cleanup commits: one commit deleting at least CLEANUP_MIN_DOCS documents; the catalogue before and after.
|
|
257
|
+
let cleanups = 0;
|
|
258
|
+
const bigDeletions = db.prepare(`SELECT deleted_commit h, count(*) n FROM deleted_docs WHERE repo = ? GROUP BY deleted_commit HAVING n >= ?`).all(repo.id, CLEANUP_MIN_DOCS) as { h: string; n: number }[];
|
|
259
|
+
for (const { h, n } of bigDeletions) {
|
|
260
|
+
if (db.prepare('SELECT 1 FROM cleanups WHERE repo = ? AND commit_hash = ?').get(repo.id, h)) continue;
|
|
261
|
+
const c = db.prepare('SELECT parents, author_at, author_ms FROM commits WHERE repo = ? AND hash = ?').get(repo.id, h) as { parents: string; author_at: string; author_ms: number } | undefined;
|
|
262
|
+
if (!c) continue;
|
|
263
|
+
const parent = c.parents.split(' ').filter(Boolean)[0] ?? null;
|
|
264
|
+
const docsAt = (ref: string) => treeFiles(repo.path, ref).filter((f) => isDocumentPath(f.path)).map((f) => f.path);
|
|
265
|
+
const total = (db.prepare('SELECT count(*) c FROM commit_files WHERE repo = ? AND hash = ? AND status = ?').get(repo.id, h, 'D') as { c: number }).c;
|
|
266
|
+
tx(db, () => {
|
|
267
|
+
db.prepare(`INSERT OR IGNORE INTO cleanups (key, repo, commit_hash, at, at_ms, deleted_docs, deleted_total, catalog_before, catalog_after) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
268
|
+
.run(cleanupKey(h), repo.id, h, materialTime(c.author_at) ?? c.author_at, c.author_ms, n, total, JSON.stringify(parent ? docsAt(parent) : []), JSON.stringify(docsAt(h)));
|
|
269
|
+
db.prepare('UPDATE deleted_docs SET cleanup = 1 WHERE repo = ? AND deleted_commit = ?').run(repo.id, h);
|
|
270
|
+
});
|
|
271
|
+
cleanups += 1;
|
|
272
|
+
}
|
|
273
|
+
void now;
|
|
274
|
+
void deletionsByCommit;
|
|
275
|
+
const versions = (db.prepare('SELECT count(*) c FROM docs WHERE repo = ?').get(repo.id) as { c: number }).c;
|
|
276
|
+
return { versions, versionsAdded, withoutText, withoutTextPaths, deleted, cleanups, importRoots };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Where a version's text is: the ledger's own copy of its content. */
|
|
280
|
+
export function versionText(db: DatabaseSync, repo: string, blob: string): string | null {
|
|
281
|
+
return getText(db, blobTextKey(repo, blob));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The dates written in a version's text, read once per content. */
|
|
285
|
+
const contexts = new Map<string, DateContext>();
|
|
286
|
+
export function datesOf(key: string, text: string): DateContext {
|
|
287
|
+
let c = contexts.get(key);
|
|
288
|
+
if (!c) {
|
|
289
|
+
if (contexts.size > 2000) contexts.clear();
|
|
290
|
+
c = dateContext(text);
|
|
291
|
+
contexts.set(key, c);
|
|
292
|
+
}
|
|
293
|
+
return c;
|
|
294
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger's git reads (CKC-22; Spec §1.16). Everything here only reads, and runs as
|
|
3
|
+
* `git -C <dir> --no-optional-locks …` with the owner's display settings switched off, so observing a repository never
|
|
4
|
+
* changes it (Spec §1.1) and a local git setting (colour, no rename detection, a pager) cannot change what is read. No
|
|
5
|
+
* network: no fetch, pull or remote query — only what the repository already has.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { canonicalPath } from '../util/paths.ts';
|
|
9
|
+
|
|
10
|
+
export interface GitOut { readonly ok: boolean; readonly out: string; readonly err: string }
|
|
11
|
+
|
|
12
|
+
function env(): NodeJS.ProcessEnv {
|
|
13
|
+
const copy: NodeJS.ProcessEnv = {};
|
|
14
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
15
|
+
if (!['GIT_PAGER', 'PAGER', 'GIT_EXTERNAL_DIFF', 'GIT_OPTIONAL_LOCKS', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'].includes(key.toUpperCase())) copy[key] = value;
|
|
16
|
+
}
|
|
17
|
+
copy.GIT_OPTIONAL_LOCKS = '0';
|
|
18
|
+
copy.GIT_TERMINAL_PROMPT = '0';
|
|
19
|
+
return copy;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Settings that decide what git prints; fixed so the owner's configuration cannot change what the ledger reads. */
|
|
23
|
+
const FIXED = [
|
|
24
|
+
'--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false', '-c', 'color.ui=false', '-c', 'log.showRoot=true',
|
|
25
|
+
'-c', 'diff.renames=true', '-c', 'diff.noprefix=false', '-c', 'log.decorate=false', '-c', 'log.showSignature=false',
|
|
26
|
+
// Long ref names (refs/codex/turn-diffs/…) exceed Windows' path limit when stored as loose refs.
|
|
27
|
+
'-c', 'core.longpaths=true',
|
|
28
|
+
'--no-optional-locks',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function ledgerGit(dir: string, args: readonly string[], opts: { timeoutMs?: number; maxBuffer?: number; input?: string } = {}): GitOut {
|
|
32
|
+
try {
|
|
33
|
+
const out = execFileSync('git', [...FIXED, '-C', dir, ...args], {
|
|
34
|
+
encoding: 'utf8', env: env(), timeout: opts.timeoutMs ?? 180_000, maxBuffer: opts.maxBuffer ?? 512_000_000, windowsHide: true,
|
|
35
|
+
stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], ...(opts.input === undefined ? {} : { input: opts.input }),
|
|
36
|
+
});
|
|
37
|
+
return { ok: true, out, err: '' };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const e = error as { stdout?: string; stderr?: string; message?: string };
|
|
40
|
+
return { ok: false, out: typeof e.stdout === 'string' ? e.stdout : '', err: (e.stderr || e.message || String(error)).trim() };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const isHash = (h: string): boolean => /^[0-9a-f]{40}$/.test(h);
|
|
45
|
+
|
|
46
|
+
export type RefNamespace = 'heads' | 'tags' | 'remotes' | 'stash' | 'other';
|
|
47
|
+
export interface RefInfo {
|
|
48
|
+
readonly refname: string;
|
|
49
|
+
readonly namespace: RefNamespace;
|
|
50
|
+
/** The commit the ref ultimately names (an annotated tag peeled); for a ref to a tree or a blob, that object. */
|
|
51
|
+
readonly tip: string;
|
|
52
|
+
readonly type: 'commit' | 'tree' | 'blob' | 'tag' | 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function namespaceOf(refname: string): RefNamespace {
|
|
56
|
+
if (refname.startsWith('refs/heads/')) return 'heads';
|
|
57
|
+
if (refname.startsWith('refs/tags/')) return 'tags';
|
|
58
|
+
if (refname.startsWith('refs/remotes/')) return 'remotes';
|
|
59
|
+
if (refname === 'refs/stash') return 'stash';
|
|
60
|
+
return 'other'; // e.g. refs/codex/turn-diffs/…
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every ref, tags peeled; a symbolic remote HEAD (`refs/remotes/origin/HEAD`) is left out. A ref that names something
|
|
65
|
+
* other than a commit (refs/codex/turn-diffs/… name trees) is kept with its object type: it is part of what the
|
|
66
|
+
* repository records, and has no history to walk.
|
|
67
|
+
*/
|
|
68
|
+
export function listRefs(dir: string): RefInfo[] {
|
|
69
|
+
const r = ledgerGit(dir, ['for-each-ref', '--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)%00%(symref)']);
|
|
70
|
+
if (!r.ok) return [];
|
|
71
|
+
const out: RefInfo[] = [];
|
|
72
|
+
for (const line of r.out.split(/\r?\n/)) {
|
|
73
|
+
if (!line.trim()) continue;
|
|
74
|
+
const [refname, type, object, peeledType, peeled, symref] = line.split('\0');
|
|
75
|
+
if (!refname || symref) continue;
|
|
76
|
+
const commit = type === 'commit' ? object : peeledType === 'commit' ? peeled : '';
|
|
77
|
+
if (commit && isHash(commit)) out.push({ refname, namespace: namespaceOf(refname), tip: commit, type: 'commit' });
|
|
78
|
+
else if (object && isHash(object)) out.push({ refname, namespace: namespaceOf(refname), tip: object, type: (peeledType || type || 'unknown') as RefInfo['type'] });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The commits reachable from `tips` and from none of `exclude` (full hashes), in one read. */
|
|
84
|
+
export function revList(dir: string, tips: readonly string[], exclude: readonly string[] = []): string[] | null {
|
|
85
|
+
if (tips.length === 0) return [];
|
|
86
|
+
const r = ledgerGit(dir, ['rev-list', '--stdin'], { input: [...tips, ...exclude.map((h) => `^${h}`)].join('\n') + '\n' });
|
|
87
|
+
return r.ok ? r.out.split(/\s+/).filter(isHash) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The commits on the first-parent line of a tip. */
|
|
91
|
+
export function firstParentLine(dir: string, tip: string): string[] {
|
|
92
|
+
const r = ledgerGit(dir, ['rev-list', '--first-parent', tip]);
|
|
93
|
+
return r.ok ? r.out.split(/\s+/).filter(isHash) : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether `ancestor` is reachable from `descendant`; null when git cannot tell (a missing object). */
|
|
97
|
+
export function isAncestor(dir: string, ancestor: string, descendant: string): boolean | null {
|
|
98
|
+
if (ancestor === descendant) return true;
|
|
99
|
+
const r = ledgerGit(dir, ['merge-base', '--is-ancestor', ancestor, descendant], { timeoutMs: 30_000 });
|
|
100
|
+
if (r.ok) return true;
|
|
101
|
+
return /^\s*$/.test(r.err) ? false : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CommitFileChange {
|
|
105
|
+
readonly status: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
|
|
106
|
+
readonly path: string;
|
|
107
|
+
/** Rename or copy source. */
|
|
108
|
+
readonly from: string | null;
|
|
109
|
+
readonly oldBlob: string | null;
|
|
110
|
+
readonly newBlob: string | null;
|
|
111
|
+
/** Null for a binary file. */
|
|
112
|
+
readonly added: number | null;
|
|
113
|
+
readonly deleted: number | null;
|
|
114
|
+
}
|
|
115
|
+
export interface CommitDetail {
|
|
116
|
+
readonly hash: string;
|
|
117
|
+
readonly parents: readonly string[];
|
|
118
|
+
readonly authorAt: string;
|
|
119
|
+
readonly committerAt: string;
|
|
120
|
+
readonly author: string;
|
|
121
|
+
readonly subject: string;
|
|
122
|
+
readonly body: string;
|
|
123
|
+
readonly files: readonly CommitFileChange[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ZERO = /^0{40}$/;
|
|
127
|
+
const DETAIL_FORMAT = '--format=%x1e%H%x1f%P%x1f%aI%x1f%cI%x1f%an%x1f%s%x1f%b%x04';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse `git log -z --raw --numstat` output. With -z every field ends in NUL: a raw entry is `:modes blobs STATUS` and
|
|
131
|
+
* then its path (two paths for a rename or a copy); a numstat entry is `added\tdeleted\tpath`, or `added\tdeleted\t`
|
|
132
|
+
* followed by the two paths of a rename. A commit's header is the format above, from \x1e to \x04.
|
|
133
|
+
*/
|
|
134
|
+
export function parseLogZ(out: string): CommitDetail[] {
|
|
135
|
+
const tokens = out.split('\0');
|
|
136
|
+
const commits: CommitDetail[] = [];
|
|
137
|
+
let head: { hash: string; parents: string[]; authorAt: string; committerAt: string; author: string; subject: string; body: string } | null = null;
|
|
138
|
+
let files = new Map<string, { status: CommitFileChange['status']; path: string; from: string | null; oldBlob: string | null; newBlob: string | null; added: number | null; deleted: number | null }>();
|
|
139
|
+
const flush = () => {
|
|
140
|
+
if (head) commits.push({ ...head, files: [...files.values()] });
|
|
141
|
+
head = null;
|
|
142
|
+
files = new Map();
|
|
143
|
+
};
|
|
144
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
145
|
+
let t = tokens[i]!;
|
|
146
|
+
const start = t.indexOf('\x1e');
|
|
147
|
+
if (start >= 0) {
|
|
148
|
+
flush();
|
|
149
|
+
t = t.slice(start + 1);
|
|
150
|
+
const end = t.lastIndexOf('\x04');
|
|
151
|
+
const fields = (end >= 0 ? t.slice(0, end) : t).split('\x1f');
|
|
152
|
+
const hash = fields[0] ?? '';
|
|
153
|
+
if (!isHash(hash)) continue;
|
|
154
|
+
head = {
|
|
155
|
+
hash, parents: (fields[1] ?? '').split(' ').filter(isHash), authorAt: fields[2] ?? '', committerAt: fields[3] ?? '',
|
|
156
|
+
author: fields[4] ?? '', subject: fields[5] ?? '', body: (fields.slice(6).join('\x1f')).replace(/\s+$/, ''),
|
|
157
|
+
};
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!head) continue;
|
|
161
|
+
t = t.replace(/^\n+/, '');
|
|
162
|
+
if (!t) continue;
|
|
163
|
+
const raw = /^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([AMDRCTUX])(\d*)$/.exec(t);
|
|
164
|
+
if (raw) {
|
|
165
|
+
const status = raw[5] as CommitFileChange['status'];
|
|
166
|
+
const two = status === 'R' || status === 'C';
|
|
167
|
+
const first = tokens[i + 1] ?? '';
|
|
168
|
+
const second = two ? tokens[i + 2] ?? '' : null;
|
|
169
|
+
i += two ? 2 : 1;
|
|
170
|
+
if (!'AMDRCT'.includes(status)) continue;
|
|
171
|
+
const path = two ? second! : first;
|
|
172
|
+
files.set(path, {
|
|
173
|
+
status, path, from: two ? first : null,
|
|
174
|
+
oldBlob: ZERO.test(raw[3]!) ? null : raw[3]!, newBlob: ZERO.test(raw[4]!) ? null : raw[4]!,
|
|
175
|
+
added: null, deleted: null,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const num = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(t);
|
|
180
|
+
if (num) {
|
|
181
|
+
let path = num[3]!;
|
|
182
|
+
if (path === '') { path = tokens[i + 2] ?? ''; i += 2; }
|
|
183
|
+
const entry = files.get(path);
|
|
184
|
+
if (entry) {
|
|
185
|
+
entry.added = num[1] === '-' ? null : Number(num[1]);
|
|
186
|
+
entry.deleted = num[2] === '-' ? null : Number(num[2]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
flush();
|
|
191
|
+
return commits;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The details of the given commits in one pass per 2,000: metadata, the whole message, and every file changed against
|
|
196
|
+
* the first parent — status, rename or copy source, old and new content ids, line counts (rename-aware). A merge's
|
|
197
|
+
* changes are what it brought into the line it was merged onto; a root commit's are everything it added.
|
|
198
|
+
*/
|
|
199
|
+
export function logDetails(dir: string, hashes: readonly string[]): { details: CommitDetail[]; failed: number } {
|
|
200
|
+
const out: CommitDetail[] = [];
|
|
201
|
+
let failed = 0;
|
|
202
|
+
for (let i = 0; i < hashes.length; i += 2000) {
|
|
203
|
+
const chunk = hashes.slice(i, i + 2000);
|
|
204
|
+
const r = ledgerGit(dir, ['log', '--no-walk', '--stdin', '--diff-merges=first-parent', '-M', '--raw', '--no-abbrev', '--numstat', '--no-textconv', '--no-ext-diff', '-z', DETAIL_FORMAT], { input: chunk.join('\n') + '\n' });
|
|
205
|
+
if (!r.ok) { failed += chunk.length; continue; }
|
|
206
|
+
out.push(...parseLogZ(r.out));
|
|
207
|
+
}
|
|
208
|
+
return { details: out, failed };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Contents of blobs by id, one `git cat-file --batch` per 500; a missing object maps to null. */
|
|
212
|
+
export function catBlobs(dir: string, blobs: readonly string[]): Map<string, Buffer | null> {
|
|
213
|
+
const out = new Map<string, Buffer | null>();
|
|
214
|
+
const wanted = [...new Set(blobs)];
|
|
215
|
+
for (let i = 0; i < wanted.length; i += 500) {
|
|
216
|
+
const chunk = wanted.slice(i, i + 500);
|
|
217
|
+
let buf: Buffer;
|
|
218
|
+
try {
|
|
219
|
+
buf = execFileSync('git', [...FIXED, '-C', dir, 'cat-file', '--batch'], {
|
|
220
|
+
input: chunk.join('\n') + '\n', env: env(), timeout: 300_000, maxBuffer: 1_500_000_000, windowsHide: true,
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
for (const q of chunk) out.set(q, null);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
let pos = 0;
|
|
227
|
+
let q = 0;
|
|
228
|
+
while (pos < buf.length && q < chunk.length) {
|
|
229
|
+
const eol = buf.indexOf(10, pos);
|
|
230
|
+
if (eol < 0) break;
|
|
231
|
+
const header = buf.subarray(pos, eol).toString('latin1');
|
|
232
|
+
pos = eol + 1;
|
|
233
|
+
const query = chunk[q]!;
|
|
234
|
+
q += 1;
|
|
235
|
+
const m = /^[0-9a-f]{40} (\S+) (\d+)$/.exec(header);
|
|
236
|
+
if (!m) { out.set(query, null); continue; } // "<id> missing"
|
|
237
|
+
const size = Number(m[2]);
|
|
238
|
+
out.set(query, m[1] === 'blob' ? buf.subarray(pos, pos + size) : null);
|
|
239
|
+
pos += size + 1;
|
|
240
|
+
}
|
|
241
|
+
while (q < chunk.length) { out.set(chunk[q]!, null); q += 1; }
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Whether content looks like text: no NUL in its first 8,000 bytes. */
|
|
247
|
+
export const isText = (buf: Buffer): boolean => !buf.subarray(0, Math.min(buf.length, 8000)).includes(0);
|
|
248
|
+
|
|
249
|
+
export interface TreeFile { readonly path: string; readonly size: number | null; readonly blob: string; readonly submodule: boolean }
|
|
250
|
+
/** The files of a commit's tree, with content ids and sizes (a submodule has no size). */
|
|
251
|
+
export function treeFiles(dir: string, ref: string): TreeFile[] {
|
|
252
|
+
const r = ledgerGit(dir, ['ls-tree', '-r', '-l', '-z', ref]);
|
|
253
|
+
if (!r.ok) return [];
|
|
254
|
+
const out: TreeFile[] = [];
|
|
255
|
+
for (const entry of r.out.split('\0')) {
|
|
256
|
+
if (!entry) continue;
|
|
257
|
+
const tab = entry.indexOf('\t');
|
|
258
|
+
if (tab < 0) continue;
|
|
259
|
+
const [mode, type, blob, size] = entry.slice(0, tab).trim().split(/\s+/);
|
|
260
|
+
const path = entry.slice(tab + 1);
|
|
261
|
+
if (mode === '160000' || type === 'commit') { out.push({ path, size: null, blob: blob ?? '', submodule: true }); continue; }
|
|
262
|
+
if (type !== 'blob') continue;
|
|
263
|
+
out.push({ path, size: size && /^\d+$/.test(size) ? Number(size) : null, blob: blob ?? '', submodule: false });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The repository's default branch (the trunk): origin's HEAD when set, else main, else master, else the checked-out branch. */
|
|
269
|
+
export function defaultBranch(dir: string, refs: readonly RefInfo[]): string | null {
|
|
270
|
+
const origin = ledgerGit(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { timeoutMs: 15_000 });
|
|
271
|
+
if (origin.ok && origin.out.trim()) {
|
|
272
|
+
const name = origin.out.trim().replace(/^[^/]+\//, '');
|
|
273
|
+
if (name && refs.some((r) => r.refname === `refs/heads/${name}`)) return name;
|
|
274
|
+
}
|
|
275
|
+
for (const name of ['main', 'master', 'trunk', 'develop']) if (refs.some((r) => r.refname === `refs/heads/${name}`)) return name;
|
|
276
|
+
const current = ledgerGit(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { timeoutMs: 15_000 });
|
|
277
|
+
return current.ok && current.out.trim() ? current.out.trim() : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function headOf(dir: string): { hash: string | null; branch: string | null } {
|
|
281
|
+
const h = ledgerGit(dir, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { timeoutMs: 15_000 }).out.trim();
|
|
282
|
+
const b = ledgerGit(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { timeoutMs: 15_000 }).out.trim();
|
|
283
|
+
return { hash: isHash(h) ? h : null, branch: b || null };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface WorktreeEntry {
|
|
287
|
+
readonly path: string;
|
|
288
|
+
readonly head: string | null;
|
|
289
|
+
readonly branch: string | null;
|
|
290
|
+
readonly detached: boolean;
|
|
291
|
+
readonly bare: boolean;
|
|
292
|
+
readonly prunable: boolean;
|
|
293
|
+
}
|
|
294
|
+
/** `git worktree list --porcelain`, the main checkout first. */
|
|
295
|
+
export function worktreeList(dir: string): WorktreeEntry[] {
|
|
296
|
+
const r = ledgerGit(dir, ['worktree', 'list', '--porcelain', '-z'], { timeoutMs: 30_000 });
|
|
297
|
+
if (!r.ok) return [];
|
|
298
|
+
const out: WorktreeEntry[] = [];
|
|
299
|
+
let cur: { path?: string; head?: string; branch?: string; detached?: boolean; bare?: boolean; prunable?: boolean } = {};
|
|
300
|
+
const flush = () => {
|
|
301
|
+
if (cur.path) out.push({ path: canonicalPath(cur.path), head: cur.head ?? null, branch: cur.branch?.replace(/^refs\/heads\//, '') ?? null, detached: cur.detached === true, bare: cur.bare === true, prunable: cur.prunable === true });
|
|
302
|
+
cur = {};
|
|
303
|
+
};
|
|
304
|
+
for (const field of r.out.split('\0')) {
|
|
305
|
+
if (field.startsWith('worktree ')) { flush(); cur.path = field.slice(9); }
|
|
306
|
+
else if (field.startsWith('HEAD ')) cur.head = field.slice(5);
|
|
307
|
+
else if (field.startsWith('branch ')) cur.branch = field.slice(7);
|
|
308
|
+
else if (field === 'detached') cur.detached = true;
|
|
309
|
+
else if (field === 'bare') cur.bare = true;
|
|
310
|
+
else if (field.startsWith('prunable')) cur.prunable = true;
|
|
311
|
+
else if (field === '') flush();
|
|
312
|
+
}
|
|
313
|
+
flush();
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Uncommitted changes of a checkout (porcelain lines); null when git cannot read it. Takes no lock (GIT_OPTIONAL_LOCKS=0). */
|
|
318
|
+
export function uncommitted(dir: string): string[] | null {
|
|
319
|
+
const r = ledgerGit(dir, ['status', '--porcelain', '--untracked-files=normal'], { timeoutMs: 60_000 });
|
|
320
|
+
return r.ok ? r.out.split(/\r?\n/).filter((l) => l.trim()) : null;
|
|
321
|
+
}
|