whygraph 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/cli/commands/config.d.ts +14 -0
  4. package/dist/cli/commands/config.js +123 -0
  5. package/dist/cli/commands/down.d.ts +9 -0
  6. package/dist/cli/commands/down.js +46 -0
  7. package/dist/cli/commands/init.d.ts +17 -0
  8. package/dist/cli/commands/init.js +144 -0
  9. package/dist/cli/commands/issues.d.ts +10 -0
  10. package/dist/cli/commands/issues.js +376 -0
  11. package/dist/cli/commands/mcp.d.ts +2 -0
  12. package/dist/cli/commands/mcp.js +9 -0
  13. package/dist/cli/commands/restart.d.ts +11 -0
  14. package/dist/cli/commands/restart.js +43 -0
  15. package/dist/cli/commands/serve.d.ts +14 -0
  16. package/dist/cli/commands/serve.js +132 -0
  17. package/dist/cli/commands/server-utils.d.ts +6 -0
  18. package/dist/cli/commands/server-utils.js +94 -0
  19. package/dist/cli/commands/status.d.ts +11 -0
  20. package/dist/cli/commands/status.js +97 -0
  21. package/dist/cli/commands/up.d.ts +13 -0
  22. package/dist/cli/commands/up.js +62 -0
  23. package/dist/cli/commands/validate.d.ts +14 -0
  24. package/dist/cli/commands/validate.js +88 -0
  25. package/dist/cli/commands/viz.d.ts +7 -0
  26. package/dist/cli/commands/viz.js +97 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.js +33 -0
  29. package/dist/entity/id.d.ts +8 -0
  30. package/dist/entity/id.js +48 -0
  31. package/dist/entity/issues.d.ts +12 -0
  32. package/dist/entity/issues.js +68 -0
  33. package/dist/entity/parser.d.ts +6 -0
  34. package/dist/entity/parser.js +166 -0
  35. package/dist/entity/types.d.ts +54 -0
  36. package/dist/entity/types.js +21 -0
  37. package/dist/entity/validate.d.ts +12 -0
  38. package/dist/entity/validate.js +136 -0
  39. package/dist/entity/writer.d.ts +16 -0
  40. package/dist/entity/writer.js +142 -0
  41. package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
  42. package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
  43. package/dist/frontend/index.html +14 -0
  44. package/dist/graph/cascade.d.ts +10 -0
  45. package/dist/graph/cascade.js +49 -0
  46. package/dist/graph/decisions.d.ts +11 -0
  47. package/dist/graph/decisions.js +27 -0
  48. package/dist/graph/gaps.d.ts +10 -0
  49. package/dist/graph/gaps.js +58 -0
  50. package/dist/graph/nodes.d.ts +20 -0
  51. package/dist/graph/nodes.js +33 -0
  52. package/dist/graph/projection.d.ts +6 -0
  53. package/dist/graph/projection.js +44 -0
  54. package/dist/graph/query.d.ts +15 -0
  55. package/dist/graph/query.js +82 -0
  56. package/dist/graph/search.d.ts +2 -0
  57. package/dist/graph/search.js +23 -0
  58. package/dist/graph/supersede.d.ts +7 -0
  59. package/dist/graph/supersede.js +48 -0
  60. package/dist/graph/temporal.d.ts +13 -0
  61. package/dist/graph/temporal.js +28 -0
  62. package/dist/mcp/index.d.ts +2 -0
  63. package/dist/mcp/index.js +10 -0
  64. package/dist/mcp/server.d.ts +3 -0
  65. package/dist/mcp/server.js +340 -0
  66. package/dist/onboarding/interview.d.ts +22 -0
  67. package/dist/onboarding/interview.js +92 -0
  68. package/dist/onboarding/scan.d.ts +17 -0
  69. package/dist/onboarding/scan.js +106 -0
  70. package/dist/platform/rules.d.ts +8 -0
  71. package/dist/platform/rules.js +229 -0
  72. package/dist/server/core.d.ts +26 -0
  73. package/dist/server/core.js +111 -0
  74. package/dist/server/derived.d.ts +8 -0
  75. package/dist/server/derived.js +13 -0
  76. package/dist/server/etag.d.ts +9 -0
  77. package/dist/server/etag.js +25 -0
  78. package/dist/server/http.d.ts +13 -0
  79. package/dist/server/http.js +131 -0
  80. package/dist/server/pubsub.d.ts +12 -0
  81. package/dist/server/pubsub.js +19 -0
  82. package/dist/server/schema.d.ts +2 -0
  83. package/dist/server/schema.js +362 -0
  84. package/dist/server/stale-refs.d.ts +7 -0
  85. package/dist/server/stale-refs.js +23 -0
  86. package/dist/server/watcher.d.ts +21 -0
  87. package/dist/server/watcher.js +98 -0
  88. package/dist/server/worktree-watcher.d.ts +20 -0
  89. package/dist/server/worktree-watcher.js +79 -0
  90. package/dist/server/worktree.d.ts +22 -0
  91. package/dist/server/worktree.js +84 -0
  92. package/package.json +73 -0
