my-durable 0.0.22 → 0.0.24

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.
@@ -57,6 +57,19 @@ export interface GitStatus {
57
57
  subject?: string;
58
58
  worktreeOf?: string;
59
59
  worktrees?: { path: string; branch: string | null; head: string }[];
60
+ /** Changed files (porcelain XY + path), capped — the IntelliJ-style commit
61
+ * dialog checks exactly these. */
62
+ files?: { path: string; x: string; y: string }[];
63
+ /** Local branches for pickers — nobody should TYPE a name that exists. */
64
+ branches?: { name: string; current?: boolean; upstream?: string }[];
65
+ }
66
+
67
+ /** One status for MANY paths — what a session list needs. The per-path cache
68
+ * keeps repeat polls cheap on big repos. */
69
+ export async function gitStatusBatch(paths: string[]): Promise<Record<string, GitStatus>> {
70
+ const unique = [...new Set(paths.filter(Boolean))];
71
+ const entries = await Promise.all(unique.map(async (p) => [p, await gitStatus(p)] as const));
72
+ return Object.fromEntries(entries);
60
73
  }
61
74
 
62
75
  /** Structured status for a folder: everything the UIs show, one call. */
@@ -73,12 +86,13 @@ export async function gitStatus(path: string): Promise<GitStatus> {
73
86
  out.root = root;
74
87
  // Everything after toplevel runs in PARALLEL — status is the long pole
75
88
  // (20s+ on big Windows checkouts) and must not wait for four fast calls.
76
- const [branchP, headP, statusP, gitDirP, wlP] = await Promise.allSettled([
89
+ const [branchP, headP, statusP, gitDirP, wlP, branchesP] = await Promise.allSettled([
77
90
  git(path, ["branch", "--show-current"]),
78
91
  git(path, ["log", "-1", "--pretty=%h%x09%s"]),
79
92
  git(path, ["--no-optional-locks", "status", "--porcelain=v1", "-b"], GIT_SLOW_TIMEOUT),
80
93
  git(path, ["rev-parse", "--git-dir"]),
81
94
  git(path, ["worktree", "list", "--porcelain"], GIT_SLOW_TIMEOUT),
95
+ git(path, ["for-each-ref", "refs/heads", "--format=%(refname:short)%09%(upstream:short)%09%(if)%(HEAD)%(then)1%(else)0%(end)"]),
82
96
  ]);
83
97
  const branch = branchP.status === "fulfilled" ? branchP.value : "";
84
98
  out.branch = branch.trim() || undefined;
@@ -107,6 +121,21 @@ export async function gitStatus(path: string): Promise<GitStatus> {
107
121
  }
108
122
  // Per line — a ^-anchored regex on the joined blob only ever sees line 1.
109
123
  const body = lines.slice(1).filter(Boolean);
124
+ out.files = body.slice(0, 500).map((l) => ({
125
+ path: l.slice(3).replace(/^"|"$/g, ""),
126
+ x: l[0] ?? " ",
127
+ y: l[1] ?? " ",
128
+ }));
129
+ if (branchesP.status === "fulfilled")
130
+ out.branches = branchesP.value
131
+ .split("\n")
132
+ .filter(Boolean)
133
+ .slice(0, 300)
134
+ .map((l) => {
135
+ const [name, upstream, head] = l.split("\t");
136
+ return { name: name ?? "", current: head === "1", upstream: upstream || undefined };
137
+ })
138
+ .filter((b) => b.name);
110
139
  out.staged = count(body.join("\n"), /^[MADRC]/gm);
111
140
  out.unstaged = count(body.join("\n"), /^ [MD]/gm);
112
141
  out.untracked = count(body.join("\n"), /^\?\?/gm);
@@ -138,6 +167,7 @@ export async function gitStatus(path: string): Promise<GitStatus> {
138
167
  export type GitOp =
139
168
  | "diff"
140
169
  | "log"
170
+ | "log-list"
141
171
  | "fetch"
142
172
  | "pull"
143
173
  | "switch"
@@ -154,14 +184,31 @@ const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
154
184
  export async function gitOp(
155
185
  path: string,
156
186
  op: GitOp,
157
- opts: { ref?: string; message?: string; target?: string; branch?: string } = {},
158
- ): Promise<{ text: string }> {
159
- const { ref, message, target, branch } = opts;
187
+ opts: { ref?: string; message?: string; target?: string; branch?: string; files?: string[] } = {},
188
+ ): Promise<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }> {
189
+ const { ref, message, target, branch, files } = opts;
160
190
  switch (op) {
161
191
  case "diff":
162
192
  return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
163
193
  case "log":
164
194
  return { text: (await git(path, ["--no-pager", "log", "--oneline", "-n", "30", ...(ref && safeRef(ref) ? [ref] : [])])).trimEnd() };
195
+ case "log-list": {
196
+ // IntelliJ's log window: hash, refs, author, date, subject — structured.
197
+ const raw = await git(
198
+ path,
199
+ ["--no-pager", "log", "-n", "50", "--date=short", "--pretty=format:%h%x1f%D%x1f%an%x1f%ad%x1f%s"],
200
+ );
201
+ return {
202
+ text: "",
203
+ commits: raw
204
+ .split("\n")
205
+ .filter(Boolean)
206
+ .map((l) => {
207
+ const [hash, refs, author, date, subject] = l.split("\x1f");
208
+ return { hash: hash ?? "", refs: refs ?? "", author: author ?? "", date: date ?? "", subject: subject ?? "" };
209
+ }),
210
+ };
211
+ }
165
212
  case "fetch":
166
213
  return { text: (await git(path, ["fetch", "--all", "--prune"])).trim() || "fetched" };
167
214
  case "pull":
@@ -175,6 +222,11 @@ export async function gitOp(
175
222
  case "commit": {
176
223
  const msg = (message ?? "").trim();
177
224
  if (!msg) throw Error("commit needs a message");
225
+ // IntelliJ-style: chosen files only, when the caller picked some.
226
+ if (files?.length) {
227
+ for (const f of files) if (f.includes("\0") || f === "") throw Error("bad file path");
228
+ return { text: (await git(path, ["commit", "-m", msg, "--", ...files])).split("\n")[0]?.trim() ?? "committed" };
229
+ }
178
230
  return { text: (await git(path, ["commit", "-am", msg])).split("\n")[0]?.trim() ?? "committed" };
179
231
  }
180
232
  case "worktree-list":
@@ -10,8 +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
+ import { gitOp, gitStatus, gitStatusBatch, type GitOp } from "./git.ts";
14
+ const GIT_OPS: GitOp[] = ["diff", "log", "log-list", "fetch", "pull", "switch", "branch", "commit", "worktree-list", "worktree-add", "worktree-remove"];
15
15
 
16
16
  interface Locals {
17
17
  row: SessionRow;
@@ -124,6 +124,10 @@ export function v1(
124
124
  // Git, spoken by the device itself: status for the UIs, ops for the humans.
125
125
  // The path must live under a start dir (same rule as every other fs route).
126
126
  r.get("/git/status", route(async (req, res) => res.json(await gitStatus(String(req.query.path ?? ".")))));
127
+ r.post("/git/status-batch", route(async (req, res) => {
128
+ const paths = Array.isArray(req.body?.paths) ? req.body.paths.map(String).slice(0, 50) : [];
129
+ res.json(await gitStatusBatch(paths));
130
+ }));
127
131
  r.post("/git", route(async (req, res) => {
128
132
  const body = req.body ?? {};
129
133
  const op = String(body.op ?? "") as GitOp;
@@ -133,6 +137,7 @@ export function v1(
133
137
  branch: body.branch,
134
138
  message: body.message,
135
139
  target: body.target,
140
+ files: Array.isArray(body.files) ? body.files.map(String).slice(0, 500) : undefined,
136
141
  });
137
142
  res.json(out);
138
143
  }));