pi-squire 0.9.5 → 0.10.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 +85 -1
- package/README.md +305 -16
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +415 -65
- package/src/render.ts +106 -1
- package/src/result-sessions.ts +47 -0
- package/src/result-types.ts +36 -0
- package/src/results.ts +122 -77
- package/src/snapshot.ts +207 -0
- package/src/tmux.ts +30 -3
- package/src/tracking-attempt.ts +65 -0
- package/src/tracking.ts +78 -20
- package/src/workspace.ts +420 -0
- package/src/worktree-placement.ts +138 -0
- package/src/worktree.ts +474 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Read-only Git facts shared by the launch, the snapshot and the worktree
|
|
2
|
+
// lifecycle: the Git runner, the repository and checkout a directory belongs
|
|
3
|
+
// to, and the bounded path lists reports are made of. A leaf module: it
|
|
4
|
+
// imports nothing of this package.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
/** A bounded list of file names, as data; never shell text. */
|
|
12
|
+
export type PathSummary = { paths: string[]; total: number; truncated: boolean };
|
|
13
|
+
/** A checkout's repository, as canonical absolute paths. */
|
|
14
|
+
export type GitIdentity = { mainRoot: string; commonDir: string; checkoutRoot: string };
|
|
15
|
+
export type GitPosition = { branch: string; detached: false } | { branch: null; detached: true };
|
|
16
|
+
/** A launch's Git facts: a checkout, proved not a checkout, or not known. */
|
|
17
|
+
export type GitLaunch =
|
|
18
|
+
| { kind: "git"; identity: GitIdentity; position: GitPosition; head: string | null; baseline: string | null }
|
|
19
|
+
| { kind: "non-git" }
|
|
20
|
+
| { kind: "unknown"; reason: string };
|
|
21
|
+
|
|
22
|
+
/** `PathSummary` as a schema. */
|
|
23
|
+
export const PathSummarySchema = Type.Object({
|
|
24
|
+
paths: Type.Array(Type.String(), { description: "The first paths, whole; never more than 100 or 16 KiB." }),
|
|
25
|
+
total: Type.Number({ description: "Every path observed in this category." }),
|
|
26
|
+
truncated: Type.Boolean(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** How many paths, and how many bytes of them, a summary lists. */
|
|
30
|
+
const LIST_PATHS = 100;
|
|
31
|
+
const LIST_BYTES = 16 * 1024;
|
|
32
|
+
|
|
33
|
+
/** The first paths that fit both caps, whole, with the count of all of them. */
|
|
34
|
+
export function summarize(paths: string[]): PathSummary {
|
|
35
|
+
const shown: string[] = [];
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
const size = Buffer.byteLength(path, "utf8");
|
|
39
|
+
if (shown.length === LIST_PATHS || bytes + size > LIST_BYTES) break;
|
|
40
|
+
shown.push(path);
|
|
41
|
+
bytes += size;
|
|
42
|
+
}
|
|
43
|
+
return { paths: shown, total: paths.length, truncated: shown.length < paths.length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** NUL-terminated fields, as `-z` prints them. */
|
|
47
|
+
export const fields = (out: string): string[] => out.split("\0").slice(0, -1);
|
|
48
|
+
|
|
49
|
+
/** Unmerged index states, as `git status` prints them. */
|
|
50
|
+
const UNMERGED = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
|
51
|
+
|
|
52
|
+
/** `git status --porcelain=v1 -z` split into pending categories. */
|
|
53
|
+
export function pendingPaths(out: string): Record<"staged" | "unstaged" | "untracked" | "conflicted", string[]> {
|
|
54
|
+
const pending = {
|
|
55
|
+
staged: [] as string[],
|
|
56
|
+
unstaged: [] as string[],
|
|
57
|
+
untracked: [] as string[],
|
|
58
|
+
conflicted: [] as string[],
|
|
59
|
+
};
|
|
60
|
+
const entries = fields(out);
|
|
61
|
+
for (let i = 0; i < entries.length; i++) {
|
|
62
|
+
const entry = entries[i];
|
|
63
|
+
const xy = entry.slice(0, 2);
|
|
64
|
+
const path = entry.slice(3);
|
|
65
|
+
// A rename or copy is followed by its source path; `--no-renames` should prevent one, but never misread it as an entry.
|
|
66
|
+
if (/[RC]/.test(xy)) i++;
|
|
67
|
+
if (xy === "??") pending.untracked.push(path);
|
|
68
|
+
else if (UNMERGED.has(xy)) pending.conflicted.push(path);
|
|
69
|
+
else {
|
|
70
|
+
if (xy[0] !== " ") pending.staged.push(path);
|
|
71
|
+
if (xy[1] !== " ") pending.unstaged.push(path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return pending;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How long one Git question may take before the answer is unknown. */
|
|
78
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
79
|
+
|
|
80
|
+
/** A Git answer, with `bytes` the stdout and stderr it captured, so a caller can budget them together. */
|
|
81
|
+
export type GitAnswer =
|
|
82
|
+
| { ok: true; out: string; bytes: number }
|
|
83
|
+
| { ok: false; status: number | null; message: string; bytes: number };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Ask Git read-only in `cwd`; never throws. A call past `timeout` is killed
|
|
87
|
+
* with SIGKILL, which it cannot ignore, and one printing more than
|
|
88
|
+
* `maxBuffer` on either stream fails; each failure names its limit.
|
|
89
|
+
*/
|
|
90
|
+
export function askGit(
|
|
91
|
+
cwd: string,
|
|
92
|
+
args: string[],
|
|
93
|
+
limits: { timeout: number; maxBuffer?: number } = { timeout: GIT_TIMEOUT_MS },
|
|
94
|
+
): GitAnswer {
|
|
95
|
+
const env: NodeJS.ProcessEnv = { ...process.env, LC_ALL: "C", GIT_OPTIONAL_LOCKS: "0" };
|
|
96
|
+
// The parent's own repository variables would answer for the wrong directory.
|
|
97
|
+
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"]) delete env[name];
|
|
98
|
+
const run = spawnSync("git", args, {
|
|
99
|
+
cwd,
|
|
100
|
+
env,
|
|
101
|
+
timeout: limits.timeout,
|
|
102
|
+
killSignal: "SIGKILL",
|
|
103
|
+
...(limits.maxBuffer === undefined ? {} : { maxBuffer: limits.maxBuffer }),
|
|
104
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
105
|
+
});
|
|
106
|
+
const bytes = (run.stdout?.length ?? 0) + (run.stderr?.length ?? 0);
|
|
107
|
+
const code = (run.error as { code?: string } | undefined)?.code;
|
|
108
|
+
if (code === "ETIMEDOUT") {
|
|
109
|
+
return { ok: false, status: null, message: `timed out after the ${limits.timeout / 1000} s time limit`, bytes };
|
|
110
|
+
}
|
|
111
|
+
if (code === "ENOBUFS") {
|
|
112
|
+
return { ok: false, status: null, message: `printed more than the ${limits.maxBuffer} byte capture limit`, bytes };
|
|
113
|
+
}
|
|
114
|
+
if (run.error !== undefined) return { ok: false, status: null, message: run.error.message, bytes };
|
|
115
|
+
if (run.status === 0) return { ok: true, out: run.stdout.toString("utf8"), bytes };
|
|
116
|
+
const stderr = run.stderr.toString("utf8").trim();
|
|
117
|
+
const how = run.status === null ? `was killed by ${run.signal}` : `exited with status ${run.status}`;
|
|
118
|
+
return { ok: false, status: run.status, message: stderr === "" ? `git ${args[0]} ${how}` : stderr, bytes };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One path Git printed alone on its output: only the final newline is dropped, since a path may hold others. */
|
|
122
|
+
export const onePath = (out: string): string => (out.endsWith("\n") ? out.slice(0, -1) : out);
|
|
123
|
+
|
|
124
|
+
/** The repository and checkout `cwd` belongs to, or why not. */
|
|
125
|
+
function identify(cwd: string): { identity: GitIdentity } | { launch: GitLaunch } {
|
|
126
|
+
// One question per path, so no output is split on a newline a path may contain.
|
|
127
|
+
const top = askGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
128
|
+
if (!top.ok) {
|
|
129
|
+
if (top.status === 128 && /not a git repository/i.test(top.message)) return { launch: { kind: "non-git" } };
|
|
130
|
+
return { launch: { kind: "unknown", reason: `git rev-parse failed: ${top.message}` } };
|
|
131
|
+
}
|
|
132
|
+
const common = askGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
133
|
+
if (!common.ok) return { launch: { kind: "unknown", reason: `git rev-parse failed: ${common.message}` } };
|
|
134
|
+
const own = askGit(cwd, ["rev-parse", "--git-dir"]);
|
|
135
|
+
if (!own.ok) return { launch: { kind: "unknown", reason: `git rev-parse failed: ${own.message}` } };
|
|
136
|
+
let checkoutRoot: string;
|
|
137
|
+
let commonDir: string;
|
|
138
|
+
let gitDir: string;
|
|
139
|
+
try {
|
|
140
|
+
checkoutRoot = realpathSync(onePath(top.out));
|
|
141
|
+
commonDir = realpathSync(resolve(cwd, onePath(common.out)));
|
|
142
|
+
gitDir = realpathSync(resolve(cwd, onePath(own.out)));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { launch: { kind: "unknown", reason: `could not resolve the repository paths: ${(err as Error).message}` } };
|
|
145
|
+
}
|
|
146
|
+
// A main checkout's own Git directory is the common one, wherever it lives (`--separate-git-dir` included).
|
|
147
|
+
if (gitDir === commonDir) return { identity: { mainRoot: checkoutRoot, commonDir, checkoutRoot } };
|
|
148
|
+
// A linked worktree's main checkout is where Git keeps it: the folder holding a `.git` common directory.
|
|
149
|
+
// A linked worktree of a bare repository, or of a main checkout with a separate Git directory, has
|
|
150
|
+
// no main checkout Git can name for a trust key, so its identity stays unknown.
|
|
151
|
+
if (basename(commonDir) !== ".git") {
|
|
152
|
+
return {
|
|
153
|
+
launch: {
|
|
154
|
+
kind: "unknown",
|
|
155
|
+
reason: `the main checkout of the linked worktree ${checkoutRoot} cannot be located: its repository's Git directory ${commonDir} is not a checkout's .git`,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { identity: { mainRoot: dirname(commonDir), commonDir, checkoutRoot } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The checkout at `cwd` as it stands: identity, branch or detached, and the commit checked out. */
|
|
163
|
+
export function inspectGit(cwd: string): GitLaunch {
|
|
164
|
+
const found = identify(cwd);
|
|
165
|
+
if ("launch" in found) return found.launch;
|
|
166
|
+
const ref = askGit(cwd, ["symbolic-ref", "-q", "HEAD"]);
|
|
167
|
+
if (!ref.ok && ref.status !== 1) return { kind: "unknown", reason: `git symbolic-ref failed: ${ref.message}` };
|
|
168
|
+
const name = ref.ok ? ref.out.trim() : "";
|
|
169
|
+
const position: GitPosition = ref.ok
|
|
170
|
+
? { branch: name.startsWith("refs/heads/") ? name.slice("refs/heads/".length) : name, detached: false }
|
|
171
|
+
: { branch: null, detached: true };
|
|
172
|
+
const head = askGit(cwd, ["rev-parse", "-q", "--verify", "HEAD^{commit}"]);
|
|
173
|
+
// Exit 1 with nothing printed is an unborn branch: a valid checkout without a commit.
|
|
174
|
+
if (!head.ok && head.status !== 1) return { kind: "unknown", reason: `git rev-parse HEAD failed: ${head.message}` };
|
|
175
|
+
const commit = head.ok ? head.out.trim() : null;
|
|
176
|
+
return { kind: "git", identity: found.identity, position, head: commit, baseline: commit };
|
|
177
|
+
}
|