my-durable 0.0.15 → 0.0.17

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Device-side git, spoken with the porcelain plumbing — no shell, ever.
3
+ * execFile("git", [...]) with whitelisted subcommands and hand-built args;
4
+ * paths are passed as args, never interpolated into a command string.
5
+ *
6
+ * GET /v1/git/status?path=<dir> branch, ahead/behind, dirty counts, worktrees
7
+ * POST /v1/git { path, op, ...op args } → text or structured
8
+ * ops: diff, log, fetch, pull, switch, branch, commit, worktree-list,
9
+ * worktree-add, worktree-remove
10
+ */
11
+ import { execFile } from "node:child_process";
12
+ import { dirname, isAbsolute } from "node:path";
13
+
14
+ const GIT_TIMEOUT = 15_000;
15
+
16
+ /** One git call; rejects on non-zero exit with stderr in the message. */
17
+ export function git(cwd: string, args: string[]): Promise<string> {
18
+ return new Promise((ok, fail) => {
19
+ execFile("git", ["-C", cwd, ...args], { timeout: GIT_TIMEOUT, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
20
+ if (error) fail(Error(String(stderr || stdout || error.message).trim().slice(0, 400)));
21
+ // git chats on stderr for worktree add/switch/fetch — keep both.
22
+ else ok(`${String(stdout)}${String(stdout) && String(stderr) ? "\n" : ""}${String(stderr)}`);
23
+ });
24
+ });
25
+ }
26
+
27
+ const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
28
+
29
+ export interface GitStatus {
30
+ repo: boolean;
31
+ root?: string;
32
+ branch?: string;
33
+ detached?: boolean;
34
+ upstream?: string;
35
+ ahead?: number;
36
+ behind?: number;
37
+ staged?: number;
38
+ unstaged?: number;
39
+ untracked?: number;
40
+ head?: string;
41
+ subject?: string;
42
+ worktreeOf?: string;
43
+ worktrees?: { path: string; branch: string | null; head: string }[];
44
+ }
45
+
46
+ /** Structured status for a folder: everything the UIs show, one call. */
47
+ export async function gitStatus(path: string): Promise<GitStatus> {
48
+ const out: GitStatus = { repo: false };
49
+ try {
50
+ const root = (await git(path, ["rev-parse", "--show-toplevel"])).trim();
51
+ out.repo = true;
52
+ out.root = root;
53
+ const branch = await git(path, ["branch", "--show-current"]);
54
+ out.branch = branch.trim() || undefined;
55
+ out.detached = !out.branch;
56
+ const headLine = (await git(path, ["log", "-1", "--pretty=%h%x09%s"])).trim();
57
+ if (headLine) {
58
+ const [h, ...rest] = headLine.split("\t");
59
+ out.head = h;
60
+ out.subject = rest.join("\t").slice(0, 120) || undefined;
61
+ }
62
+ const status = await git(path, ["status", "--porcelain=v1", "-b"]);
63
+ const lines = status.split("\n");
64
+ const head2 = lines[0] ?? "";
65
+ // "## main...origin/main [ahead 2, behind 1]" — split by hand: a lazy \S+?
66
+ // ate the branch down to one letter.
67
+ const info = head2.replace(/^## /, "");
68
+ const [branchPart, bracket] = info.split(/\s+\[/);
69
+ const [local, upstream] = branchPart.trim().split("...");
70
+ if (local) out.branch = local === "HEAD" || local === "HEAD (no branch)" ? out.branch : local;
71
+ if (upstream) {
72
+ out.upstream = upstream;
73
+ const ab = bracket?.replace(/\]$/, "") ?? "";
74
+ out.ahead = Number(ab.match(/ahead (\d+)/)?.[1] ?? 0);
75
+ out.behind = Number(ab.match(/behind (\d+)/)?.[1] ?? 0);
76
+ }
77
+ // Per line — a ^-anchored regex on the joined blob only ever sees line 1.
78
+ const body = lines.slice(1).filter(Boolean);
79
+ out.staged = count(body.join("\n"), /^[MADRC]/gm);
80
+ out.unstaged = count(body.join("\n"), /^ [MD]/gm);
81
+ out.untracked = count(body.join("\n"), /^\?\?/gm);
82
+ // Is this folder itself a linked worktree (not the main checkout)?
83
+ // In a linked worktree --git-dir is "<main>/.git/worktrees/<name>";
84
+ // the main checkout is the .git's parent.
85
+ const gitDir = (await git(path, ["rev-parse", "--git-dir"])).trim();
86
+ if (gitDir.includes("/worktrees/")) out.worktreeOf = dirname(gitDir.split("/worktrees/")[0]!);
87
+ const wl = await git(path, ["worktree", "list", "--porcelain"]);
88
+ out.worktrees = wl
89
+ .split("\n\n")
90
+ .map((b) => b.trim())
91
+ .filter(Boolean)
92
+ .map((b) => {
93
+ const g = (k: string) => b.split("\n").find((l) => l.startsWith(k + " "))?.slice(k.length + 1) ?? null;
94
+ return { path: g("worktree") ?? "", branch: g("branch")?.replace("refs/heads/", "") ?? null, head: g("HEAD")?.slice(0, 8) ?? "" };
95
+ })
96
+ .filter((w) => w.path);
97
+ } catch {
98
+ /* not a repo, or git missing — the empty { repo: false } is the answer */
99
+ }
100
+ return out;
101
+ }
102
+
103
+ export type GitOp =
104
+ | "diff"
105
+ | "log"
106
+ | "fetch"
107
+ | "pull"
108
+ | "switch"
109
+ | "branch"
110
+ | "commit"
111
+ | "worktree-list"
112
+ | "worktree-add"
113
+ | "worktree-remove";
114
+
115
+ /** Plain string an arg may never contain — we build argv ourselves anyway. */
116
+ const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
117
+
118
+ /** Run one whitelisted git operation. Every arg is ours; callers only pick names. */
119
+ export async function gitOp(
120
+ path: string,
121
+ op: GitOp,
122
+ opts: { ref?: string; message?: string; target?: string; branch?: string } = {},
123
+ ): Promise<{ text: string }> {
124
+ const { ref, message, target, branch } = opts;
125
+ switch (op) {
126
+ case "diff":
127
+ return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
128
+ case "log":
129
+ return { text: (await git(path, ["--no-pager", "log", "--oneline", "-n", "30", ...(ref && safeRef(ref) ? [ref] : [])])).trimEnd() };
130
+ case "fetch":
131
+ return { text: (await git(path, ["fetch", "--all", "--prune"])).trim() || "fetched" };
132
+ case "pull":
133
+ return { text: (await git(path, ["pull", "--ff-only"])).trim() || "up to date" };
134
+ case "branch":
135
+ return { text: (await git(path, ["--no-pager", "branch", "-vv"])).trimEnd() };
136
+ case "switch": {
137
+ if (!ref || !safeRef(ref)) throw Error("switch needs a branch name (letters, digits, ./-_)");
138
+ return { text: (await git(path, ["switch", ref])).trim() };
139
+ }
140
+ case "commit": {
141
+ const msg = (message ?? "").trim();
142
+ if (!msg) throw Error("commit needs a message");
143
+ return { text: (await git(path, ["commit", "-am", msg])).split("\n")[0]?.trim() ?? "committed" };
144
+ }
145
+ case "worktree-list":
146
+ return { text: (await git(path, ["--no-pager", "worktree", "list"])).trimEnd() };
147
+ case "worktree-add": {
148
+ // branch = create a NEW branch for the worktree; ref = check out an
149
+ // existing one. Both validated the same strict way — never a shell.
150
+ if (!target || !isAbsolute(target)) throw Error("worktree-add needs an absolute target path");
151
+ if (branch) {
152
+ if (!safeRef(branch)) throw Error("worktree-add branch name is invalid (letters, digits, ./-_)");
153
+ return { text: (await git(path, ["worktree", "add", "-b", branch, target])).trim() };
154
+ }
155
+ if (!ref || !safeRef(ref)) throw Error("worktree-add needs a branch (new) or ref (existing)");
156
+ return { text: (await git(path, ["worktree", "add", target, ref])).trim() };
157
+ }
158
+ case "worktree-remove": {
159
+ if (!target || !isAbsolute(target)) throw Error("worktree-remove needs an absolute path");
160
+ return { text: (await git(path, ["worktree", "remove", target])).trim() || `removed ${target}` };
161
+ }
162
+ }
163
+ }
@@ -10,6 +10,8 @@ import type { DevicePairing } from "./pairing.js";
10
10
 
11
11
  type Async = (req: Request, res: Response) => Promise<unknown>;
12
12
  const route = (fn: Async) => (req: Request, res: Response, next: NextFunction) => fn(req, res).catch(next);
13
+ import { gitOp, gitStatus, type GitOp } from "./git.ts";
14
+ const GIT_OPS: GitOp[] = ["diff", "log", "fetch", "pull", "switch", "branch", "commit", "worktree-list", "worktree-add", "worktree-remove"];
13
15
 
14
16
  interface Locals {
15
17
  row: SessionRow;
@@ -119,6 +121,21 @@ export function v1(
119
121
  }
120
122
  });
121
123
  r.get("/fs/dirs", route(async (req, res) => res.json(await store.browse(String(req.query.path ?? "")))));
124
+ // Git, spoken by the device itself: status for the UIs, ops for the humans.
125
+ // The path must live under a start dir (same rule as every other fs route).
126
+ r.get("/git/status", route(async (req, res) => res.json(await gitStatus(String(req.query.path ?? ".")))));
127
+ r.post("/git", route(async (req, res) => {
128
+ const body = req.body ?? {};
129
+ const op = String(body.op ?? "") as GitOp;
130
+ if (!GIT_OPS.includes(op)) throw invalid(`Unknown git op ${String(body.op ?? "")}`);
131
+ const out = await gitOp(String(body.path ?? "."), op, {
132
+ ref: body.ref,
133
+ branch: body.branch,
134
+ message: body.message,
135
+ target: body.target,
136
+ });
137
+ res.json(out);
138
+ }));
122
139
  r.get(
123
140
  "/docs/:kind",
124
141
  route(async (req, res) => {