@neuralnomads/codenomad-dev 0.14.0-dev-20260416-657e78da → 0.14.0-dev-20260418-e022a158

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.
Files changed (48) hide show
  1. package/dist/server/http-server.js +2 -31
  2. package/dist/server/routes/workspaces.js +118 -0
  3. package/dist/workspaces/git-mutations.js +98 -0
  4. package/dist/workspaces/git-status.js +323 -0
  5. package/dist/workspaces/git-worktrees.js +18 -1
  6. package/dist/workspaces/worktree-directory.js +74 -0
  7. package/package.json +1 -1
  8. package/public/assets/{ChangesTab-CFkk3tAp.js → ChangesTab-DUZlwLiK.js} +2 -2
  9. package/public/assets/{DiffToolbar-CQQLR1mp.js → DiffToolbar-C32dRoEE.js} +1 -1
  10. package/public/assets/{FilesTab-CXgucPtN.js → FilesTab-BHR_yoxs.js} +2 -2
  11. package/public/assets/GitChangesTab-glvFfIqf.js +2 -0
  12. package/public/assets/{SplitFilePanel-CupweEA5.js → SplitFilePanel-c1Cyzd2Q.js} +1 -1
  13. package/public/assets/StatusTab-D_y6grlM.js +1 -0
  14. package/public/assets/{bundle-full-B-5u6Iv3.js → bundle-full-xErn6pK2.js} +1 -1
  15. package/public/assets/{diff-viewer-_ehhYA-Z.js → diff-viewer-Ti4vD-Wf.js} +1 -1
  16. package/public/assets/index-Buu9pQM-.js +1 -0
  17. package/public/assets/index-D5K3x-5q.js +1 -0
  18. package/public/assets/index-DAUGrHIr.js +1 -0
  19. package/public/assets/index-DCKsvmLK.js +1 -0
  20. package/public/assets/{index-BqiNjV6R.js → index-DNjsUvJP.js} +1 -1
  21. package/public/assets/index-KGa2MZ03.js +1 -0
  22. package/public/assets/index-MN-xsoRk.js +2 -0
  23. package/public/assets/{index-CxQr-wOF.js → index-RbYx88R2.js} +1 -1
  24. package/public/assets/index-Vi334d28.js +1 -0
  25. package/public/assets/index-s309YxXV.css +1 -0
  26. package/public/assets/{loading-Dck2H3XH.js → loading-8z6r7QGU.js} +1 -1
  27. package/public/assets/main-BL6QgWzz.js +48 -0
  28. package/public/assets/{markdown-CFV9kR80.js → markdown-CN__9g8I.js} +3 -3
  29. package/public/assets/monaco-viewer-C8arwHE9.js +26 -0
  30. package/public/assets/{todo-Ghwirj-b.js → todo-CGjayFGz.js} +1 -1
  31. package/public/assets/{tool-call-BC2Pv3uG.js → tool-call-Wyw279OP.js} +3 -3
  32. package/public/assets/{unified-picker-Dw0kTXoT.js → unified-picker-DqT__5EQ.js} +1 -1
  33. package/public/assets/{wrap-text-D2MJk2ad.js → wrap-text-DJEP0aF1.js} +1 -1
  34. package/public/index.html +4 -4
  35. package/public/loading.html +4 -4
  36. package/public/sw.js +1 -1
  37. package/public/assets/GitChangesTab-BidSb28_.js +0 -2
  38. package/public/assets/StatusTab-C2Pl_Vce.js +0 -1
  39. package/public/assets/index-BnfDkr8C.js +0 -1
  40. package/public/assets/index-BntAaLZs.js +0 -1
  41. package/public/assets/index-BwCEJ4BE.css +0 -1
  42. package/public/assets/index-DXaitXVC.js +0 -1
  43. package/public/assets/index-DzRzoTK8.js +0 -2
  44. package/public/assets/index-ShHad48G.js +0 -1
  45. package/public/assets/index-_EaXFUIj.js +0 -1
  46. package/public/assets/index-mMh5TJvW.js +0 -1
  47. package/public/assets/main-BsIf5lOY.js +0 -50
  48. package/public/assets/monaco-viewer-CMM6n0T3.js +0 -25
