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,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A narrow undo journal for Keeper's shell. The command parser supplies the starting directory, every explicit path
|
|
3
|
+
* it could resolve, and the places the command says it writes (command.ts `planShellCommand`). We inspect only the
|
|
4
|
+
* corresponding project roots: Git repositories by porcelain status, ordinary directories by size and mtime. This is a
|
|
5
|
+
* guardrail for ordinary commands, not an OS sandbox for detached writers.
|
|
6
|
+
*
|
|
7
|
+
* What it undoes depends on whether anyone else writes in the project while a command runs (BQ):
|
|
8
|
+
* - A controlled trial (a home that does not watch its projects): nobody else does, so every change to the working
|
|
9
|
+
* tree that Git status or the directory scan shows is undone.
|
|
10
|
+
* - A live project (the home watches its projects): the owner and agents write at the same time, so only what the
|
|
11
|
+
* command itself names as its writes is undone — its redirection and output targets and the operands of the
|
|
12
|
+
* commands that write theirs. Anything else that changed while it ran is not its doing: it is left as it is and
|
|
13
|
+
* reported (`left`).
|
|
14
|
+
* Git's own state — the index, HEAD, refs, anything in a .git directory or file — is never backed up for restore and
|
|
15
|
+
* never restored, on either: other people's git operations and the Keeper's own folder commits change it legitimately.
|
|
16
|
+
* So what a commit made while the command ran is never undone either; it is reported. Changes are judged by content:
|
|
17
|
+
* a path Git status showed before the command is compared with what was saved of it then, any other path with what HEAD
|
|
18
|
+
* held then. The guard's git calls take no lock and never write the index.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { createReadStream, type Stats } from 'node:fs';
|
|
23
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readlink, readdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { constants } from 'node:fs';
|
|
25
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
26
|
+
import type { Project } from '../../model/types.ts';
|
|
27
|
+
import { LONG_PATHS } from '../../util/git.ts';
|
|
28
|
+
import { canonicalKey } from './paths.ts';
|
|
29
|
+
|
|
30
|
+
/** How a shell step's result starts when something changed in the project while its command ran, not by it (BQ). */
|
|
31
|
+
export const LEFT_NOTE = 'Changed while this command ran, not by it';
|
|
32
|
+
|
|
33
|
+
export interface ShellGuardOptions {
|
|
34
|
+
/** Others write in the project while a command runs (a live project): only `writes` are undone. Default: nobody does. */
|
|
35
|
+
readonly othersWrite?: boolean;
|
|
36
|
+
/** The places the command says it writes (planShellCommand `writePaths`), as canonical keys; a directory covers what is under it. */
|
|
37
|
+
readonly writes?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Kind = 'file' | 'directory' | 'symlink' | 'other';
|
|
41
|
+
type Entry = {
|
|
42
|
+
path: string;
|
|
43
|
+
kind: Kind;
|
|
44
|
+
mode: number;
|
|
45
|
+
size: number;
|
|
46
|
+
mtimeMs: number;
|
|
47
|
+
mtime: Date;
|
|
48
|
+
atime: Date;
|
|
49
|
+
backup?: string;
|
|
50
|
+
digest?: string;
|
|
51
|
+
target?: string;
|
|
52
|
+
};
|
|
53
|
+
/** A path as a commit holds it: its mode and object (a blob, a link's target, a submodule's commit). */
|
|
54
|
+
type TreeEntry = { mode: string; object: string };
|
|
55
|
+
type GitScope = {
|
|
56
|
+
root: string;
|
|
57
|
+
pathspec: string | null;
|
|
58
|
+
checkPaths: Set<string>;
|
|
59
|
+
before: Map<string, string>;
|
|
60
|
+
/** Each path status showed before the command, as it was then: saved in full where the guard may restore it. */
|
|
61
|
+
dirty: Map<string, Entry | null>;
|
|
62
|
+
/** HEAD when the command started (null before the first commit): what the working tree held at every other path. */
|
|
63
|
+
head: string | null;
|
|
64
|
+
};
|
|
65
|
+
type PlainScope = { root: string; before: Map<string, Entry>; excluded: readonly string[] };
|
|
66
|
+
|
|
67
|
+
const WIN = process.platform === 'win32';
|
|
68
|
+
const within = (path: string, root: string): boolean => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
69
|
+
const missing = (error: unknown): boolean => (error as NodeJS.ErrnoException).code === 'ENOENT';
|
|
70
|
+
const depth = (path: string): number => path.split(/[\\/]/).length;
|
|
71
|
+
/** A path in Git's own directory or file (`.git`), the project's or a nested repository's: never restored. */
|
|
72
|
+
const gitInternal = (path: string): boolean => path.split(/[\\/]/).includes('.git');
|
|
73
|
+
|
|
74
|
+
async function statIfPresent(path: string) {
|
|
75
|
+
try { return await lstat(path); } catch (error) { if (missing(error)) return null; throw error; }
|
|
76
|
+
}
|
|
77
|
+
const kindOf = (st: Stats): Kind => st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'directory' : st.isFile() ? 'file' : 'other';
|
|
78
|
+
async function hashFile(path: string): Promise<string> {
|
|
79
|
+
const hash = createHash('sha256');
|
|
80
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
81
|
+
return hash.digest('hex');
|
|
82
|
+
}
|
|
83
|
+
async function git(cwd: string, args: string[], input?: Buffer, allowOne = false): Promise<{ code: number; output: Buffer; error: string }> {
|
|
84
|
+
return new Promise((ok, fail) => {
|
|
85
|
+
const env = { ...process.env };
|
|
86
|
+
for (const name of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']) delete env[name];
|
|
87
|
+
const child = spawn('git', ['--no-optional-locks', ...LONG_PATHS, '-C', cwd, ...args], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
88
|
+
const out: Buffer[] = [];
|
|
89
|
+
const err: Buffer[] = [];
|
|
90
|
+
child.stdout.on('data', (part: Buffer) => out.push(part));
|
|
91
|
+
child.stderr.on('data', (part: Buffer) => err.push(part));
|
|
92
|
+
child.on('error', fail);
|
|
93
|
+
child.on('close', (code) => {
|
|
94
|
+
const error = Buffer.concat(err).toString('utf8');
|
|
95
|
+
if (code !== 0 && !(allowOne && code === 1)) fail(new Error(`git ${args[0]} failed in ${cwd}: ${error.trim()}`));
|
|
96
|
+
else ok({ code: code ?? -1, output: Buffer.concat(out), error });
|
|
97
|
+
});
|
|
98
|
+
child.stdin.end(input);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function nearestDirectory(path: string): Promise<string> {
|
|
102
|
+
let at = path;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const st = await statIfPresent(at);
|
|
105
|
+
if (st?.isDirectory()) return at;
|
|
106
|
+
const parent = dirname(at);
|
|
107
|
+
if (parent === at) throw new Error(`Cannot locate a directory for shell path ${path}`);
|
|
108
|
+
at = parent;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function gitRoot(path: string): Promise<string | null> {
|
|
112
|
+
const dir = await nearestDirectory(path);
|
|
113
|
+
const result = await git(dir, ['rev-parse', '--show-toplevel'], undefined, true).catch((error: Error) => {
|
|
114
|
+
if (/not a git repository/i.test(error.message)) return null;
|
|
115
|
+
throw error;
|
|
116
|
+
});
|
|
117
|
+
if (!result) return null;
|
|
118
|
+
if (result.code === 1 || result.code === 128) return null;
|
|
119
|
+
return canonicalKey(result.output.toString('utf8').trim(), dir);
|
|
120
|
+
}
|
|
121
|
+
/** The commit HEAD names (null before the first commit). Read only: no lock, nothing written. */
|
|
122
|
+
async function headOf(root: string): Promise<string | null> {
|
|
123
|
+
const result = await git(root, ['rev-parse', '--verify', '-q', 'HEAD'], undefined, true);
|
|
124
|
+
return result.code === 0 ? result.output.toString('utf8').trim() || null : null;
|
|
125
|
+
}
|
|
126
|
+
function parseStatus(output: Buffer): Map<string, string> {
|
|
127
|
+
const parts = output.toString('utf8').split('\0');
|
|
128
|
+
const status = new Map<string, string>();
|
|
129
|
+
for (let i = 0; i < parts.length && parts[i]; i += 1) {
|
|
130
|
+
const row = parts[i]!;
|
|
131
|
+
if (row.length < 4 || row[2] !== ' ') throw new Error('Cannot parse Git porcelain status; refusing shell command');
|
|
132
|
+
const code = row.slice(0, 2);
|
|
133
|
+
status.set(row.slice(3), code);
|
|
134
|
+
if (/[RC]/.test(code)) {
|
|
135
|
+
const source = parts[++i];
|
|
136
|
+
if (!source) throw new Error('Cannot parse Git rename status; refusing shell command');
|
|
137
|
+
status.set(source, `source:${code}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return status;
|
|
141
|
+
}
|
|
142
|
+
async function status(scope: Pick<GitScope, 'root' | 'pathspec'>): Promise<Map<string, string>> {
|
|
143
|
+
const args = ['status', '--porcelain=v1', '-z', '--untracked-files=all'];
|
|
144
|
+
if (scope.pathspec) args.push('--', scope.pathspec);
|
|
145
|
+
return parseStatus((await git(scope.root, args)).output);
|
|
146
|
+
}
|
|
147
|
+
/** Split paths for command lines Windows accepts (about 32,000 characters). */
|
|
148
|
+
function chunks(paths: readonly string[], limit = 20_000): string[][] {
|
|
149
|
+
const out: string[][] = [];
|
|
150
|
+
let current: string[] = [];
|
|
151
|
+
let length = 0;
|
|
152
|
+
for (const path of paths) {
|
|
153
|
+
if (current.length && length + path.length + 12 > limit) { out.push(current); current = []; length = 0; }
|
|
154
|
+
current.push(path);
|
|
155
|
+
length += path.length + 12;
|
|
156
|
+
}
|
|
157
|
+
if (current.length) out.push(current);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** What `head` held at each of these repository paths (null where it held nothing), read from Git's objects. */
|
|
161
|
+
async function treeEntries(root: string, head: string | null, paths: readonly string[]): Promise<Map<string, TreeEntry | null>> {
|
|
162
|
+
const found = new Map<string, TreeEntry | null>(paths.map((path) => [path, null]));
|
|
163
|
+
if (!head) return found;
|
|
164
|
+
for (const part of chunks(paths)) {
|
|
165
|
+
const out = (await git(root, ['ls-tree', '-z', head, '--', ...part.map((path) => `:(literal)${path}`)])).output.toString('utf8');
|
|
166
|
+
for (const row of out.split('\0')) {
|
|
167
|
+
const m = /^(\d{6}) \w+ ([0-9a-f]+)\t([\s\S]*)$/.exec(row);
|
|
168
|
+
if (m && found.has(m[3]!)) found.set(m[3]!, { mode: m[1]!, object: m[2]! });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
/** The blob Git would make of each of these files as they are now (its clean filters applied); nothing is written. A
|
|
174
|
+
* file that cannot be hashed (it went away meanwhile) is left out, and so counts as changed. */
|
|
175
|
+
async function worktreeBlobs(root: string, paths: readonly string[]): Promise<Map<string, string>> {
|
|
176
|
+
const out = new Map<string, string>();
|
|
177
|
+
const batch = paths.filter((path) => !/[\r\n]/.test(path));
|
|
178
|
+
const one = async (path: string) => {
|
|
179
|
+
try {
|
|
180
|
+
const content = await readFile(resolve(root, path));
|
|
181
|
+
out.set(path, (await git(root, ['hash-object', '--stdin', `--path=${path}`], content)).output.toString('utf8').trim());
|
|
182
|
+
} catch { /* gone or unreadable: counts as changed */ }
|
|
183
|
+
};
|
|
184
|
+
if (batch.length) {
|
|
185
|
+
try {
|
|
186
|
+
const hashes = (await git(root, ['hash-object', '--stdin-paths'], Buffer.from(`${batch.join('\n')}\n`))).output.toString('utf8').trim().split(/\r?\n/);
|
|
187
|
+
if (hashes.length !== batch.length) throw new Error('hash-object answered a different number of paths');
|
|
188
|
+
batch.forEach((path, i) => out.set(path, hashes[i]!));
|
|
189
|
+
} catch { for (const path of batch) await one(path); }
|
|
190
|
+
}
|
|
191
|
+
for (const path of paths.filter((item) => /[\r\n]/.test(item))) await one(path);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** Of these repository paths, those that are regular files now and were files or links in `head`: the ones to hash. */
|
|
195
|
+
async function filesToHash(root: string, paths: readonly string[], was: ReadonlyMap<string, TreeEntry | null>): Promise<string[]> {
|
|
196
|
+
const out: string[] = [];
|
|
197
|
+
for (const path of paths) {
|
|
198
|
+
const entry = was.get(path);
|
|
199
|
+
if (!entry || entry.mode === '160000') continue;
|
|
200
|
+
if ((await statIfPresent(resolve(root, path)))?.isFile()) out.push(path);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** The paths a change of HEAD while the command ran changed (a commit, a reset, a switch), within the scope. */
|
|
205
|
+
async function movedPaths(scope: GitScope, from: string | null, to: string | null): Promise<string[]> {
|
|
206
|
+
if (from === to || !to) return [];
|
|
207
|
+
const spec = scope.pathspec ? ['--', scope.pathspec] : [];
|
|
208
|
+
const out = from
|
|
209
|
+
? await git(scope.root, ['diff', '--name-only', '-z', '--no-renames', from, to, ...spec])
|
|
210
|
+
: await git(scope.root, ['ls-tree', '-r', '--name-only', '-z', to, ...spec]);
|
|
211
|
+
return out.output.toString('utf8').split('\0').filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
/** What a path is now; with `backup`, a copy to restore it from (and its digest), otherwise only what tells a change. */
|
|
214
|
+
async function capture(path: string, backup: string | null, digest: boolean): Promise<Entry | null> {
|
|
215
|
+
const st = await statIfPresent(path);
|
|
216
|
+
if (!st) return null;
|
|
217
|
+
const kind = kindOf(st);
|
|
218
|
+
if (kind === 'other' && backup) throw new Error(`Unsupported filesystem entry in shell guard: ${path}`);
|
|
219
|
+
const entry: Entry = { path, kind, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime, atime: st.atime };
|
|
220
|
+
if (kind === 'symlink') entry.target = await readlink(path);
|
|
221
|
+
if (kind === 'file' && backup) {
|
|
222
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
223
|
+
await copyFile(path, backup, constants.COPYFILE_FICLONE);
|
|
224
|
+
entry.backup = backup;
|
|
225
|
+
if (digest) entry.digest = await hashFile(backup);
|
|
226
|
+
}
|
|
227
|
+
return entry;
|
|
228
|
+
}
|
|
229
|
+
async function same(entry: Entry | null, hash: boolean): Promise<boolean> {
|
|
230
|
+
if (!entry) return false;
|
|
231
|
+
const now = await statIfPresent(entry.path);
|
|
232
|
+
if (!now) return false;
|
|
233
|
+
const kind = kindOf(now);
|
|
234
|
+
if (kind !== entry.kind || now.mode !== entry.mode || now.size !== entry.size || now.mtimeMs !== entry.mtimeMs) return false;
|
|
235
|
+
if (kind === 'symlink') return (await readlink(entry.path)) === entry.target;
|
|
236
|
+
return !hash || kind !== 'file' || (await hashFile(entry.path)) === entry.digest;
|
|
237
|
+
}
|
|
238
|
+
/** The path holds what `entry` recorded, by content rather than timestamps: how a restore is verified. */
|
|
239
|
+
async function holds(entry: Entry | null, path: string): Promise<boolean> {
|
|
240
|
+
const now = await statIfPresent(path);
|
|
241
|
+
if (!entry || !now) return !entry && !now;
|
|
242
|
+
if (kindOf(now) !== entry.kind) return false;
|
|
243
|
+
if (entry.kind === 'symlink') return (await readlink(path)) === entry.target;
|
|
244
|
+
return entry.kind !== 'file' || entry.digest === undefined || (await hashFile(path)) === entry.digest;
|
|
245
|
+
}
|
|
246
|
+
/** Whether a path holds now what `was` says a commit held there (null: nothing), as Git compares a file with its blob. */
|
|
247
|
+
async function holdsTree(root: string, path: string, was: TreeEntry | null, blob: string | undefined): Promise<boolean> {
|
|
248
|
+
const now = await statIfPresent(resolve(root, path));
|
|
249
|
+
if (!was || !now) return !was && !now;
|
|
250
|
+
if (was.mode === '160000') return false; // a submodule status shows is taken as changed
|
|
251
|
+
if (now.isSymbolicLink()) return was.mode === '120000' && (await readlink(resolve(root, path))) === (await git(root, ['cat-file', 'blob', was.object])).output.toString('utf8');
|
|
252
|
+
if (!now.isFile()) return false;
|
|
253
|
+
if (!WIN && was.mode !== '120000' && ((now.mode & 0o111) !== 0) !== (was.mode === '100755')) return false;
|
|
254
|
+
return blob === was.object;
|
|
255
|
+
}
|
|
256
|
+
async function restoreEntry(path: string, entry: Entry | null): Promise<void> {
|
|
257
|
+
if (!entry) { await rm(path, { recursive: true, force: true }); return; }
|
|
258
|
+
if (entry.kind === 'file' && !entry.backup) throw new Error(`No copy was kept to restore ${path}`);
|
|
259
|
+
await rm(path, { recursive: true, force: true });
|
|
260
|
+
await mkdir(dirname(path), { recursive: true });
|
|
261
|
+
if (entry.kind === 'directory') await mkdir(path, { recursive: true });
|
|
262
|
+
else if (entry.kind === 'symlink') await symlink(entry.target!, path);
|
|
263
|
+
else await copyFile(entry.backup!, path);
|
|
264
|
+
if (entry.kind !== 'symlink') {
|
|
265
|
+
await chmod(path, entry.mode);
|
|
266
|
+
await utimes(path, entry.atime, entry.mtime);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Put back what a commit held at a path, as a checkout writes it (its smudge filters applied), or remove what it did not hold. */
|
|
270
|
+
async function restoreTree(root: string, path: string, was: TreeEntry | null): Promise<void> {
|
|
271
|
+
const absolute = resolve(root, path);
|
|
272
|
+
if (!was) { await rm(absolute, { recursive: true, force: true }); return; }
|
|
273
|
+
if (was.mode === '160000') throw new Error(`A Git submodule changed and cannot be restored safely: ${absolute}`);
|
|
274
|
+
const link = was.mode === '120000';
|
|
275
|
+
const content = (await git(root, link ? ['cat-file', 'blob', was.object] : ['cat-file', '--filters', `--path=${path}`, was.object])).output;
|
|
276
|
+
await rm(absolute, { recursive: true, force: true });
|
|
277
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
278
|
+
if (link && !WIN) { await symlink(content.toString('utf8'), absolute); return; }
|
|
279
|
+
await writeFile(absolute, content);
|
|
280
|
+
if (!WIN) await chmod(absolute, was.mode === '100755' ? 0o755 : 0o644);
|
|
281
|
+
}
|
|
282
|
+
/** A directory that holds a repository of its own (`.git` inside): Git's, never removed. */
|
|
283
|
+
async function nestedRepository(path: string): Promise<boolean> {
|
|
284
|
+
return (await statIfPresent(path))?.isDirectory() === true && (await statIfPresent(join(path, '.git'))) !== null;
|
|
285
|
+
}
|
|
286
|
+
async function scanPlain(root: string, backupDir: string | null, excluded: readonly string[], keep?: (path: string) => boolean): Promise<Map<string, Entry>> {
|
|
287
|
+
const found = new Map<string, Entry>();
|
|
288
|
+
const queue = [root];
|
|
289
|
+
while (queue.length) {
|
|
290
|
+
const path = queue.pop()!;
|
|
291
|
+
if (excluded.some((item) => within(path, item)) || path.split(sep).includes('.git')) continue;
|
|
292
|
+
const st = await statIfPresent(path);
|
|
293
|
+
if (!st) continue;
|
|
294
|
+
const kind = kindOf(st);
|
|
295
|
+
// A socket or a named pipe (a server's socket in `tmp/`, an editor's) is noted as being there and nothing more: it
|
|
296
|
+
// holds no content to save, and its times move with use. Refusing the directory for holding one stopped every
|
|
297
|
+
// command in such a project.
|
|
298
|
+
const entry: Entry = { path, kind, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime, atime: st.atime };
|
|
299
|
+
if (entry.kind === 'symlink') entry.target = await readlink(path);
|
|
300
|
+
if (entry.kind === 'file' && backupDir && (!keep || keep(path))) {
|
|
301
|
+
const backup = join(backupDir, relative(root, path) || '_root');
|
|
302
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
303
|
+
await copyFile(path, backup, constants.COPYFILE_FICLONE);
|
|
304
|
+
entry.backup = backup;
|
|
305
|
+
}
|
|
306
|
+
found.set(path, entry);
|
|
307
|
+
if (entry.kind === 'directory') for (const child of await readdir(path)) queue.push(join(path, child));
|
|
308
|
+
}
|
|
309
|
+
return found;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Project locations and separately scoped repositories/worktrees. Nested roots stay separate so
|
|
313
|
+
* an explicit path into an inner worktree selects that repository rather than its parent. */
|
|
314
|
+
export function shellProjectRoots(project: Pick<Project, 'locations' | 'scope'>): string[] {
|
|
315
|
+
const candidates = [
|
|
316
|
+
...project.locations,
|
|
317
|
+
...project.scope.filter((item) => !item.missing && item.relation !== 'Excluded'
|
|
318
|
+
&& (item.category === 'Repository' || item.category === 'Worktree' || item.category === 'Directory'))
|
|
319
|
+
.map((item) => item.path),
|
|
320
|
+
].map((path) => canonicalKey(path, path));
|
|
321
|
+
return [...new Set(candidates)];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Several pi calls and jobs can share a worktree. Serialize their shell checks within this process.
|
|
325
|
+
let previousShell: Promise<void> = Promise.resolve();
|
|
326
|
+
export async function withShellWriteLock<T>(run: () => Promise<T>): Promise<T> {
|
|
327
|
+
const before = previousShell;
|
|
328
|
+
let release!: () => void;
|
|
329
|
+
previousShell = new Promise<void>((done) => { release = done; });
|
|
330
|
+
await before;
|
|
331
|
+
try { return await run(); } finally { release(); }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export class ShellProjectSnapshot {
|
|
335
|
+
private readonly gitScopes: GitScope[] = [];
|
|
336
|
+
private readonly plainScopes: PlainScope[] = [];
|
|
337
|
+
private readonly backupDir: string;
|
|
338
|
+
private readonly othersWrite: boolean;
|
|
339
|
+
private readonly writes: readonly string[];
|
|
340
|
+
private leftPaths: string[] = [];
|
|
341
|
+
private constructor(backupDir: string, options: ShellGuardOptions) {
|
|
342
|
+
this.backupDir = backupDir;
|
|
343
|
+
this.othersWrite = options.othersWrite === true;
|
|
344
|
+
this.writes = options.writes ?? [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Whether the guard may undo a change here: on a controlled trial anything but Git's own; on a live project only
|
|
348
|
+
* what the command names as its writes. `path` lies under a canonical root, so folding its case gives its key. */
|
|
349
|
+
private restorable(path: string): boolean {
|
|
350
|
+
if (gitInternal(path)) return false;
|
|
351
|
+
if (!this.othersWrite) return true;
|
|
352
|
+
const key = WIN ? path.toLowerCase() : path;
|
|
353
|
+
return this.writes.some((write) => within(key, write));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
static async take(roots: readonly string[], guardHome: string, locations: readonly string[] = roots, excludedRoots: readonly string[] = [], options: ShellGuardOptions = {}): Promise<ShellProjectSnapshot> {
|
|
357
|
+
const projectRoots = roots.map((path) => canonicalKey(path, path));
|
|
358
|
+
const excluded = excludedRoots.map((path) => canonicalKey(path, path));
|
|
359
|
+
const backupKey = canonicalKey(guardHome, guardHome);
|
|
360
|
+
if (projectRoots.some((root) => within(backupKey, root))) throw new Error('Shell guard backup directory is inside the project; refusing command');
|
|
361
|
+
await mkdir(guardHome, { recursive: true });
|
|
362
|
+
const backupDir = await mkdtemp(join(guardHome, 'shell-'));
|
|
363
|
+
const snapshot = new ShellProjectSnapshot(backupDir, options);
|
|
364
|
+
try {
|
|
365
|
+
const candidates = new Map<string, { scope: string; target: string; path: string }>();
|
|
366
|
+
for (const path of locations) {
|
|
367
|
+
const key = canonicalKey(path, path);
|
|
368
|
+
const containing = projectRoots.filter((root) => within(key, root)).sort((a, b) => b.length - a.length);
|
|
369
|
+
const scope = containing[0];
|
|
370
|
+
if (!scope || excluded.some((item) => within(key, item))) continue;
|
|
371
|
+
const target = await nearestDirectory(key);
|
|
372
|
+
candidates.set(key, { scope, target, path: key });
|
|
373
|
+
}
|
|
374
|
+
const gitByKey = new Map<string, GitScope>();
|
|
375
|
+
const plainRoots: string[] = [];
|
|
376
|
+
for (const { scope, target, path } of candidates.values()) {
|
|
377
|
+
const repo = await gitRoot(target);
|
|
378
|
+
if (!repo || (!within(scope, repo) && !within(repo, scope))) { plainRoots.push(target); continue; }
|
|
379
|
+
const pathspec = projectRoots.some((root) => within(repo, root)) ? null : relative(repo, scope).replaceAll('\\', '/');
|
|
380
|
+
const key = `${repo}\0${pathspec ?? ''}`;
|
|
381
|
+
let gitScope = gitByKey.get(key);
|
|
382
|
+
if (!gitScope) {
|
|
383
|
+
gitScope = { root: repo, pathspec, checkPaths: new Set(), before: new Map(), dirty: new Map(), head: null };
|
|
384
|
+
gitByKey.set(key, gitScope);
|
|
385
|
+
snapshot.gitScopes.push(gitScope);
|
|
386
|
+
}
|
|
387
|
+
gitScope.checkPaths.add(relative(repo, path).replaceAll('\\', '/') || '.');
|
|
388
|
+
}
|
|
389
|
+
// Any explicit path into an ignored tree is rejected before the shell starts. The initial
|
|
390
|
+
// working directory itself is checked too; ordinary reads of tracked paths remain cheap.
|
|
391
|
+
for (const scope of snapshot.gitScopes) {
|
|
392
|
+
const paths = [...scope.checkPaths];
|
|
393
|
+
if (paths.length) {
|
|
394
|
+
const result = await git(scope.root, ['check-ignore', '-z', '--stdin'], Buffer.from(paths.join('\0') + '\0'), true);
|
|
395
|
+
if (result.output.length) throw new Error(`Refused: shell path is Git-ignored (${result.output.toString('utf8').split('\0').filter(Boolean).join(', ')}); it cannot be checked by Git status.`);
|
|
396
|
+
}
|
|
397
|
+
[scope.before, scope.head] = await Promise.all([status(scope), headOf(scope.root)]);
|
|
398
|
+
const label = String(snapshot.gitScopes.indexOf(scope));
|
|
399
|
+
for (const path of scope.before.keys()) {
|
|
400
|
+
const absolute = resolve(scope.root, path);
|
|
401
|
+
if (!within(absolute, scope.root)) throw new Error('Git status returned a path outside the repository');
|
|
402
|
+
// Saved in full only where the guard may restore it; elsewhere what tells a change is enough (a live project).
|
|
403
|
+
const restorable = snapshot.restorable(absolute);
|
|
404
|
+
const prior = await capture(absolute, restorable ? join(backupDir, 'git', label, path) : null, restorable);
|
|
405
|
+
if (restorable && prior?.kind === 'directory') throw new Error(`Refused: a dirty Git directory cannot be restored safely: ${absolute}`);
|
|
406
|
+
scope.dirty.set(path, prior);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const narrowed = plainRoots.filter((root, i) => !plainRoots.some((other, j) => i !== j && within(root, other) && (root !== other || j < i)));
|
|
410
|
+
for (const [i, root] of narrowed.entries()) {
|
|
411
|
+
snapshot.plainScopes.push({ root, before: await scanPlain(root, join(backupDir, 'plain', String(i)), excluded, (path) => snapshot.restorable(path)), excluded });
|
|
412
|
+
}
|
|
413
|
+
return snapshot;
|
|
414
|
+
} catch (error) {
|
|
415
|
+
await rm(backupDir, { recursive: true, force: true });
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Undo what the guard may undo of the changes seen by the selected Git statuses and plain-directory scans; returns
|
|
421
|
+
* the paths restored. What changed but was not the command's doing is in `left` afterwards. */
|
|
422
|
+
async restore(): Promise<string[]> {
|
|
423
|
+
const changed: string[] = [];
|
|
424
|
+
const left: string[] = [];
|
|
425
|
+
try {
|
|
426
|
+
for (const scope of this.gitScopes) await this.restoreGit(scope, changed, left);
|
|
427
|
+
for (const scope of this.plainScopes) await this.restorePlain(scope, changed, left);
|
|
428
|
+
await rm(this.backupDir, { recursive: true, force: true });
|
|
429
|
+
this.leftPaths = [...new Set(left)];
|
|
430
|
+
return changed;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw new Error(`Shell project check could not restore the project. Recovery copy: ${this.backupDir}. ${String(error)}`, { cause: error });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** After `restore()`: the paths that changed while the command ran and were left as they are — on a live project what
|
|
437
|
+
* it did not name as its writes, on either what a commit or other change of HEAD made. */
|
|
438
|
+
get left(): readonly string[] { return this.leftPaths; }
|
|
439
|
+
|
|
440
|
+
private async restoreGit(scope: GitScope, changed: string[], left: string[]): Promise<void> {
|
|
441
|
+
const [after, head] = await Promise.all([status(scope), headOf(scope.root)]);
|
|
442
|
+
// Every path status shows before or after. One it showed before is compared with what was saved of it then; any
|
|
443
|
+
// other was clean then, so the working tree held what HEAD held — an index or HEAD moved by someone else's git
|
|
444
|
+
// command changes its status, not its content, and is no change to undo.
|
|
445
|
+
const shown = [...new Set([...scope.before.keys(), ...after.keys()])].filter((path) => !gitInternal(path));
|
|
446
|
+
const fresh = shown.filter((path) => !scope.before.has(path));
|
|
447
|
+
const was = await treeEntries(scope.root, scope.head, fresh);
|
|
448
|
+
const blobs = await worktreeBlobs(scope.root, await filesToHash(scope.root, fresh, was));
|
|
449
|
+
const restored: string[] = [];
|
|
450
|
+
for (const path of shown) {
|
|
451
|
+
const absolute = resolve(scope.root, path);
|
|
452
|
+
const original = scope.dirty.get(path) ?? null;
|
|
453
|
+
const unchanged = scope.before.has(path)
|
|
454
|
+
? original ? await same(original, original.digest !== undefined) : !(await statIfPresent(absolute))
|
|
455
|
+
: await holdsTree(scope.root, path, was.get(path) ?? null, blobs.get(path));
|
|
456
|
+
if (unchanged) continue;
|
|
457
|
+
if (!this.restorable(absolute) || await nestedRepository(absolute)) { left.push(absolute); continue; }
|
|
458
|
+
if (scope.before.has(path)) {
|
|
459
|
+
if ((await statIfPresent(absolute))?.isDirectory()) {
|
|
460
|
+
const stage = await git(scope.root, ['ls-files', '--stage', '--', path]);
|
|
461
|
+
if (stage.output.toString('utf8').startsWith('160000 ')) throw new Error(`A Git submodule changed and cannot be restored safely: ${absolute}`);
|
|
462
|
+
}
|
|
463
|
+
await restoreEntry(absolute, original);
|
|
464
|
+
} else await restoreTree(scope.root, path, was.get(path) ?? null);
|
|
465
|
+
restored.push(path);
|
|
466
|
+
changed.push(absolute);
|
|
467
|
+
}
|
|
468
|
+
if (restored.length) {
|
|
469
|
+
const again = await worktreeBlobs(scope.root, await filesToHash(scope.root, restored.filter((path) => !scope.before.has(path)), was));
|
|
470
|
+
for (const path of restored) {
|
|
471
|
+
const back = scope.before.has(path)
|
|
472
|
+
? await holds(scope.dirty.get(path) ?? null, resolve(scope.root, path))
|
|
473
|
+
: await holdsTree(scope.root, path, was.get(path) ?? null, again.get(path));
|
|
474
|
+
if (!back) throw new Error(`The working tree still differs after restoring ${resolve(scope.root, path)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// HEAD moved while the command ran (a commit, a reset): that is Git's, never undone. What it changed that status no
|
|
478
|
+
// longer shows is reported with the rest.
|
|
479
|
+
if (head !== scope.head) {
|
|
480
|
+
const seen = new Set(shown);
|
|
481
|
+
for (const path of await movedPaths(scope, scope.head, head)) if (!seen.has(path) && !gitInternal(path)) left.push(resolve(scope.root, path));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async restorePlain(scope: PlainScope, changed: string[], left: string[]): Promise<void> {
|
|
486
|
+
const after = await scanPlain(scope.root, null, scope.excluded);
|
|
487
|
+
for (const path of [...after.keys()].filter((path) => !scope.before.has(path)).sort((a, b) => depth(b) - depth(a))) {
|
|
488
|
+
if (!this.restorable(path)) { left.push(path); continue; }
|
|
489
|
+
await rm(path, { recursive: true, force: true });
|
|
490
|
+
changed.push(path);
|
|
491
|
+
}
|
|
492
|
+
for (const entry of [...scope.before.values()].sort((a, b) => depth(a.path) - depth(b.path))) {
|
|
493
|
+
if (entry.kind === 'directory') {
|
|
494
|
+
const now = await statIfPresent(entry.path);
|
|
495
|
+
if (now?.isDirectory()) {
|
|
496
|
+
if (now.mode === entry.mode) continue;
|
|
497
|
+
if (this.restorable(entry.path)) { await chmod(entry.path, entry.mode); changed.push(entry.path); } else left.push(entry.path);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (entry.kind === 'other') {
|
|
502
|
+
// Still a socket or a pipe: unchanged, whatever its times say. Gone or become something else: that cannot be
|
|
503
|
+
// put back, so it is reported and left.
|
|
504
|
+
const now = await statIfPresent(entry.path);
|
|
505
|
+
if (!now || kindOf(now) !== 'other') left.push(entry.path);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (await same(entry, false)) continue;
|
|
509
|
+
if (!this.restorable(entry.path)) { left.push(entry.path); continue; }
|
|
510
|
+
await restoreEntry(entry.path, entry);
|
|
511
|
+
changed.push(entry.path);
|
|
512
|
+
}
|
|
513
|
+
// A child insertion changes parent directory mtime. Restore directory timestamps last, where the guard may restore.
|
|
514
|
+
for (const entry of [...scope.before.values()].filter((item) => item.kind === 'directory' && this.restorable(item.path)).sort((a, b) => depth(b.path) - depth(a.path))) {
|
|
515
|
+
const now = await statIfPresent(entry.path);
|
|
516
|
+
if (now && now.mtimeMs !== entry.mtimeMs) await utimes(entry.path, entry.atime, entry.mtime);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
get recoveryPath(): string { return resolve(this.backupDir); }
|
|
521
|
+
}
|