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
package/src/util/git.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git access. Every call disables optional locks and pagers, so observing a
|
|
3
|
+
* repository never changes its state (Spec §1.1: the Keeper's own work does not change
|
|
4
|
+
* version-control state).
|
|
5
|
+
*/
|
|
6
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { canonicalPath } from './paths.ts';
|
|
9
|
+
|
|
10
|
+
export interface GitResult {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
readonly out: string;
|
|
13
|
+
readonly err: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Given to every git call ProjectKeeper makes. Without it Git for Windows stops at any path of 260 characters or more,
|
|
18
|
+
* its own files included: `<repository>\.git\objects\…` is that long once the repository's own path passes about
|
|
19
|
+
* 200. Then `status` fails, a tracked file deep in the tree reads as deleted, and nothing there can be hashed or added.
|
|
20
|
+
* (What no setting helps, a repository whose own path is too long, is util/paths.ts `gitPathLimit`.)
|
|
21
|
+
*/
|
|
22
|
+
export const LONG_PATHS: readonly string[] = ['-c', 'core.longpaths=true'];
|
|
23
|
+
|
|
24
|
+
function env(): NodeJS.ProcessEnv {
|
|
25
|
+
const copy: NodeJS.ProcessEnv = {};
|
|
26
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
27
|
+
if (!['GIT_PAGER', 'PAGER', 'GIT_EXTERNAL_DIFF', 'GIT_OPTIONAL_LOCKS'].includes(key.toUpperCase())) copy[key] = value;
|
|
28
|
+
}
|
|
29
|
+
copy.GIT_OPTIONAL_LOCKS = '0';
|
|
30
|
+
return copy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function git(cwd: string, args: readonly string[], timeoutMs = 8000, maxBuffer = 16_000_000): GitResult {
|
|
34
|
+
try {
|
|
35
|
+
const out = execFileSync('git', ['--no-pager', '-c', 'core.fsmonitor=false', ...LONG_PATHS, '-C', cwd, ...args], {
|
|
36
|
+
encoding: 'utf8', env: env(), timeout: timeoutMs, maxBuffer, windowsHide: true,
|
|
37
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
});
|
|
39
|
+
return { ok: true, out, err: '' };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const e = error as { status?: number; stdout?: string; stderr?: string; message?: string };
|
|
42
|
+
return { ok: false, out: e.stdout ?? '', err: (e.stderr || e.message || String(error)).trim() };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The paths git reports are put in the file system's own spelling (canonicalPath), the one every location is kept in:
|
|
47
|
+
// git usually prints that spelling already, but not for a repository reached through a mapped drive, and a worktree
|
|
48
|
+
// is listed as it was spelled when it was registered.
|
|
49
|
+
|
|
50
|
+
export function gitToplevel(dir: string): string | null {
|
|
51
|
+
const r = git(dir, ['rev-parse', '--show-toplevel']);
|
|
52
|
+
return r.ok && r.out.trim() ? canonicalPath(resolve(r.out.trim())) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function gitCommonDir(dir: string): string | null {
|
|
56
|
+
const r = git(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
57
|
+
return r.ok && r.out.trim() ? canonicalPath(r.out.trim()) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function gitDir(dir: string): string | null {
|
|
61
|
+
const r = git(dir, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
62
|
+
return r.ok && r.out.trim() ? canonicalPath(r.out.trim()) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WorktreeInfo {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
readonly head: string | null;
|
|
68
|
+
readonly branch: string | null;
|
|
69
|
+
readonly detached: boolean;
|
|
70
|
+
readonly bare: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function gitWorktrees(dir: string): WorktreeInfo[] {
|
|
74
|
+
const r = git(dir, ['worktree', 'list', '--porcelain']);
|
|
75
|
+
if (!r.ok) return [];
|
|
76
|
+
const out: WorktreeInfo[] = [];
|
|
77
|
+
let current: { path?: string; head?: string; branch?: string; detached?: boolean; bare?: boolean } = {};
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (current.path) {
|
|
80
|
+
out.push({
|
|
81
|
+
path: canonicalPath(current.path), head: current.head ?? null, branch: current.branch ?? null,
|
|
82
|
+
detached: current.detached === true, bare: current.bare === true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
current = {};
|
|
86
|
+
};
|
|
87
|
+
for (const line of r.out.split(/\r?\n/)) {
|
|
88
|
+
if (line.startsWith('worktree ')) { flush(); current.path = line.slice(9); }
|
|
89
|
+
else if (line.startsWith('HEAD ')) current.head = line.slice(5);
|
|
90
|
+
else if (line.startsWith('branch ')) current.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
91
|
+
else if (line === 'detached') current.detached = true;
|
|
92
|
+
else if (line === 'bare') current.bare = true;
|
|
93
|
+
else if (line === '') flush();
|
|
94
|
+
}
|
|
95
|
+
flush();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function gitHead(dir: string): string | null {
|
|
100
|
+
const r = git(dir, ['rev-parse', 'HEAD']);
|
|
101
|
+
return r.ok && /^[0-9a-f]{40}/.test(r.out.trim()) ? r.out.trim() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function gitBranch(dir: string): string | null {
|
|
105
|
+
const r = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
106
|
+
return r.ok && r.out.trim() && r.out.trim() !== 'HEAD' ? r.out.trim() : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** First commit(s) of the history; two repositories sharing one are a copy or a fork of each other. */
|
|
110
|
+
export function gitRootCommits(dir: string): string[] {
|
|
111
|
+
const r = git(dir, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
112
|
+
return r.ok ? r.out.split(/\s+/).filter((h) => /^[0-9a-f]{40}$/.test(h)).sort() : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function gitRemotes(dir: string): string[] {
|
|
116
|
+
const r = git(dir, ['remote', '-v']);
|
|
117
|
+
return r.ok ? r.out.split(/\r?\n/).filter((l) => l.trim()) : [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GitStatus {
|
|
121
|
+
readonly branch: string | null;
|
|
122
|
+
readonly head: string | null;
|
|
123
|
+
readonly dirty: readonly string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The same read-only git call without blocking the event loop (used where the workbench must keep answering). */
|
|
127
|
+
export function gitAsync(cwd: string, args: readonly string[], timeoutMs = 8000): Promise<GitResult> {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
execFile('git', ['--no-pager', '-c', 'core.fsmonitor=false', ...LONG_PATHS, '-C', cwd, ...args], {
|
|
130
|
+
encoding: 'utf8', env: env(), timeout: timeoutMs, maxBuffer: 16_000_000, windowsHide: true,
|
|
131
|
+
}, (error, stdout, stderr) => {
|
|
132
|
+
if (error) resolve({ ok: false, out: String(stdout ?? ''), err: String(stderr || error.message) });
|
|
133
|
+
else resolve({ ok: true, out: String(stdout), err: '' });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function gitStatusAsync(dir: string): Promise<GitStatus | null> {
|
|
139
|
+
const [status, head, branch] = await Promise.all([
|
|
140
|
+
gitAsync(dir, ['status', '--porcelain', '--untracked-files=normal']),
|
|
141
|
+
gitAsync(dir, ['rev-parse', 'HEAD']),
|
|
142
|
+
gitAsync(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
143
|
+
]);
|
|
144
|
+
if (!status.ok) return null;
|
|
145
|
+
const h = head.out.trim();
|
|
146
|
+
const b = branch.out.trim();
|
|
147
|
+
return { branch: branch.ok && b && b !== 'HEAD' ? b : null, head: head.ok && /^[0-9a-f]{40}/.test(h) ? h : null, dirty: status.out.split(/\r?\n/).filter((l) => l.trim()) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function gitStatus(dir: string): GitStatus | null {
|
|
151
|
+
const r = git(dir, ['status', '--porcelain', '--untracked-files=normal']);
|
|
152
|
+
if (!r.ok) return null;
|
|
153
|
+
return { branch: gitBranch(dir), head: gitHead(dir), dirty: r.out.split(/\r?\n/).filter((l) => l.trim()) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CommitInfo {
|
|
157
|
+
readonly hash: string;
|
|
158
|
+
readonly at: string;
|
|
159
|
+
readonly author: string;
|
|
160
|
+
readonly subject: string;
|
|
161
|
+
readonly files: readonly string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The commits reachable from HEAD — all of them unless `limit` asks for fewer (D77: the history is read in full, no
|
|
166
|
+
* recent-N cap), since `since` when given; `notFrom` excludes what another ref already reaches (a worktree's commits
|
|
167
|
+
* beyond its main repository).
|
|
168
|
+
*/
|
|
169
|
+
export function gitRecentCommits(dir: string, since: string | null, limit: number | null = null, notFrom: string | null = null): CommitInfo[] {
|
|
170
|
+
const args = ['log', ...(limit !== null ? [`-n${Math.max(1, Math.floor(limit))}`] : []), '--date=iso-strict', '--name-only', '--format=%x1e%H%x1f%aI%x1f%an%x1f%s'];
|
|
171
|
+
if (since) args.push(`--since=${since}`);
|
|
172
|
+
if (notFrom) args.push('HEAD', `^${notFrom}`);
|
|
173
|
+
// A whole history takes longer and prints more than the recent part did.
|
|
174
|
+
const r = git(dir, args, limit === null ? 180_000 : 20000, limit === null ? 512_000_000 : 16_000_000);
|
|
175
|
+
if (!r.ok) return [];
|
|
176
|
+
const out: CommitInfo[] = [];
|
|
177
|
+
for (const block of r.out.split('\x1e')) {
|
|
178
|
+
const trimmed = block.trim();
|
|
179
|
+
if (!trimmed) continue;
|
|
180
|
+
const [header, ...rest] = trimmed.split(/\r?\n/);
|
|
181
|
+
const [hash, at, author, subject] = (header ?? '').split('\x1f');
|
|
182
|
+
if (!hash) continue;
|
|
183
|
+
out.push({ hash, at: at ?? '', author: author ?? '', subject: subject ?? '', files: rest.map((l) => l.trim()).filter(Boolean) });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Content of a path at a commit; null when it does not exist there. */
|
|
189
|
+
export function gitShow(dir: string, commit: string, path: string): string | null {
|
|
190
|
+
const r = git(dir, ['show', `${commit}:${path.split('\\').join('/')}`], 20000);
|
|
191
|
+
return r.ok ? r.out : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ───────────────────────── reading version history (Spec §1.2 `History only`, §3.1; CKC-03 AC-24) ─────────────────────────
|
|
195
|
+
//
|
|
196
|
+
// Everything below is a read: log, show, ls-tree, cat-file, rev-parse, diff --name-only and hash-object without -w. Paths
|
|
197
|
+
// come raw (core.quotePath=false), so names in any script read as they are. A user-given ref only ever reaches
|
|
198
|
+
// rev-parse, after `--end-of-options`; every later call uses the full hash it resolved to.
|
|
199
|
+
|
|
200
|
+
/** The same read-only call with room for a long history: a larger buffer and time limit, raw path names. */
|
|
201
|
+
export function gitRead(cwd: string, args: readonly string[], options: { readonly timeoutMs?: number; readonly maxBuffer?: number } = {}): GitResult {
|
|
202
|
+
try {
|
|
203
|
+
const out = execFileSync('git', ['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false', ...LONG_PATHS, '-C', cwd, ...args], {
|
|
204
|
+
encoding: 'utf8', env: env(), timeout: options.timeoutMs ?? 30_000, maxBuffer: options.maxBuffer ?? 64_000_000, windowsHide: true,
|
|
205
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
206
|
+
});
|
|
207
|
+
return { ok: true, out, err: '' };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const e = error as { stdout?: string; stderr?: string; message?: string };
|
|
210
|
+
return { ok: false, out: e.stdout ?? '', err: (e.stderr || e.message || String(error)).trim() };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A ref the history tools accept — a hash, a branch or tag, HEAD, with `~`, `^` or `@{…}` after it — and never an option. */
|
|
215
|
+
export function isSafeRef(ref: string): boolean {
|
|
216
|
+
return ref.length > 0 && ref.length <= 200 && !ref.startsWith('-') && !ref.includes('..') && /^[A-Za-z0-9._/@{}~^+-]+$/.test(ref);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The full hash of the commit a ref names, or null when it names none. */
|
|
220
|
+
export function gitResolveCommit(dir: string, ref: string): string | null {
|
|
221
|
+
if (!isSafeRef(ref)) return null;
|
|
222
|
+
const r = gitRead(dir, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { timeoutMs: 8000 });
|
|
223
|
+
const hash = r.out.trim();
|
|
224
|
+
return r.ok && /^[0-9a-f]{40}$/.test(hash) ? hash : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface CommitMeta {
|
|
228
|
+
readonly hash: string;
|
|
229
|
+
readonly parents: readonly string[];
|
|
230
|
+
readonly at: string;
|
|
231
|
+
readonly author: string;
|
|
232
|
+
readonly subject: string;
|
|
233
|
+
}
|
|
234
|
+
const META_FORMAT = '--format=%x1e%H%x1f%P%x1f%aI%x1f%an%x1f%s';
|
|
235
|
+
function metaOf(header: string): CommitMeta | null {
|
|
236
|
+
const [hash, parents, at, author, subject] = header.split('\x1f');
|
|
237
|
+
if (!hash || !/^[0-9a-f]{40}$/.test(hash)) return null;
|
|
238
|
+
return { hash, parents: (parents ?? '').split(' ').filter(Boolean), at: at ?? '', author: author ?? '', subject: subject ?? '' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Author, time, subject and parents of one commit. */
|
|
242
|
+
export function gitCommitMeta(dir: string, hash: string): CommitMeta | null {
|
|
243
|
+
const r = gitRead(dir, ['log', '-1', META_FORMAT, hash, '--'], { timeoutMs: 8000 });
|
|
244
|
+
return r.ok ? metaOf(r.out.replace(/^\x1e/, '').trim()) : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A path git printed in C quotes (only control characters, quotes and backslashes are quoted once quotePath is off). */
|
|
248
|
+
function unquote(path: string): string {
|
|
249
|
+
if (!(path.startsWith('"') && path.endsWith('"'))) return path;
|
|
250
|
+
const body = path.slice(1, -1);
|
|
251
|
+
const bytes: number[] = [];
|
|
252
|
+
for (let i = 0; i < body.length; i++) {
|
|
253
|
+
const c = body[i]!;
|
|
254
|
+
if (c !== '\\') { bytes.push(...Buffer.from(c, 'utf8')); continue; }
|
|
255
|
+
const n = body[++i] ?? '';
|
|
256
|
+
if (/[0-7]/.test(n)) { const oct = body.slice(i, i + 3); bytes.push(parseInt(oct, 8)); i += 2; continue; }
|
|
257
|
+
bytes.push(({ n: 10, t: 9, r: 13, '"': 34, '\\': 92, a: 7, b: 8, f: 12, v: 11 } as Record<string, number>)[n] ?? n.charCodeAt(0));
|
|
258
|
+
}
|
|
259
|
+
return Buffer.from(bytes).toString('utf8');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface PathChange {
|
|
263
|
+
/** A added, M modified, D deleted, R renamed, C copied, T type changed. */
|
|
264
|
+
readonly status: 'A' | 'M' | 'D' | 'R' | 'C' | 'T';
|
|
265
|
+
readonly path: string;
|
|
266
|
+
/** The other side of a rename or copy: where it came from (`path` is where it went). */
|
|
267
|
+
readonly from: string | null;
|
|
268
|
+
}
|
|
269
|
+
export interface PathCommit extends CommitMeta {
|
|
270
|
+
/** The changes of this commit that touch the path (a file's own line, or every file under a directory). */
|
|
271
|
+
readonly changes: readonly PathChange[];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const under = (path: string, target: string): boolean => path === target || path.startsWith(`${target}/`);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Every commit reachable from `ref` that touched a path, newest first — including the one that deleted it, and a rename
|
|
278
|
+
* away from it with where it went. `--full-diff` lets rename detection see both sides of a move out of the path. A page
|
|
279
|
+
* of `limit` commits after the first `skip`; paging reaches the whole history.
|
|
280
|
+
*/
|
|
281
|
+
export function gitPathHistory(dir: string, relPath: string, limit = 100, ref = 'HEAD', skip = 0): PathCommit[] | null {
|
|
282
|
+
const target = relPath.replace(/\/+$/, '');
|
|
283
|
+
const r = gitRead(dir, ['log', '-M', '--full-diff', '--name-status', META_FORMAT, `-n${Math.max(1, limit)}`, ...(skip > 0 ? [`--skip=${Math.floor(skip)}`] : []), ref, '--', target], { timeoutMs: 120_000, maxBuffer: 256_000_000 });
|
|
284
|
+
if (!r.ok) return null;
|
|
285
|
+
const out: PathCommit[] = [];
|
|
286
|
+
for (const block of r.out.split('\x1e')) {
|
|
287
|
+
const lines = block.split(/\r?\n/);
|
|
288
|
+
const meta = metaOf(lines[0] ?? '');
|
|
289
|
+
if (!meta) continue;
|
|
290
|
+
const changes: PathChange[] = [];
|
|
291
|
+
for (const line of lines.slice(1)) {
|
|
292
|
+
if (!line.trim()) continue;
|
|
293
|
+
const [code, a, b] = line.split('\t');
|
|
294
|
+
const status = (code ?? '').charAt(0) as PathChange['status'];
|
|
295
|
+
if (!'AMDRCT'.includes(status) || !a) continue;
|
|
296
|
+
const first = unquote(a);
|
|
297
|
+
const second = b !== undefined ? unquote(b) : null;
|
|
298
|
+
if ((status === 'R' || status === 'C') && second !== null) {
|
|
299
|
+
if (under(first, target) || under(second, target)) changes.push({ status, path: second, from: first });
|
|
300
|
+
} else if (under(first, target)) changes.push({ status, path: first, from: null });
|
|
301
|
+
}
|
|
302
|
+
if (changes.length) out.push({ ...meta, changes });
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface TreeEntry {
|
|
308
|
+
readonly type: 'blob' | 'tree' | 'commit';
|
|
309
|
+
readonly id: string;
|
|
310
|
+
readonly size: number | null;
|
|
311
|
+
}
|
|
312
|
+
/** What a path is at a commit — a file (blob), a directory (tree) or a submodule — or null when it is not there. */
|
|
313
|
+
export function gitTreeEntry(dir: string, commit: string, relPath: string): TreeEntry | null {
|
|
314
|
+
const r = gitRead(dir, ['ls-tree', '-l', '-z', commit, '--', relPath.replace(/\/+$/, '')], { timeoutMs: 8000 });
|
|
315
|
+
if (!r.ok) return null;
|
|
316
|
+
const wanted = relPath.replace(/\/+$/, '');
|
|
317
|
+
for (const entry of r.out.split('\0')) {
|
|
318
|
+
const tab = entry.indexOf('\t');
|
|
319
|
+
if (tab < 0 || entry.slice(tab + 1) !== wanted) continue;
|
|
320
|
+
const [, type, id, size] = entry.slice(0, tab).trim().split(/\s+/);
|
|
321
|
+
if (type !== 'blob' && type !== 'tree' && type !== 'commit') return null;
|
|
322
|
+
return { type, id: id ?? '', size: size && /^\d+$/.test(size) ? Number(size) : null };
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The blob id a working-tree file would have if it were added now (filters applied, nothing written). */
|
|
328
|
+
export function gitHashWorkingFile(dir: string, absPath: string, relPath: string): string | null {
|
|
329
|
+
const r = gitRead(dir, ['hash-object', `--path=${relPath}`, '--', absPath], { timeoutMs: 8000 });
|
|
330
|
+
const id = r.out.trim();
|
|
331
|
+
return r.ok && /^[0-9a-f]{40}$/.test(id) ? id : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Every file path in a commit's tree, optionally under a path. */
|
|
335
|
+
export function gitTrackedPaths(dir: string, ref = 'HEAD', relPath: string | null = null): Set<string> | null {
|
|
336
|
+
const r = gitRead(dir, ['ls-tree', '-r', '--name-only', '-z', ref, ...(relPath ? ['--', relPath] : [])]);
|
|
337
|
+
return r.ok ? new Set(r.out.split('\0').filter(Boolean)) : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface DeletedPath {
|
|
341
|
+
readonly path: string;
|
|
342
|
+
/** The commit that deleted it last — the newest deletion when it was deleted, added back and deleted again. */
|
|
343
|
+
readonly deletedIn: CommitMeta;
|
|
344
|
+
}
|
|
345
|
+
/** Files deleted in the history reachable from `ref`, newest deletion first; a rename is not a deletion. */
|
|
346
|
+
export function gitDeletedPaths(dir: string, ref = 'HEAD', relPath: string | null = null): DeletedPath[] | null {
|
|
347
|
+
const r = gitRead(dir, ['log', '-M', '--diff-filter=D', '--name-only', META_FORMAT, ref, ...(relPath ? ['--', relPath] : [])], { timeoutMs: 120_000, maxBuffer: 256_000_000 });
|
|
348
|
+
if (!r.ok) return null;
|
|
349
|
+
const seen = new Set<string>();
|
|
350
|
+
const out: DeletedPath[] = [];
|
|
351
|
+
for (const block of r.out.split('\x1e')) {
|
|
352
|
+
const lines = block.split(/\r?\n/);
|
|
353
|
+
const meta = metaOf(lines[0] ?? '');
|
|
354
|
+
if (!meta) continue;
|
|
355
|
+
for (const line of lines.slice(1)) {
|
|
356
|
+
const path = unquote(line.trim());
|
|
357
|
+
if (!path || seen.has(path)) continue;
|
|
358
|
+
seen.add(path);
|
|
359
|
+
out.push({ path, deletedIn: meta });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Files HEAD has that the working tree no longer has: deletions not committed yet. */
|
|
366
|
+
export function gitUncommittedDeletions(dir: string): string[] {
|
|
367
|
+
const r = gitRead(dir, ['diff', '--name-only', '-z', '--diff-filter=D', 'HEAD', '--'], { timeoutMs: 20_000 });
|
|
368
|
+
return r.ok ? r.out.split('\0').filter(Boolean) : [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export type PathFate =
|
|
372
|
+
| { readonly kind: 'deleted'; readonly commit: CommitMeta }
|
|
373
|
+
| { readonly kind: 'moved'; readonly to: string; readonly commit: CommitMeta };
|
|
374
|
+
/**
|
|
375
|
+
* What became of paths HEAD no longer has: the commit that deleted each, or the one that moved it away and where to.
|
|
376
|
+
* Null for a path HEAD still has (a change in the working tree, not committed yet) or whose last commit says neither.
|
|
377
|
+
* A few calls per hundred paths, however many there are: a deleted directory is one question, not one per file.
|
|
378
|
+
*/
|
|
379
|
+
export function gitPathFates(dir: string, relPaths: readonly string[]): Map<string, PathFate | null> {
|
|
380
|
+
const out = new Map<string, PathFate | null>();
|
|
381
|
+
const wanted = [...new Set(relPaths)];
|
|
382
|
+
for (let i = 0; i < wanted.length; i += 100) {
|
|
383
|
+
const chunk = wanted.slice(i, i + 100);
|
|
384
|
+
for (const p of chunk) out.set(p, null);
|
|
385
|
+
const present = gitRead(dir, ['ls-tree', '-r', '--name-only', '-z', 'HEAD', '--', ...chunk]);
|
|
386
|
+
const inHead = new Set(present.ok ? present.out.split('\0').filter(Boolean) : chunk);
|
|
387
|
+
const open = new Set(chunk.filter((p) => !inHead.has(p)));
|
|
388
|
+
if (open.size === 0) continue;
|
|
389
|
+
const r = gitRead(dir, ['log', '-M', '--full-diff', '--name-status', META_FORMAT, 'HEAD', '--', ...open], { timeoutMs: 60_000 });
|
|
390
|
+
if (!r.ok) continue;
|
|
391
|
+
// Newest first: the first time a path shows up is what last happened to it.
|
|
392
|
+
for (const block of r.out.split('\x1e')) {
|
|
393
|
+
if (open.size === 0) break;
|
|
394
|
+
const lines = block.split(/\r?\n/);
|
|
395
|
+
const meta = metaOf(lines[0] ?? '');
|
|
396
|
+
if (!meta) continue;
|
|
397
|
+
for (const line of lines.slice(1)) {
|
|
398
|
+
const [code, a, b] = line.split('\t');
|
|
399
|
+
if (!a) continue;
|
|
400
|
+
const status = (code ?? '').charAt(0);
|
|
401
|
+
const first = unquote(a);
|
|
402
|
+
if (!open.has(first)) continue;
|
|
403
|
+
if (status === 'R' && b !== undefined) out.set(first, { kind: 'moved', to: unquote(b), commit: meta });
|
|
404
|
+
else if (status === 'D') out.set(first, { kind: 'deleted', commit: meta });
|
|
405
|
+
else if (status === 'C') continue; // copied from it: the path itself is still there after this commit
|
|
406
|
+
open.delete(first);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a new Node thread or process needs to load ProjectKeeper's own TypeScript sources.
|
|
3
|
+
*
|
|
4
|
+
* In a clone: nothing — Node strips the types itself. Installed as a package the sources sit under node_modules, where
|
|
5
|
+
* Node refuses to; bin/strip-types.mjs does it instead, and has to be loaded again in every worker thread and child
|
|
6
|
+
* process that runs one of these sources (the file scan's and the ledger rebuild's workers, the code engine's child).
|
|
7
|
+
*/
|
|
8
|
+
const underNodeModules = import.meta.url.includes('/node_modules/');
|
|
9
|
+
const preload = ['--import', new URL('../../bin/strip-types.mjs', import.meta.url).href];
|
|
10
|
+
|
|
11
|
+
/** For a child process: flags to put before the script. */
|
|
12
|
+
export const OWN_TS_NODE_ARGS: readonly string[] = underNodeModules ? preload : [];
|
|
13
|
+
|
|
14
|
+
/** For `new Worker(…)`: options to spread in. Nothing in a clone, so a worker there starts exactly as it always did. */
|
|
15
|
+
export const OWN_TS_WORKER_OPTIONS: { readonly execArgv?: string[] } = underNodeModules ? { execArgv: [...preload] } : {};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, parse, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const WIN = process.platform === 'win32';
|
|
6
|
+
const MAC = process.platform === 'darwin';
|
|
7
|
+
|
|
8
|
+
/** Absolute, normalised, without a trailing separator. Display form keeps the original case. */
|
|
9
|
+
export function normalizePath(path: string): string {
|
|
10
|
+
const abs = normalize(isAbsolute(path) ? path : resolve(path));
|
|
11
|
+
const root = parse(abs).root;
|
|
12
|
+
let out = abs;
|
|
13
|
+
while (out.length > root.length && /[\\/]$/.test(out)) out = out.slice(0, -1);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one spelling of a path, as the file system itself gives it: absolute, with links and junctions resolved and, on
|
|
19
|
+
* Windows, 8.3 short names expanded (`C:\Users\RUNNER~1` → `C:\Users\runneradmin`), a `subst` drive followed and
|
|
20
|
+
* every name in the case it has on disk. git reports paths this way (`rev-parse --show-toplevel`, `worktree list`),
|
|
21
|
+
* so a path that enters ProjectKeeper — a project's location, the home, a directory a configuration file names — is
|
|
22
|
+
* put in this spelling before it is kept or compared; two spellings of one directory compared as text are two
|
|
23
|
+
* directories, and a repository then looks like it is not one. What does not exist yet keeps its missing tail on top
|
|
24
|
+
* of the real spelling of the part that exists. A mapped network drive keeps its drive letter (its real spelling is
|
|
25
|
+
* a `\\server\share` path, which not every program can work in).
|
|
26
|
+
*/
|
|
27
|
+
export function canonicalPath(path: string): string {
|
|
28
|
+
const abs = normalizePath(path);
|
|
29
|
+
const tail: string[] = [];
|
|
30
|
+
for (let head = abs; ;) {
|
|
31
|
+
let real: string | null = null;
|
|
32
|
+
try { real = realpathSync.native(head); } catch {
|
|
33
|
+
// Some volumes (a RAM disk, some network file systems) do not answer the native call; the links are still resolved.
|
|
34
|
+
try { real = realpathSync(head); } catch { real = null; }
|
|
35
|
+
}
|
|
36
|
+
if (real !== null) {
|
|
37
|
+
if (WIN && real.startsWith('\\\\') && !abs.startsWith('\\\\')) return abs;
|
|
38
|
+
return normalizePath(tail.length ? join(real, ...tail.reverse()) : real);
|
|
39
|
+
}
|
|
40
|
+
const parent = dirname(head);
|
|
41
|
+
if (parent === head) return abs; // nothing of it exists, not even its root
|
|
42
|
+
tail.push(basename(head));
|
|
43
|
+
head = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A normalised path folded into the key two spellings of one file share on a system:
|
|
49
|
+
*
|
|
50
|
+
* - Windows: case folded. Its file systems take another case for the same name.
|
|
51
|
+
* - macOS: case folded and the Unicode form composed (NFC). The usual Mac volume takes another case for the same name,
|
|
52
|
+
* and a name in either Unicode form: Finder and Cocoa applications write `é` and Korean and Japanese syllables
|
|
53
|
+
* decomposed (NFD), a shell tool writes them as typed, and git reports them composed. A Mac volume formatted
|
|
54
|
+
* case-sensitive is treated the same way, as a directory made case-sensitive is on Windows: two names there that
|
|
55
|
+
* differ only in case are one name to ProjectKeeper.
|
|
56
|
+
* - anywhere else: as it is.
|
|
57
|
+
*
|
|
58
|
+
* Only keys are folded. A path that is opened, shown or handed to git keeps its spelling (`canonicalPath` does not
|
|
59
|
+
* fold, and must not: the name Claude Code gives a project's session folder is made from the directory's own
|
|
60
|
+
* characters), and the shell guard's keys (keeper/bounds/paths.ts `canonicalKey`) are not folded on macOS because the
|
|
61
|
+
* guard also opens them.
|
|
62
|
+
*/
|
|
63
|
+
export function foldForSystem(normalised: string, platform: NodeJS.Platform = process.platform): string {
|
|
64
|
+
if (platform === 'win32') return normalised.toLowerCase();
|
|
65
|
+
if (platform === 'darwin') return normalised.normalize('NFC').toLowerCase();
|
|
66
|
+
return normalised;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Comparison key: folded as the system's file system folds names (`foldForSystem`). Never shown to the user, never opened. */
|
|
70
|
+
export function pathKey(path: string): string {
|
|
71
|
+
return foldForSystem(normalizePath(path));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function samePath(a: string, b: string): boolean {
|
|
75
|
+
return pathKey(a) === pathKey(b);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A path that says where it is in full, drive included on Windows; any other would be completed from wherever this process runs. */
|
|
79
|
+
const FULL_PATH = WIN ? /^(?:[A-Za-z]:[\\/]|[\\/]{2})/ : /^\//;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Comparison key of the directory a path names: `pathKey` of its `canonicalPath`, so two spellings of one directory
|
|
83
|
+
* give one key. For a path somebody else wrote down — the working directory in an agent's session log — which is in
|
|
84
|
+
* whatever spelling that program ran under. A directory that no longer exists has no real spelling to ask for: its
|
|
85
|
+
* missing part is compared as text, on top of the real spelling of the part that exists. So a session recorded through
|
|
86
|
+
* a junction or a `subst` drive that is gone since is not recognised.
|
|
87
|
+
*/
|
|
88
|
+
export function directoryKey(path: string): string {
|
|
89
|
+
return pathKey(FULL_PATH.test(path) ? canonicalPath(path) : path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Two paths name one directory: the same text, or two spellings of it by the file system's own judgement. */
|
|
93
|
+
export function sameDirectory(a: string, b: string): boolean {
|
|
94
|
+
return samePath(a, b) || directoryKey(a) === directoryKey(b);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `path` is `root` or lives under it. */
|
|
98
|
+
export function isWithin(root: string, path: string): boolean {
|
|
99
|
+
const r = pathKey(root);
|
|
100
|
+
const p = pathKey(path);
|
|
101
|
+
return p === r || p.startsWith(r + sep);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A name, or a path below a project's location, in the one Unicode form ProjectKeeper keeps such names in on a system.
|
|
106
|
+
*
|
|
107
|
+
* On macOS that is the composed form (NFC), which is the form git reports names in there. The file system lists a
|
|
108
|
+
* name in the form it was written in — decomposed (NFD) when Finder or a Cocoa application wrote it — and takes either
|
|
109
|
+
* form for the same file; git, with `core.precomposeunicode` (which `git init` and `git clone` set on a Mac), composes
|
|
110
|
+
* every name it lists and every name it is given. A document found by the file walk and the same document in the
|
|
111
|
+
* history then had two names, and what joined them by name — the ledger's text and versions of a file, the ignore
|
|
112
|
+
* rules, a material rule, a removal — missed. So a name is composed where it enters from outside git: a directory
|
|
113
|
+
* listing, a change the watcher reports, a path a person or the model wrote (which may be copied from a listing), and
|
|
114
|
+
* every path made relative to a location. This is what git itself does at the same two places. What git says is kept
|
|
115
|
+
* as git says it, so what is said back to git is in its own words. (A repository whose history holds decomposed
|
|
116
|
+
* names — made by a git without that setting — is not covered: there git and the walk still differ.)
|
|
117
|
+
*
|
|
118
|
+
* Elsewhere a name is the characters it was written with, and stays so.
|
|
119
|
+
*/
|
|
120
|
+
export function nameForm(text: string, platform: NodeJS.Platform = process.platform): string {
|
|
121
|
+
return platform === 'darwin' ? text.normalize('NFC') : text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The path of `path` below `root` (or from it, with `..`), written with `/` and in the form names are kept in (`nameForm`). */
|
|
125
|
+
export function relativeDisplay(root: string, path: string): string {
|
|
126
|
+
const from = normalizePath(root);
|
|
127
|
+
const to = normalizePath(path);
|
|
128
|
+
// On macOS `relative` compares names exactly while `isWithin` folds them, as the file system does: a path under the
|
|
129
|
+
// root in another case or Unicode form would come back as `../../…`. (On Windows `relative` folds case itself.)
|
|
130
|
+
if (MAC) {
|
|
131
|
+
const under = partUnder(from, to, sep, foldForSystem);
|
|
132
|
+
if (under !== null) return nameForm(under);
|
|
133
|
+
}
|
|
134
|
+
return nameForm(relative(from, to).split(sep).join('/'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The part of `path` below `root`, its names as `path` spells them and joined with `/` — `''` when they are the same
|
|
139
|
+
* place, null when `path` is not under `root`. Names are compared through `fold`, one by one.
|
|
140
|
+
*/
|
|
141
|
+
export function partUnder(root: string, path: string, separator: string, fold: (name: string) => string): string | null {
|
|
142
|
+
const above = root.split(separator);
|
|
143
|
+
while (above.length > 1 && above[above.length - 1] === '') above.pop(); // a root directory ends in its separator
|
|
144
|
+
const names = path.split(separator);
|
|
145
|
+
if (names.length < above.length) return null;
|
|
146
|
+
for (let i = 0; i < above.length; i += 1) if (fold(above[i]!) !== fold(names[i]!)) return null;
|
|
147
|
+
return names.slice(above.length).filter((name) => name !== '').join('/');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `path` ends with the names `tail` gives — a repository named by the last part of its path, say — whichever
|
|
152
|
+
* separator either is written with, and whatever the case. (A path of this system has the system's separator: looking
|
|
153
|
+
* for `/name` at the end of it as text finds nothing on Windows.)
|
|
154
|
+
*/
|
|
155
|
+
export function endsWithPath(path: string, tail: string): boolean {
|
|
156
|
+
const names = (p: string) => p.split(/[\\/]+/).filter((name) => name !== '').map((name) => name.toLowerCase());
|
|
157
|
+
const all = names(path);
|
|
158
|
+
const last = names(tail);
|
|
159
|
+
return last.length > 0 && last.length <= all.length && last.every((name, i) => name === all[all.length - last.length + i]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where a file lies among these directories: the innermost one that holds it, as a key, and the file's path below it
|
|
164
|
+
* written with `/` — or, when none holds it, no directory and the file's own key.
|
|
165
|
+
*/
|
|
166
|
+
export function placeUnder(roots: readonly string[], file: string): { repo: string | null; path: string } {
|
|
167
|
+
const root = roots.filter((r) => isWithin(r, file) && !samePath(r, file)).sort((a, b) => pathKey(b).length - pathKey(a).length)[0];
|
|
168
|
+
return root !== undefined ? { repo: pathKey(root), path: relativeDisplay(root, file) } : { repo: null, path: pathKey(file) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A path as its owner typed it into the workbench: `~` and `~/…` are the home directory. A shell makes that of them
|
|
173
|
+
* before a program sees the path; a field of a web page is no shell, and the path would be taken for a directory named
|
|
174
|
+
* `~` under wherever ProjectKeeper was started. `~name` (another account's home) is left as it is.
|
|
175
|
+
*/
|
|
176
|
+
export function expandHome(path: string): string {
|
|
177
|
+
if (path === '~') return homedir();
|
|
178
|
+
if (path.startsWith('~/') || (WIN && path.startsWith('~\\'))) return join(homedir(), path.slice(2));
|
|
179
|
+
return path;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Claude Code stores a project's sessions under `~/.claude/projects/<encoded cwd>/`, where
|
|
184
|
+
* every character outside `[A-Za-z0-9-]` becomes `-`. Observed on Windows:
|
|
185
|
+
* `D:\orchard` → `D--orchard`, `D:\my_app` → `D--my-app`,
|
|
186
|
+
* `D:\orchard\.worktrees\w9` → `D--orchard--worktrees-w9`.
|
|
187
|
+
*/
|
|
188
|
+
export function claudeProjectDirName(cwd: string): string {
|
|
189
|
+
return normalizePath(cwd).replace(/[^A-Za-z0-9-]/g, '-');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ───────────────────────── Windows: how long a project's own path can be ─────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// Files deep inside a project are no trouble: Node opens them at any length, and every git call is given
|
|
195
|
+
// `core.longpaths` (util/git.ts `LONG_PATHS`). The length of the project's own path is another matter, and no setting
|
|
196
|
+
// helps there, neither git's nor Windows' own for long paths. Both limits were measured (Git for Windows 2.53, pi
|
|
197
|
+
// 0.87.1, Windows 11 with long paths enabled).
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The longest path of a repository Git for Windows can open. Before it reads any setting it looks for
|
|
201
|
+
* `<repository>\.git\objects`, which has to stay under 260 characters: one character more and it answers "not a git
|
|
202
|
+
* repository"; from 259 on it cannot enter the directory at all.
|
|
203
|
+
*/
|
|
204
|
+
export const GIT_ROOT_MAX = 246;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The longest path of a directory the Keeper can work in. pi keeps a job's session in a folder named after the working
|
|
208
|
+
* directory, with two dashes before and after, and a folder's name is at most 255 characters. A few characters
|
|
209
|
+
* further, at 259, Windows starts no process in the directory, so no shell command could run there either.
|
|
210
|
+
*/
|
|
211
|
+
export const KEEPER_CWD_MAX = 251;
|
|
212
|
+
|
|
213
|
+
/** What to say of a repository at `dir` that git cannot open because its path is too long; null when that is not the case. */
|
|
214
|
+
export function gitPathLimit(dir: string): string | null {
|
|
215
|
+
const path = normalizePath(dir);
|
|
216
|
+
if (!WIN || path.length <= GIT_ROOT_MAX || !existsSync(join(path, '.git'))) return null;
|
|
217
|
+
return `This directory holds a git repository, but its path is ${path.length} characters long, and git on Windows cannot open a repository whose path is longer than ${GIT_ROOT_MAX} characters: its history, worktrees and ignore rules are not read. Move the project to a shorter path.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What to say of a directory the Keeper cannot work in because its path is too long; null when it can. */
|
|
221
|
+
export function keeperPathLimit(dir: string): string | null {
|
|
222
|
+
const path = normalizePath(dir);
|
|
223
|
+
if (!WIN || path.length <= KEEPER_CWD_MAX) return null;
|
|
224
|
+
return `The Keeper cannot work in this project's directory: its path is ${path.length} characters long, and on Windows the Keeper can only work in a directory whose path is at most ${KEEPER_CWD_MAX} characters. Move the project to a shorter path.`;
|
|
225
|
+
}
|