@@ -7,7 +7,8 @@ import { connect as connectTcp } from "net";
7
7
  import path from "path";
8
8
  import { connect as connectTls } from "tls";
9
9
  import { fetch } from "undici";
10
- import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees";
10
+ import { isValidWorktreeSlug } from "../workspaces/git-worktrees";
11
+ import { resolveWorktreeDirectory } from "../workspaces/worktree-directory";
11
12
  import { registerWorkspaceRoutes } from "./routes/workspaces";
12
13
  import { registerSettingsRoutes } from "./routes/settings";
13
14
  import { registerFilesystemRoutes } from "./routes/filesystem";
@@ -602,36 +603,6 @@ function normalizeInstanceSuffix(pathSuffix) {
602
603
  const trimmed = pathSuffix.replace(/^\/+/, "");
603
604
  return trimmed.length === 0 ? "/" : `/${trimmed}`;
604
605
  }
605
- const WORKTREE_CACHE_TTL_MS = 2000;
606
- const worktreeCache = new Map();
607
- async function getCachedWorktrees(params) {
608
- const cached = worktreeCache.get(params.workspaceId);
609
- const now = Date.now();
610
- if (cached && cached.expiresAt > now) {
611
- return cached;
612
- }
613
- const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger);
614
- const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger });
615
- const entry = {
616
- expiresAt: now + WORKTREE_CACHE_TTL_MS,
617
- repoRoot,
618
- worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
619
- };
620
- worktreeCache.set(params.workspaceId, entry);
621
- return entry;
622
- }
623
- async function resolveWorktreeDirectory(params) {
624
- const { worktreeSlug } = params;
625
- const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger });
626
- const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug);
627
- if (match) {
628
- return match.directory;
629
- }
630
- // If the slug is new (e.g., created moments ago), refresh once.
631
- worktreeCache.delete(params.workspaceId);
632
- const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger });
633
- return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null;
634
- }
635
606
  function setupStaticUi(app, uiDir, authManager) {
636
607
  if (!uiDir) {
637
608
  app.log.warn("UI static directory not provided; API endpoints only");
@@ -1,4 +1,8 @@
1
1
  import { z } from "zod";
2
+ import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status";
3
+ import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations";
4
+ import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees";
5
+ import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory";
2
6
  const WorkspaceCreateSchema = z.object({
3
7
  path: z.string(),
4
8
  name: z.string().optional(),
@@ -12,6 +16,17 @@ const WorkspaceFileContentQuerySchema = z.object({
12
16
  const WorkspaceFileContentBodySchema = z.object({
13
17
  contents: z.string(),
14
18
  });
19
+ const WorktreeGitDiffQuerySchema = z.object({
20
+ path: z.string().trim().min(1, "Path is required"),
21
+ originalPath: z.string().trim().optional(),
22
+ scope: z.enum(["staged", "unstaged"]),
23
+ });
24
+ const WorktreeGitPathsBodySchema = z.object({
25
+ paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
26
+ });
27
+ const WorktreeGitCommitBodySchema = z.object({
28
+ message: z.string().trim().min(1, "Commit message is required"),
29
+ });
15
30
  const WorkspaceFileSearchQuerySchema = z.object({
16
31
  q: z.string().trim().min(1, "Query is required"),
17
32
  limit: z.coerce.number().int().positive().max(200).optional(),
@@ -92,8 +107,111 @@ export function registerWorkspaceRoutes(app, deps) {
92
107
  return handleWorkspaceError(error, reply);
93
108
  }
94
109
  });
110
+ app.get("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
111
+ try {
112
+ const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
113
+ if (!directory)
114
+ return;
115
+ return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log });
116
+ }
117
+ catch (error) {
118
+ return handleWorkspaceError(error, reply);
119
+ }
120
+ });
121
+ app.get("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
122
+ try {
123
+ const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {});
124
+ const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
125
+ if (!directory)
126
+ return;
127
+ return await getWorktreeGitDiff({
128
+ workspaceFolder: directory,
129
+ path: query.path,
130
+ originalPath: query.originalPath,
131
+ scope: query.scope,
132
+ });
133
+ }
134
+ catch (error) {
135
+ return handleWorkspaceError(error, reply);
136
+ }
137
+ });
138
+ app.post("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
139
+ try {
140
+ const body = WorktreeGitPathsBodySchema.parse(request.body ?? {});
141
+ const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
142
+ if (!directory)
143
+ return;
144
+ await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths });
145
+ return { ok: true };
146
+ }
147
+ catch (error) {
148
+ return handleWorkspaceError(error, reply);
149
+ }
150
+ });
151
+ app.post("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
152
+ try {
153
+ const body = WorktreeGitPathsBodySchema.parse(request.body ?? {});
154
+ const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
155
+ if (!directory)
156
+ return;
157
+ await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths });
158
+ return { ok: true };
159
+ }
160
+ catch (error) {
161
+ return handleWorkspaceError(error, reply);
162
+ }
163
+ });
164
+ app.post("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
165
+ try {
166
+ const body = WorktreeGitCommitBodySchema.parse(request.body ?? {});
167
+ const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply);
168
+ if (!directory)
169
+ return;
170
+ const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message });
171
+ return { ok: true, ...result };
172
+ }
173
+ catch (error) {
174
+ return handleWorkspaceError(error, reply);
175
+ }
176
+ });
177
+ }
178
+ async function resolveGitWorktreeDirectory(workspaceManager, workspaceId, worktreeSlug, logger, reply) {
179
+ const workspace = workspaceManager.get(workspaceId);
180
+ if (!workspace) {
181
+ reply.code(404);
182
+ reply.send({ error: "Workspace not found" });
183
+ return null;
184
+ }
185
+ const gitAvailable = await isGitAvailable(workspace.path);
186
+ if (!gitAvailable) {
187
+ reply.code(503);
188
+ reply.send({ error: "Git is not installed or not available in PATH" });
189
+ return null;
190
+ }
191
+ const { isGitRepo } = await resolveRepoRoot(workspace.path, logger);
192
+ if (!isGitRepo) {
193
+ reply.code(400);
194
+ reply.send({ error: "Workspace is not a Git repository" });
195
+ return null;
196
+ }
197
+ const directory = await resolveWorktreeDirectory({
198
+ workspaceId: workspace.id,
199
+ workspacePath: workspace.path,
200
+ worktreeSlug,
201
+ logger,
202
+ });
203
+ if (!directory) {
204
+ reply.code(404);
205
+ reply.send({ error: "Worktree not found" });
206
+ return null;
207
+ }
208
+ return directory;
95
209
  }