@@ -0,0 +1,362 @@
1
+ import * as path from "node:path";
2
+ import { createSchema, createPubSub } from "graphql-yoga";
3
+ import { isStructuralNode, isDecisionNode } from "../entity/types.js";
4
+ import { generateId } from "../entity/id.js";
5
+ import { writeEntity } from "../entity/writer.js";
6
+ import { getContext } from "../graph/query.js";
7
+ import { getDecisions } from "../graph/decisions.js";
8
+ import { getGaps } from "../graph/gaps.js";
9
+ import { listNodes } from "../graph/nodes.js";
10
+ import { searchDecisions } from "../graph/search.js";
11
+ import { computeDerivedState } from "./derived.js";
12
+ const typeDefs = /* GraphQL */ `
13
+ type StructuralNode {
14
+ id: ID!
15
+ label: String!
16
+ name: String!
17
+ status: String!
18
+ parent: String
19
+ refs: [SymbolRef!]
20
+ description: String
21
+ created_at: String!
22
+ updated_at: String!
23
+ removed_at: String
24
+ }
25
+
26
+ type DecisionNode {
27
+ id: ID!
28
+ label: String!
29
+ title: String!
30
+ status: String!
31
+ date: String!
32
+ affects: [String!]!
33
+ tags: [String!]!
34
+ supersedes: String
35
+ context: String!
36
+ decision: String!
37
+ tradeoffs: String!
38
+ alternatives: String!
39
+ created_at: String!
40
+ updated_at: String!
41
+ removed_at: String
42
+ }
43
+
44
+ type SymbolRef {
45
+ file: String!
46
+ symbol: String
47
+ }
48
+
49
+ input SymbolRefInput {
50
+ file: String!
51
+ symbol: String
52
+ }
53
+
54
+ union Entity = StructuralNode | DecisionNode
55
+
56
+ type ContextNodeResult {
57
+ id: ID!
58
+ label: String!
59
+ name: String!
60
+ parentChain: [String!]!
61
+ }
62
+
63
+ type ContextResult {
64
+ nodes: [ContextNodeResult!]!
65
+ decisions: [DecisionNode!]!
66
+ }
67
+
68
+ type NodeSummary {
69
+ id: ID!
70
+ label: String!
71
+ name: String!
72
+ parent: String
73
+ }
74
+
75
+ type ValidationErrorEntry {
76
+ field: String!
77
+ message: String!
78
+ severity: String!
79
+ }
80
+
81
+ type EntityValidationError {
82
+ entityId: ID!
83
+ errors: [ValidationErrorEntry!]!
84
+ }
85
+
86
+ type SupersedeCandidate {
87
+ newDecisionId: ID!
88
+ existingDecisionId: ID!
89
+ sharedNodeIds: [String!]!
90
+ }
91
+
92
+ type Query {
93
+ entities: [Entity!]!
94
+ entity(id: ID!): Entity
95
+ status: ServerStatus!
96
+ context(file: String!, symbol: String): ContextResult!
97
+ decisions(status: String, tags: [String!], dateFrom: String, dateTo: String): [DecisionNode!]!
98
+ gaps(limit: Int): [StructuralNode!]!
99
+ nodes(label: String, parent: String, search: String): [NodeSummary!]!
100
+ search(query: String!): [DecisionNode!]!
101
+ validationErrors: [EntityValidationError!]!
102
+ supersedeCandidates: [SupersedeCandidate!]!
103
+ }
104
+
105
+ type ServerStatus {
106
+ running: Boolean!
107
+ entityCount: Int!
108
+ nodeCount: Int!
109
+ decisionCount: Int!
110
+ }
111
+
112
+ type Mutation {
113
+ ping: Boolean!
114
+ createNode(
115
+ label: String!
116
+ name: String!
117
+ parent: String
118
+ refs: [SymbolRefInput!]
119
+ description: String
120
+ ): StructuralNode!
121
+ createDecision(
122
+ title: String!
123
+ date: String!
124
+ affects: [String!]!
125
+ tags: [String!]!
126
+ context: String!
127
+ decision: String!
128
+ tradeoffs: String!
129
+ alternatives: String!
130
+ supersedes: String
131
+ ): DecisionNode!
132
+ updateEntity(
133
+ id: ID!
134
+ status: String
135
+ removed_at: String
136
+ refs: [SymbolRefInput!]
137
+ ): Entity!
138
+ }
139
+
140
+ enum EntityChangeType {
141
+ CREATED
142
+ UPDATED
143
+ DELETED
144
+ INITIAL_SNAPSHOT
145
+ }
146
+
147
+ type EntityChangeEvent {
148
+ type: EntityChangeType!
149
+ entities: [Entity!]
150
+ entity: Entity
151
+ entityId: String
152
+ }
153
+
154
+ type Subscription {
155
+ entityChanged(includeInitial: Boolean): EntityChangeEvent!
156
+ }
157
+ `;
158
+ export function buildSchema(core) {
159
+ const yogaPubSub = createPubSub();
160
+ // Wire ServerCore's PubSub to Yoga's PubSub for subscriptions
161
+ core.pubsub.subscribe((event) => {
162
+ if (event.type === "entity_created") {
163
+ yogaPubSub.publish("entityChanged", {
164
+ type: "CREATED",
165
+ entity: event.entity,
166
+ entityId: event.entityId,
167
+ });
168
+ }
169
+ else if (event.type === "entity_updated") {
170
+ yogaPubSub.publish("entityChanged", {
171
+ type: "UPDATED",
172
+ entity: event.entity,
173
+ entityId: event.entityId,
174
+ });
175
+ /* v8 ignore next 5 */
176
+ }
177
+ else if (event.type === "entity_deleted") {
178
+ yogaPubSub.publish("entityChanged", {
179
+ type: "DELETED",
180
+ entityId: event.entityId,
181
+ });
182
+ }
183
+ });
184
+ return createSchema({
185
+ typeDefs,
186
+ resolvers: {
187
+ Entity: {
188
+ __resolveType(obj) {
189
+ if (isDecisionNode(obj))
190
+ return "DecisionNode";
191
+ return "StructuralNode";
192
+ },
193
+ },
194
+ Query: {
195
+ entities: () => core.getAllEntities(),
196
+ entity: (_, args) => core.getEntity(args.id) ?? null,
197
+ status: () => {
198
+ const all = core.getAllEntities();
199
+ const decisions = all.filter(isDecisionNode);
200
+ const nodes = all.filter(isStructuralNode);
201
+ return {
202
+ running: true,
203
+ entityCount: all.length,
204
+ nodeCount: nodes.length,
205
+ decisionCount: decisions.length,
206
+ };
207
+ },
208
+ context: (_, args) => {
209
+ const result = getContext(core.getGraph(), args.file, args.symbol ?? undefined);
210
+ return result;
211
+ },
212
+ decisions: (_, args) => {
213
+ const filters = {};
214
+ if (args.status !== undefined && args.status !== null) {
215
+ filters.status = args.status;
216
+ }
217
+ if (args.tags !== undefined && args.tags !== null) {
218
+ filters.tags = args.tags;
219
+ }
220
+ if (args.dateFrom !== undefined && args.dateFrom !== null) {
221
+ filters.dateFrom = args.dateFrom;
222
+ }
223
+ if (args.dateTo !== undefined && args.dateTo !== null) {
224
+ filters.dateTo = args.dateTo;
225
+ }
226
+ return getDecisions(core.getGraph(), filters);
227
+ },
228
+ gaps: (_, args) => {
229
+ return getGaps(core.getGraph(), args.limit ?? undefined);
230
+ },
231
+ nodes: (_, args) => {
232
+ const filters = {};
233
+ if (args.label !== undefined && args.label !== null) {
234
+ filters.label = args.label;
235
+ }
236
+ if (args.parent !== undefined && args.parent !== null) {
237
+ filters.parent = args.parent;
238
+ }
239
+ if (args.search !== undefined && args.search !== null) {
240
+ filters.search = args.search;
241
+ }
242
+ return listNodes(core.getGraph(), filters);
243
+ },
244
+ search: (_, args) => {
245
+ return searchDecisions(core.getEntityMap(), args.query);
246
+ },
247
+ validationErrors: () => {
248
+ return core.getIssues().map((issue) => ({
249
+ entityId: issue.entityId,
250
+ errors: issue.errors,
251
+ }));
252
+ },
253
+ supersedeCandidates: () => {
254
+ const derived = computeDerivedState(core.getEntityMap());
255
+ return derived.supersedeCandidates;
256
+ },
257
+ },
258
+ Mutation: {
259
+ ping: () => true,
260
+ createNode: (_, args) => {
261
+ const now = new Date().toISOString();
262
+ const id = generateId();
263
+ const graphDir = path.join(core.getWhygraphDir(), "graph");
264
+ const node = {
265
+ id,
266
+ label: args.label,
267
+ name: args.name,
268
+ status: "active",
269
+ created_at: now,
270
+ updated_at: now,
271
+ };
272
+ if (args.parent !== undefined)
273
+ node.parent = args.parent;
274
+ if (args.refs !== undefined)
275
+ node.refs = args.refs;
276
+ if (args.description !== undefined)
277
+ node.description = args.description;
278
+ writeEntity(graphDir, node);
279
+ core.addOrUpdateEntity(id, node);
280
+ return node;
281
+ },
282
+ createDecision: (_, args) => {
283
+ const now = new Date().toISOString();
284
+ const id = generateId();
285
+ const graphDir = path.join(core.getWhygraphDir(), "graph");
286
+ const decision = {
287
+ id,
288
+ label: "Decision",
289
+ title: args.title,
290
+ status: "active",
291
+ date: args.date,
292
+ affects: args.affects,
293
+ tags: args.tags,
294
+ context: args.context,
295
+ decision: args.decision,
296
+ tradeoffs: args.tradeoffs,
297
+ alternatives: args.alternatives,
298
+ created_at: now,
299
+ updated_at: now,
300
+ };
301
+ if (args.supersedes !== undefined)
302
+ decision.supersedes = args.supersedes;
303
+ writeEntity(graphDir, decision);
304
+ core.addOrUpdateEntity(id, decision);
305
+ return decision;
306
+ },
307
+ updateEntity: (_, args) => {
308
+ const existing = core.getEntity(args.id);
309
+ if (!existing) {
310
+ throw new Error(`Entity not found: ${args.id}`);
311
+ }
312
+ const now = new Date().toISOString();
313
+ const graphDir = path.join(core.getWhygraphDir(), "graph");
314
+ const updated = { ...existing, updated_at: now };
315
+ if (args.status !== undefined) {
316
+ updated.status = args.status;
317
+ }
318
+ if (args.removed_at !== undefined) {
319
+ updated.removed_at = args.removed_at;
320
+ }
321
+ if (args.refs !== undefined && isStructuralNode(updated)) {
322
+ updated.refs = args.refs;
323
+ }
324
+ writeEntity(graphDir, updated);
325
+ core.addOrUpdateEntity(args.id, updated);
326
+ return updated;
327
+ },
328
+ },
329
+ Subscription: {
330
+ entityChanged: {
331
+ subscribe: (_, args) => {
332
+ const baseIterator = yogaPubSub.subscribe("entityChanged");
333
+ if (!args.includeInitial) {
334
+ return baseIterator;
335
+ }
336
+ // Wrap with initial snapshot
337
+ async function* withInitialSnapshot() {
338
+ const allEntities = core.getAllEntities();
339
+ yield {
340
+ entityChanged: {
341
+ type: "INITIAL_SNAPSHOT",
342
+ entities: allEntities,
343
+ },
344
+ };
345
+ for await (const event of baseIterator) {
346
+ yield event;
347
+ }
348
+ }
349
+ return withInitialSnapshot();
350
+ },
351
+ resolve: (event) => {
352
+ // Events from yogaPubSub come wrapped, initial snapshot comes unwrapped
353
+ if ("entityChanged" in event) {
354
+ return event.entityChanged;
355
+ }
356
+ return event;
357
+ },
358
+ },
359
+ },
360
+ },
361
+ });
362
+ }
@@ -0,0 +1,7 @@
1
+ import type { Entity, SymbolRef } from "../entity/types.js";
2
+ export interface StaleRef {
3
+ entityId: string;
4
+ entityName: string;
5
+ ref: SymbolRef;
6
+ }
7
+ export declare function detectStaleRefs(entities: Map<string, Entity>, basePath: string): StaleRef[];
@@ -0,0 +1,23 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isStructuralNode } from "../entity/types.js";
4
+ export function detectStaleRefs(entities, basePath) {
5
+ const staleRefs = [];
6
+ for (const [id, entity] of entities) {
7
+ if (!isStructuralNode(entity))
8
+ continue;
9
+ if (!entity.refs || entity.refs.length === 0)
10
+ continue;
11
+ for (const ref of entity.refs) {
12
+ const absolutePath = path.resolve(basePath, ref.file);
13
+ if (!fs.existsSync(absolutePath)) {
14
+ staleRefs.push({
15
+ entityId: id,
16
+ entityName: entity.name,
17
+ ref,
18
+ });
19
+ }
20
+ }
21
+ }
22
+ return staleRefs;
23
+ }
@@ -0,0 +1,21 @@
1
+ export interface WatcherEvent {
2
+ type: "created" | "updated" | "deleted";
3
+ filePath: string;
4
+ entityId?: string;
5
+ }
6
+ export type WatcherCallback = (events: WatcherEvent[]) => void;
7
+ export declare class FileWatcher {
8
+ private readonly dirPath;
9
+ private watcher;
10
+ private pendingEvents;
11
+ private debounceTimer;
12
+ private callback;
13
+ private knownFiles;
14
+ constructor(dirPath: string);
15
+ start(callback: WatcherCallback): void;
16
+ stop(): Promise<void>;
17
+ private handleFileChange;
18
+ private enqueue;
19
+ private scheduleBatch;
20
+ private flush;
21
+ }
@@ -0,0 +1,98 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { watch } from "chokidar";
3
+ import { parseEntity } from "../entity/parser.js";
4
+ import { parseFilename } from "../entity/id.js";
5
+ export class FileWatcher {
6
+ dirPath;
7
+ watcher = null;
8
+ pendingEvents = [];
9
+ debounceTimer = null;
10
+ callback = null;
11
+ knownFiles = new Set();
12
+ constructor(dirPath) {
13
+ this.dirPath = dirPath;
14
+ }
15
+ start(callback) {
16
+ this.callback = callback;
17
+ this.watcher = watch(this.dirPath, {
18
+ ignoreInitial: true,
19
+ awaitWriteFinish: false,
20
+ });
21
+ this.watcher.on("add", (filePath) => {
22
+ if (!filePath.endsWith(".md"))
23
+ return;
24
+ this.knownFiles.add(filePath);
25
+ this.handleFileChange(filePath, "created");
26
+ });
27
+ this.watcher.on("change", (filePath) => {
28
+ /* v8 ignore next 1 */
29
+ if (!filePath.endsWith(".md"))
30
+ return;
31
+ this.handleFileChange(filePath, "updated");
32
+ });
33
+ this.watcher.on("unlink", (filePath) => {
34
+ /* v8 ignore next 1 */
35
+ if (!filePath.endsWith(".md"))
36
+ return;
37
+ this.knownFiles.delete(filePath);
38
+ /* v8 ignore next 1 */
39
+ const entityId = parseFilename(filePath) ?? undefined;
40
+ this.enqueue({
41
+ type: "deleted",
42
+ filePath,
43
+ entityId,
44
+ });
45
+ });
46
+ }
47
+ async stop() {
48
+ /* v8 ignore next 4 */
49
+ if (this.debounceTimer) {
50
+ clearTimeout(this.debounceTimer);
51
+ this.debounceTimer = null;
52
+ }
53
+ if (this.watcher) {
54
+ await this.watcher.close();
55
+ this.watcher = null;
56
+ }
57
+ this.pendingEvents = [];
58
+ this.callback = null;
59
+ this.knownFiles.clear();
60
+ }
61
+ handleFileChange(filePath, type) {
62
+ fs.readFile(filePath, "utf-8")
63
+ .then((content) => {
64
+ const entity = parseEntity(content);
65
+ this.enqueue({
66
+ type,
67
+ filePath,
68
+ entityId: entity?.id,
69
+ });
70
+ })
71
+ /* v8 ignore start */
72
+ .catch(() => {
73
+ // File may have been deleted between event and read — ignore
74
+ });
75
+ /* v8 ignore stop */
76
+ }
77
+ enqueue(event) {
78
+ this.pendingEvents.push(event);
79
+ this.scheduleBatch();
80
+ }
81
+ scheduleBatch() {
82
+ if (this.debounceTimer) {
83
+ clearTimeout(this.debounceTimer);
84
+ }
85
+ this.debounceTimer = setTimeout(() => {
86
+ this.flush();
87
+ }, 100);
88
+ }
89
+ flush() {
90
+ /* v8 ignore next 1 */
91
+ if (this.pendingEvents.length === 0 || !this.callback)
92
+ return;
93
+ const events = this.pendingEvents;
94
+ this.pendingEvents = [];
95
+ this.debounceTimer = null;
96
+ this.callback(events);
97
+ }
98
+ }
@@ -0,0 +1,20 @@
1
+ import { ServerCore } from "./core.js";
2
+ import { type DetectDeps, type WorktreeInfo } from "./worktree.js";
3
+ export interface WorktreeWatcherDeps {
4
+ detectWorktrees: (repoDir: string, deps?: DetectDeps) => Promise<WorktreeInfo[]>;
5
+ }
6
+ export declare class WorktreeWatcher {
7
+ private readonly core;
8
+ private readonly repoDir;
9
+ private readonly watchers;
10
+ private readonly worktreeEntities;
11
+ private readonly dirtyEntities;
12
+ private readonly deps;
13
+ constructor(core: ServerCore, repoDir: string, deps?: WorktreeWatcherDeps);
14
+ startWatching(): Promise<void>;
15
+ stop(): Promise<void>;
16
+ getWatchedWorktrees(): string[];
17
+ isWorktreeEntity(id: string): boolean;
18
+ getDirtyIds(): string[];
19
+ private handleEvents;
20
+ }
@@ -0,0 +1,79 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { FileWatcher } from "./watcher.js";
3
+ import { detectWorktrees } from "./worktree.js";
4
+ import { parseEntity } from "../entity/parser.js";
5
+ import { computeETag } from "./etag.js";
6
+ export class WorktreeWatcher {
7
+ core;
8
+ repoDir;
9
+ watchers = new Map();
10
+ worktreeEntities = new Set();
11
+ dirtyEntities = new Set();
12
+ deps;
13
+ constructor(core, repoDir, deps) {
14
+ this.core = core;
15
+ this.repoDir = repoDir;
16
+ this.deps = deps ?? { detectWorktrees };
17
+ }
18
+ async startWatching() {
19
+ const worktrees = await this.deps.detectWorktrees(this.repoDir);
20
+ for (const wt of worktrees) {
21
+ const watcher = new FileWatcher(wt.graphDir);
22
+ this.watchers.set(wt.path, watcher);
23
+ watcher.start((events) => {
24
+ this.handleEvents(events, wt);
25
+ });
26
+ }
27
+ }
28
+ async stop() {
29
+ const stops = Array.from(this.watchers.values()).map((w) => w.stop());
30
+ await Promise.all(stops);
31
+ this.watchers.clear();
32
+ }
33
+ getWatchedWorktrees() {
34
+ return Array.from(this.watchers.keys());
35
+ }
36
+ isWorktreeEntity(id) {
37
+ return this.worktreeEntities.has(id);
38
+ }
39
+ getDirtyIds() {
40
+ return Array.from(this.dirtyEntities);
41
+ }
42
+ handleEvents(events, wt) {
43
+ for (const event of events) {
44
+ if (event.type === "deleted" && event.entityId) {
45
+ this.worktreeEntities.delete(event.entityId);
46
+ this.dirtyEntities.delete(event.entityId);
47
+ this.core.removeEntity(event.entityId);
48
+ /* v8 ignore next 1 */
49
+ }
50
+ else if (event.entityId) {
51
+ this.worktreeEntities.add(event.entityId);
52
+ // Read the file to get the full entity
53
+ fs.readFile(event.filePath, "utf-8")
54
+ .then((content) => {
55
+ const entity = parseEntity(content);
56
+ /* v8 ignore next 1 */
57
+ if (entity) {
58
+ // Track dirty state via ETag comparison with main entity
59
+ const mainEntity = this.core.getEntity(entity.id);
60
+ if (mainEntity && computeETag(entity) === computeETag(mainEntity)) {
61
+ this.dirtyEntities.delete(entity.id);
62
+ }
63
+ else {
64
+ this.dirtyEntities.add(entity.id);
65
+ }
66
+ // Mark entity as coming from a worktree
67
+ const tagged = { ...entity, _worktree: wt.path };
68
+ this.core.addOrUpdateEntity(entity.id, tagged);
69
+ }
70
+ })
71
+ /* v8 ignore start */
72
+ .catch(() => {
73
+ // File may have been deleted between event and read
74
+ });
75
+ /* v8 ignore stop */
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,22 @@
1
+ export interface WorktreeInfo {
2
+ path: string;
3
+ branch: string;
4
+ graphDir: string;
5
+ }
6
+ export interface ParsedWorktree {
7
+ path: string;
8
+ branch: string | null;
9
+ isMain: boolean;
10
+ }
11
+ /** Dependency injection for testing. */
12
+ export interface DetectDeps {
13
+ execFile: (cmd: string, args: string[], opts: {
14
+ cwd: string;
15
+ }) => Promise<{
16
+ stdout: string;
17
+ stderr: string;
18
+ }>;
19
+ access: (p: string) => Promise<void>;
20
+ }
21
+ export declare function parseWorktreeList(output: string): ParsedWorktree[];
22
+ export declare function detectWorktrees(repoDir: string, deps?: DetectDeps): Promise<WorktreeInfo[]>;