96
210
  function handleWorkspaceError(error, reply) {
211
+ if (isGitMutationError(error)) {
212
+ reply.code(error.statusCode);
213
+ return { error: error.message };
214
+ }
97
215
  if (error instanceof Error && error.message === "Workspace not found") {
98
216
  reply.code(404);
99
217
  return { error: "Workspace not found" };
@@ -0,0 +1,98 @@
1
+ import { spawn } from "child_process";
2
+ import path from "path";
3
+ class GitMutationError extends Error {
4
+ constructor(message, statusCode = 400) {
5
+ super(message);
6
+ this.name = "GitMutationError";
7
+ this.statusCode = statusCode;
8
+ }
9
+ }
10
+ function runGit(args, cwd) {
11
+ return new Promise((resolve) => {
12
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
13
+ let stdout = "";
14
+ let stderr = "";
15
+ child.stdout?.on("data", (chunk) => {
16
+ stdout += chunk.toString();
17
+ });
18
+ child.stderr?.on("data", (chunk) => {
19
+ stderr += chunk.toString();
20
+ });
21
+ child.once("error", (error) => {
22
+ resolve({ ok: false, error, stdout, stderr });
23
+ });
24
+ child.once("close", (code) => {
25
+ if (code === 0) {
26
+ resolve({ ok: true, stdout });
27
+ }
28
+ else {
29
+ const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`);
30
+ resolve({ ok: false, error, stdout, stderr });
31
+ }
32
+ });
33
+ });
34
+ }
35
+ export function normalizeGitWorktreeRelativePath(input) {
36
+ const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "");
37
+ if (!normalized) {
38
+ throw new GitMutationError("Path is required", 400);
39
+ }
40
+ if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
41
+ throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400);
42
+ }
43
+ if (normalized === "." || normalized === "..") {
44
+ throw new GitMutationError(`Invalid path: ${input}`, 400);
45
+ }
46
+ if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
47
+ throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400);
48
+ }
49
+ return normalized;
50
+ }
51
+ function normalizeGitMutationPaths(paths) {
52
+ const deduped = new Set();
53
+ for (const rawPath of paths) {
54
+ deduped.add(normalizeGitWorktreeRelativePath(rawPath));
55
+ }
56
+ const normalized = Array.from(deduped);
57
+ if (normalized.length === 0) {
58
+ throw new GitMutationError("At least one path is required", 400);
59
+ }
60
+ return normalized;
61
+ }
62
+ async function ensureGitCommandSucceeded(resultPromise, fallbackMessage) {
63
+ const result = await resultPromise;
64
+ if (!result.ok) {
65
+ const message = result.stderr?.trim() || result.error.message || fallbackMessage;
66
+ throw new GitMutationError(message, 409);
67
+ }
68
+ return result.stdout;
69
+ }
70
+ export function isGitMutationError(error) {
71
+ return error instanceof GitMutationError;
72
+ }
73
+ export async function stageWorktreePaths(params) {
74
+ const paths = normalizeGitMutationPaths(params.paths);
75
+ await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files");
76
+ }
77
+ export async function unstageWorktreePaths(params) {
78
+ const paths = normalizeGitMutationPaths(params.paths);
79
+ const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder);
80
+ if (headResult.ok) {
81
+ await ensureGitCommandSucceeded(runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder), "Failed to unstage files");
82
+ return;
83
+ }
84
+ await ensureGitCommandSucceeded(runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder), "Failed to unstage files");
85
+ }
86
+ export async function commitWorktreeChanges(params) {
87
+ const message = params.message.trim();
88
+ if (!message) {
89
+ throw new GitMutationError("Commit message is required", 400);
90
+ }
91
+ await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit");
92
+ const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder);
93
+ if (!shaResult.ok) {
94
+ return {};
95
+ }
96
+ const commitSha = shaResult.stdout.trim();
97
+ return commitSha ? { commitSha } : {};
98
+ }
@@ -0,0 +1,323 @@
1
+ import { spawn } from "child_process";
2
+ import { readFile } from "fs/promises";
3
+ import path from "path";
4
+ import { normalizeGitWorktreeRelativePath } from "./git-mutations";
5
+ async function readFileAsDiffText(filePath) {
6
+ return readFile(filePath, "utf-8");
7
+ }
8
+ async function readGitBlobAsDiffText(resultPromise, missingOk = false) {
9
+ const result = await resultPromise;
10
+ if (!result.ok) {
11
+ return decodeGitShowResult(result, missingOk);
12
+ }
13
+ return result.stdout;
14
+ }
15
+ function runGit(args, cwd, acceptedExitCodes = [0]) {
16
+ return new Promise((resolve) => {
17
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
18
+ let stdout = "";
19
+ let stderr = "";
20
+ child.stdout?.on("data", (chunk) => {
21
+ stdout += chunk.toString();
22
+ });
23
+ child.stderr?.on("data", (chunk) => {
24
+ stderr += chunk.toString();
25
+ });
26
+ child.once("error", (error) => {
27
+ resolve({ ok: false, error, stdout, stderr });
28
+ });
29
+ child.once("close", (code) => {
30
+ if (acceptedExitCodes.includes(code ?? 0)) {
31
+ resolve({ ok: true, stdout });
32
+ }
33
+ else {
34
+ const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`);
35
+ resolve({ ok: false, error, stdout, stderr });
36
+ }
37
+ });
38
+ });
39
+ }
40
+ function ensureEntry(map, path) {
41
+ const existing = map.get(path);
42
+ if (existing)
43
+ return existing;
44
+ const next = {
45
+ path,
46
+ originalPath: null,
47
+ stagedStatus: null,
48
+ stagedAdditions: 0,
49
+ stagedDeletions: 0,
50
+ unstagedStatus: null,
51
+ unstagedAdditions: 0,
52
+ unstagedDeletions: 0,
53
+ };
54
+ map.set(path, next);
55
+ return next;
56
+ }
57
+ function normalizeGitStatusPath(value) {
58
+ return value.trim().replace(/\\+/g, "/");
59
+ }
60
+ function parseGitChangeKind(code) {
61
+ const normalized = code.trim().toUpperCase();
62
+ if (!normalized)
63
+ return null;
64
+ if (normalized === "A")
65
+ return "added";
66
+ if (normalized === "M")
67
+ return "modified";
68
+ if (normalized === "D")
69
+ return "deleted";
70
+ if (normalized.startsWith("R"))
71
+ return "renamed";
72
+ if (normalized.startsWith("C"))
73
+ return "copied";
74
+ if (normalized === "U")
75
+ return "unmerged";
76
+ return null;
77
+ }
78
+ function applyNameStatusOutput(map, output, target) {
79
+ const tokens = output.split("\0");
80
+ let index = 0;
81
+ while (index < tokens.length) {
82
+ const record = tokens[index++] ?? "";
83
+ if (!record)
84
+ continue;
85
+ const parts = record.split("\t");
86
+ const statusCode = parseGitChangeKind(parts[0] ?? "");
87
+ if (!statusCode)
88
+ continue;
89
+ const inlinePath = parts.slice(1).join("\t");
90
+ const firstPath = inlinePath || tokens[index++] || "";
91
+ const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : "";
92
+ const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath;
93
+ const normalizedPath = normalizeGitStatusPath(path);
94
+ if (!normalizedPath)
95
+ continue;
96
+ const entry = ensureEntry(map, normalizedPath);
97
+ entry[target] = statusCode;
98
+ if (statusCode === "renamed" || statusCode === "copied") {
99
+ const originalPath = normalizeGitStatusPath(firstPath);
100
+ entry.originalPath = originalPath || entry.originalPath || null;
101
+ }
102
+ }
103
+ }
104
+ function applyUntrackedOutput(map, output) {
105
+ for (const rawLine of output.split(/\r?\n/)) {
106
+ const path = normalizeGitStatusPath(rawLine);
107
+ if (!path)
108
+ continue;
109
+ ensureEntry(map, path).unstagedStatus = "untracked";
110
+ }
111
+ }
112
+ function parseSingleNumstat(output) {
113
+ for (const rawLine of output.split(/\r?\n/)) {
114
+ const line = rawLine.trim();
115
+ if (!line)
116
+ continue;
117
+ const parts = rawLine.split("\t");
118
+ const isBinary = parts[0] === "-" || parts[1] === "-";
119
+ return {
120
+ additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
121
+ deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
122
+ isBinary,
123
+ found: true,
124
+ };
125
+ }
126
+ return { additions: 0, deletions: 0, isBinary: false, found: false };
127
+ }
128
+ async function getUntrackedFileNumstat(workspaceFolder, relativePath) {
129
+ const absolutePath = path.join(workspaceFolder, relativePath);
130
+ const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1]);
131
+ if (!result.ok) {
132
+ throw result.error;
133
+ }
134
+ const parsed = parseSingleNumstat(result.stdout);
135
+ return { additions: parsed.additions, deletions: parsed.deletions };
136
+ }
137
+ async function applyUntrackedFileStats(map, workspaceFolder) {
138
+ const pending = Array.from(map.values())
139
+ .filter((entry) => entry.unstagedStatus === "untracked")
140
+ .map(async (entry) => {
141
+ try {
142
+ const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path);
143
+ entry.unstagedAdditions = stats.additions;
144
+ entry.unstagedDeletions = stats.deletions;
145
+ }
146
+ catch {
147
+ entry.unstagedAdditions = 0;
148
+ entry.unstagedDeletions = 0;
149
+ }
150
+ });
151
+ await Promise.all(pending);
152
+ }
153
+ function applyNumstatOutput(map, output, target) {
154
+ const tokens = output.split("\0");
155
+ let index = 0;
156
+ while (index < tokens.length) {
157
+ const record = tokens[index++] ?? "";
158
+ if (!record)
159
+ continue;
160
+ const parts = record.split("\t");
161
+ if (parts.length < 3)
162
+ continue;
163
+ const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10);
164
+ const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10);
165
+ const inlinePath = parts.slice(2).join("\t");
166
+ const isRenameLike = inlinePath === "";
167
+ const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null;
168
+ const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath);
169
+ if (!normalizedPath)
170
+ continue;
171
+ const entry = ensureEntry(map, normalizedPath);
172
+ if (originalPath) {
173
+ entry.originalPath = originalPath;
174
+ }
175
+ if (target === "staged") {
176
+ entry.stagedAdditions = Number.isFinite(additions) ? additions : 0;
177
+ entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0;
178
+ }
179
+ else {
180
+ entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0;
181
+ entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0;
182
+ }
183
+ }
184
+ }
185
+ export async function getWorktreeGitStatus(params) {
186
+ const { workspaceFolder, logger } = params;
187
+ const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
188
+ runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
189
+ runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
190
+ runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
191
+ runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
192
+ runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
193
+ ]);
194
+ for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
195
+ if (!result.ok) {
196
+ logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree");
197
+ throw result.error;
198
+ }
199
+ }
200
+ const stagedOutput = stagedResult.stdout;
201
+ const unstagedOutput = unstagedResult.stdout;
202
+ const untrackedOutput = untrackedResult.stdout;
203
+ const stagedNumstatOutput = stagedNumstatResult.stdout;
204
+ const unstagedNumstatOutput = unstagedNumstatResult.stdout;
205
+ const entries = new Map();
206
+ applyNameStatusOutput(entries, stagedOutput, "stagedStatus");
207
+ applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus");
208
+ applyUntrackedOutput(entries, untrackedOutput);
209
+ applyNumstatOutput(entries, stagedNumstatOutput, "staged");
210
+ applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged");
211
+ await applyUntrackedFileStats(entries, workspaceFolder);
212
+ return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path));
213
+ }
214
+ function decodeGitShowResult(result, missingOk = false) {
215
+ if (result.ok)
216
+ return result.stdout;
217
+ const message = result.stderr?.trim() || result.error.message || "";
218
+ if (missingOk &&
219
+ (message.includes("exists on disk, but not in") ||
220
+ message.includes("Path '") ||
221
+ message.includes("does not exist") ||
222
+ message.includes("unknown revision or path not in the working tree"))) {
223
+ return "";
224
+ }
225
+ throw result.error;
226
+ }
227
+ async function readGitIndexBlob(workspaceFolder, normalizedPath) {
228
+ return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder);
229
+ }
230
+ async function getTrackedDiffMetadata(params) {
231
+ const args = ["diff", "--numstat"];
232
+ if (params.scope === "staged") {
233
+ args.push("--cached");
234
+ }
235
+ args.push("--find-renames", "--find-copies", "--");
236
+ args.push(params.normalizedPath);
237
+ if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
238
+ args.push(params.normalizedOriginalPath);
239
+ }
240
+ const result = await runGit(args, params.workspaceFolder);
241
+ if (!result.ok) {
242
+ throw result.error;
243
+ }
244
+ const parsed = parseSingleNumstat(result.stdout);
245
+ return { isBinary: parsed.isBinary, found: parsed.found };
246
+ }
247
+ async function getUntrackedDiffMetadata(params) {
248
+ const absolutePath = path.join(params.workspaceFolder, params.normalizedPath);
249
+ const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1]);
250
+ if (!result.ok) {
251
+ throw result.error;
252
+ }
253
+ return { isBinary: parseSingleNumstat(result.stdout).isBinary };
254
+ }
255
+ async function resolveUnstagedBeforePath(params) {
256
+ const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath);
257
+ if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
258
+ return currentPathResult;
259
+ }
260
+ return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath);
261
+ }
262
+ export async function getWorktreeGitDiff(params) {
263
+ const normalizedPath = normalizeGitWorktreeRelativePath(params.path);
264
+ const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null;
265
+ const trackedMetadata = await getTrackedDiffMetadata({
266
+ workspaceFolder: params.workspaceFolder,
267
+ scope: params.scope,
268
+ normalizedPath,
269
+ normalizedOriginalPath,
270
+ });
271
+ const diffMetadata = params.scope === "unstaged" && !trackedMetadata.found
272
+ ? await getUntrackedDiffMetadata({
273
+ workspaceFolder: params.workspaceFolder,
274
+ normalizedPath,
275
+ })
276
+ : trackedMetadata;
277
+ if (diffMetadata.isBinary) {
278
+ return {
279
+ path: normalizedPath,
280
+ originalPath: normalizedOriginalPath,
281
+ scope: params.scope,
282
+ before: "",
283
+ after: "",
284
+ isBinary: true,
285
+ };
286
+ }
287
+ if (params.scope === "staged") {
288
+ const [beforeResult, afterResult] = await Promise.all([
289
+ readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
290
+ readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
291
+ ]);
292
+ return {
293
+ path: normalizedPath,
294
+ originalPath: normalizedOriginalPath,
295
+ scope: params.scope,
296
+ before: beforeResult,
297
+ after: afterResult,
298
+ isBinary: false,
299
+ };
300
+ }
301
+ const indexResult = await resolveUnstagedBeforePath({
302
+ workspaceFolder: params.workspaceFolder,
303
+ normalizedPath,
304
+ normalizedOriginalPath,
305
+ });
306
+ const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true);
307
+ let after = beforeResult;
308
+ const fsPath = path.join(params.workspaceFolder, normalizedPath);
309
+ try {
310
+ after = await readFileAsDiffText(fsPath);
311
+ }
312
+ catch {
313
+ after = "";
314
+ }
315
+ return {
316
+ path: normalizedPath,
317
+ originalPath: normalizedOriginalPath,
318
+ scope: params.scope,
319
+ before: beforeResult,
320
+ after,
321
+ isBinary: false,
322
+ };
323
+ }
@@ -1,6 +1,9 @@
1
1
  import path from "path";
2
2
  import { spawn } from "child_process";
3
3
  import { promises as fsp } from "fs";
4
+ function isGitUnavailableResult(result) {
5
+ return !result.ok && result.error?.code === "ENOENT";
6
+ }
4
7
  function runGit(args, cwd) {
5
8
  return new Promise((resolve) => {
6
9
  const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
@@ -28,6 +31,9 @@ function runGit(args, cwd) {
28
31
  }
29
32
  export async function resolveRepoRoot(folder, logger) {
30
33
  const result = await runGit(["rev-parse", "--show-toplevel"], folder);
34
+ if (isGitUnavailableResult(result)) {
35
+ throw new Error("Git is not installed or not available in PATH");
36
+ }
31
37
  if (!result.ok) {
32
38
  logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root");
33
39
  return { repoRoot: folder, isGitRepo: false };
@@ -38,6 +44,10 @@ export async function resolveRepoRoot(folder, logger) {
38
44
  }
39
45
  return { repoRoot, isGitRepo: true };
40
46
  }
47
+ export async function isGitAvailable(folder) {
48
+ const result = await runGit(["--version"], folder);
49
+ return result.ok || !isGitUnavailableResult(result);
50
+ }
41
51
  function parseWorktreePorcelain(output) {
42
52
  const records = [];
43
53
  const lines = output.split(/\r?\n/);
@@ -75,13 +85,20 @@ function parseWorktreePorcelain(output) {
75
85
  }
76
86
  export async function listWorktrees(params) {
77
87
  const { repoRoot, workspaceFolder, logger } = params;
78
- const rootDescriptor = { slug: "root", directory: repoRoot, kind: "root" };
79
88
  const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder);
80
89
  if (!result.ok) {
90
+ const rootDescriptor = { slug: "root", directory: repoRoot, kind: "root" };
81
91
  logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only");
82
92
  return [rootDescriptor];
83
93
  }
84
94
  const records = parseWorktreePorcelain(result.stdout);
95
+ const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot));
96
+ const rootDescriptor = {
97
+ slug: "root",
98
+ directory: repoRoot,
99
+ kind: "root",
100
+ branch: rootRecord?.branch,
101
+ };
85
102
  const worktrees = [rootDescriptor];
86
103
  const seen = new Set(["root"]);
87
104
  const normalizeSlug = (record) => {