@jkwd/inbase 0.1.10 → 0.1.12

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.
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import crypto from 'node:crypto'
3
4
  import { spawnSync } from 'node:child_process'
4
5
  import {
5
6
  accumulatePatchAdditions,
@@ -10,6 +11,7 @@ import {
10
11
  foldersFromFileIds,
11
12
  parseUnifiedPatch,
12
13
  } from './patch-lib.mjs'
14
+ import { diffSourceTrees, snapshotSourceTree } from './tree-diff.mjs'
13
15
 
14
16
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
15
17
  const CONNECTED_TTL_MS = 15_000
@@ -39,6 +41,19 @@ function atomicWrite(file, contents) {
39
41
  fs.renameSync(temporary, file)
40
42
  }
41
43
 
44
+ function featureName(value) {
45
+ const trimmed = typeof value === 'string' ? value.trim() : ''
46
+ return trimmed
47
+ }
48
+
49
+ function sessionName(value) {
50
+ return featureName(value)
51
+ }
52
+
53
+ function resolvedSessionName(manifest) {
54
+ return sessionName(manifest?.name) || sessionName(manifest?.feature)
55
+ }
56
+
42
57
  export function sessionPaths(dataDir, sessionId) {
43
58
  const safeId = assertSessionId(sessionId)
44
59
  const root = path.join(dataDir, 'diff-sessions', safeId)
@@ -49,6 +64,7 @@ export function sessionPaths(dataDir, sessionId) {
49
64
  blueprint: path.join(root, 'blueprint.json'),
50
65
  baseline: path.join(root, 'baseline.json'),
51
66
  baselineFiles: path.join(root, 'baseline'),
67
+ preStep: path.join(root, 'pre-step'),
52
68
  stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
53
69
  }
54
70
  }
@@ -124,6 +140,38 @@ function connectionFile(dataDir, sessionId) {
124
140
  return path.join(sessionPaths(dataDir, sessionId).root, 'connected.json')
125
141
  }
126
142
 
143
+ function ackFile(dataDir, sessionId) {
144
+ return path.join(sessionPaths(dataDir, sessionId).root, 'ack.json')
145
+ }
146
+
147
+ export function recordSessionAck(dataDir, sessionId, kind, detail = '') {
148
+ const safeId = assertSessionId(sessionId)
149
+ if (isSessionStopped(dataDir, safeId) && kind !== 'stopped' && kind !== 'finished') {
150
+ return null
151
+ }
152
+ const payload = {
153
+ kind: String(kind),
154
+ detail: String(detail ?? ''),
155
+ at: new Date().toISOString(),
156
+ }
157
+ try {
158
+ atomicWrite(ackFile(dataDir, safeId), `${JSON.stringify(payload, null, 2)}\n`)
159
+ } catch {
160
+ return null
161
+ }
162
+ return payload
163
+ }
164
+
165
+ function readSessionAck(dataDir, sessionId) {
166
+ const value = readJson(ackFile(dataDir, sessionId), null)
167
+ if (!value || typeof value.kind !== 'string' || value.kind.trim() === '') return null
168
+ return {
169
+ kind: value.kind,
170
+ detail: typeof value.detail === 'string' ? value.detail : '',
171
+ at: typeof value.at === 'string' ? value.at : null,
172
+ }
173
+ }
174
+
127
175
  function isFreshTimestamp(value, now = Date.now()) {
128
176
  if (typeof value !== 'string') return false
129
177
  const at = Date.parse(value)
@@ -137,6 +185,11 @@ export function touchSessionConnection(dataDir, sessionId) {
137
185
  connectionFile(dataDir, safeId),
138
186
  `${JSON.stringify({ sessionId: safeId, connectedAt: new Date().toISOString() }, null, 2)}\n`,
139
187
  )
188
+ const manifest = readManifest(dataDir, safeId)
189
+ if (manifest?.awaitingAttach) {
190
+ manifest.awaitingAttach = false
191
+ writeManifest(dataDir, manifest)
192
+ }
140
193
  }
141
194
 
142
195
  function waiterSessionIds() {
@@ -196,10 +249,12 @@ export function isSessionConnected(
196
249
  const safeId = assertSessionId(sessionId)
197
250
  const manifest = readManifest(dataDir, safeId)
198
251
  if (isTerminalSession(manifest)) return false
199
- if (isGeneratingPhase(manifest.phase)) return true
200
- if (waiterIds.has(safeId)) return true
201
252
  const connected = readJson(connectionFile(dataDir, safeId), null)
202
- if (isFreshTimestamp(connected?.connectedAt)) return true
253
+ const heartbeat =
254
+ waiterIds.has(safeId) || isFreshTimestamp(connected?.connectedAt)
255
+ if (manifest.awaitingAttach) return heartbeat
256
+ if (isGeneratingPhase(manifest.phase)) return true
257
+ if (heartbeat) return true
203
258
  return isFreshTimestamp(manifest.updatedAt) || isFreshTimestamp(manifest.createdAt)
204
259
  }
205
260
 
@@ -327,6 +382,8 @@ export function readManifest(dataDir, sessionId) {
327
382
  value.workStartedAt ??= null
328
383
  }
329
384
  if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
385
+ value.initialInstruction =
386
+ typeof value.initialInstruction === 'string' ? value.initialInstruction : null
330
387
  return value
331
388
  }
332
389
 
@@ -500,7 +557,12 @@ export function sessionIntent(
500
557
  showMap: previewVisible,
501
558
  status: activeView ? phaseStatus ?? historicalStatus : historicalStatus,
502
559
  phase: manifest.phase,
560
+ name: resolvedSessionName(manifest) || null,
503
561
  feature: manifest.feature,
562
+ initialInstruction:
563
+ typeof manifest.initialInstruction === 'string'
564
+ ? manifest.initialInstruction
565
+ : null,
504
566
  steps: manifest.steps,
505
567
  step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
506
568
  stepByStep: isStepByStep(manifest),
@@ -519,11 +581,17 @@ export function sessionIntent(
519
581
  isActiveDiff: Boolean(selected && selected.id === manifest.activeDiffId),
520
582
  preview: previewVisible,
521
583
  working:
522
- manifest.phase === 'preparing' ||
523
- manifest.phase === 'working' ||
524
- manifest.phase === 'replanning',
584
+ !manifest.awaitingAttach &&
585
+ (manifest.phase === 'preparing' ||
586
+ manifest.phase === 'working' ||
587
+ manifest.phase === 'replanning'),
525
588
  stalledWait: isStalledWorking(manifest, waiterIds, sessionId),
526
589
  llmIdle: !isSessionConnected(dataDir, sessionId, waiterIds),
590
+ awaitingAttach:
591
+ Boolean(manifest.awaitingAttach) &&
592
+ !isSessionConnected(dataDir, sessionId, waiterIds),
593
+ listening: waiterIds.has(sessionId),
594
+ lastAck: readSessionAck(dataDir, sessionId),
527
595
  creationMode: sessionAllowsPlacement(manifest),
528
596
  canEnterBlueprint,
529
597
  blueprintSessionId: null,
@@ -738,11 +806,6 @@ function planSteps(titles, startAt = 1) {
738
806
  })
739
807
  }
740
808
 
741
- function featureName(value) {
742
- const trimmed = typeof value === 'string' ? value.trim() : ''
743
- return trimmed
744
- }
745
-
746
809
  export function isStepByStep(manifest) {
747
810
  return manifest?.stepByStep !== false
748
811
  }
@@ -774,7 +837,12 @@ export function startSession(dataDir, input) {
774
837
  const sessionId = assertSessionId(input.sessionId)
775
838
  clearStoppedMarker(dataDir, sessionId)
776
839
  const existing = readManifest(dataDir, sessionId)
840
+ const name = sessionName(input.name) || sessionName(input.feature)
777
841
  if (existing) {
842
+ if (name && existing.name !== name) {
843
+ existing.name = name
844
+ writeManifest(dataDir, existing)
845
+ }
778
846
  focusSession(dataDir, sessionId)
779
847
  return existing
780
848
  }
@@ -783,7 +851,8 @@ export function startSession(dataDir, input) {
783
851
  const manifest = {
784
852
  version: 2,
785
853
  sessionId,
786
- feature: featureName(input.feature),
854
+ name,
855
+ feature: featureName(input.feature) || name,
787
856
  steps: [],
788
857
  status: 'active',
789
858
  phase: 'blueprint_ask',
@@ -791,6 +860,7 @@ export function startSession(dataDir, input) {
791
860
  currentStep: 1,
792
861
  activeDiffId: null,
793
862
  pendingInstruction: null,
863
+ initialInstruction: null,
794
864
  workStartedAt: null,
795
865
  createdAt: now,
796
866
  updatedAt: now,
@@ -802,6 +872,115 @@ export function startSession(dataDir, input) {
802
872
  return manifest
803
873
  }
804
874
 
875
+ export function setupSession(dataDir, input = {}) {
876
+ const sessionId = input.sessionId
877
+ ? assertSessionId(input.sessionId)
878
+ : generateVisualizerSessionId(dataDir)
879
+ const existing = readManifest(dataDir, sessionId)
880
+ if (existing && !isTerminalSession(existing)) {
881
+ throw new Error(`Session ${sessionId} already exists`)
882
+ }
883
+ if (existing) {
884
+ discardStoredSession(dataDir, sessionId, null, { restore: false })
885
+ }
886
+ clearStoppedMarker(dataDir, sessionId)
887
+ const now = new Date().toISOString()
888
+ const name = sessionName(input.name)
889
+ const manifest = {
890
+ version: 2,
891
+ sessionId,
892
+ name,
893
+ feature: featureName(input.feature) || name,
894
+ steps: [],
895
+ status: 'active',
896
+ phase: 'blueprint',
897
+ awaitingAttach: true,
898
+ stepByStep: true,
899
+ currentStep: 1,
900
+ activeDiffId: null,
901
+ pendingInstruction: null,
902
+ initialInstruction: null,
903
+ workStartedAt: null,
904
+ createdAt: now,
905
+ updatedAt: now,
906
+ diffs: [],
907
+ }
908
+ writeManifest(dataDir, manifest)
909
+ writeBlueprint(dataDir, sessionId, {
910
+ ...emptyBlueprint(),
911
+ enabled: true,
912
+ sent: false,
913
+ })
914
+ focusSession(dataDir, sessionId)
915
+ return manifest
916
+ }
917
+
918
+ export function setInitialInstruction(dataDir, sessionId, instruction) {
919
+ const manifest = requireManifest(dataDir, sessionId)
920
+ if (isTerminalSession(manifest)) {
921
+ throw sessionStoppedError(sessionId)
922
+ }
923
+ const text = typeof instruction === 'string' ? instruction : ''
924
+ if (text.length > 4000) {
925
+ throw new Error('instruction must be a string up to 4000 characters')
926
+ }
927
+ const next = text.trim() === '' ? null : text
928
+ if ((manifest.initialInstruction ?? null) === next) return manifest
929
+ manifest.initialInstruction = next
930
+ writeManifest(dataDir, manifest)
931
+ return manifest
932
+ }
933
+
934
+ function generateVisualizerSessionId(dataDir) {
935
+ for (let attempt = 0; attempt < 8; attempt += 1) {
936
+ const sessionId = `viz-${crypto.randomBytes(6).toString('hex')}`
937
+ if (!readManifest(dataDir, sessionId) && !isSessionStopped(dataDir, sessionId)) {
938
+ return sessionId
939
+ }
940
+ }
941
+ throw new Error('Could not allocate a visualizer session id')
942
+ }
943
+
944
+ export function readAttachedSession(dataDir) {
945
+ for (const sessionId of listOpenSessionIds(dataDir)) {
946
+ const manifest = readManifest(dataDir, sessionId)
947
+ if (manifest?.awaitingAttach === false) return sessionId
948
+ }
949
+ return null
950
+ }
951
+
952
+ export function attachSession(dataDir, sessionId) {
953
+ const safeId = sessionId
954
+ ? assertSessionId(sessionId)
955
+ : readActiveSession(dataDir)
956
+ if (!safeId) {
957
+ throw new Error(
958
+ 'No visualizer session is focused. Click Setup LLM session in the map, then /inbase.',
959
+ )
960
+ }
961
+ const manifest = requireManifest(
962
+ dataDir,
963
+ safeId,
964
+ `No visualizer session ${safeId}. Click Setup LLM session in the map, then /inbase.`,
965
+ )
966
+ if (isTerminalSession(manifest)) {
967
+ throw sessionStoppedError(safeId)
968
+ }
969
+ const attached = readAttachedSession(dataDir)
970
+ if (attached && attached !== safeId) {
971
+ const other = readManifest(dataDir, attached)
972
+ const label = resolvedSessionName(other) || attached
973
+ throw new Error(
974
+ `An LLM is already attached to ${label}. Stop that session before attaching another.`,
975
+ )
976
+ }
977
+ focusSession(dataDir, safeId)
978
+ touchSessionConnection(dataDir, safeId)
979
+ recordSessionAck(dataDir, safeId, 'attached', resolvedSessionName(manifest) || safeId)
980
+ maybeStartVisualizerHandshake(dataDir, safeId)
981
+ return readManifest(dataDir, safeId) ?? manifest
982
+ }
983
+
805
984
  export function answerBlueprint(dataDir, sessionId, enabled) {
806
985
  const manifest = requireManifest(dataDir, sessionId)
807
986
  if (manifest.phase !== 'blueprint_ask') {
@@ -849,14 +1028,36 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
849
1028
  throw new Error(`Session ${safeId} is not in blueprint mode`)
850
1029
  }
851
1030
  const current = readBlueprint(dataDir, safeId)
852
- writeBlueprint(dataDir, safeId, {
853
- enabled: true,
854
- sent: true,
1031
+ const next = {
855
1032
  userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
856
1033
  userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
857
1034
  addedFunctions: input.addedFunctions ?? current.addedFunctions,
858
1035
  addedVariables: input.addedVariables ?? current.addedVariables,
859
1036
  addedImports: input.addedImports ?? current.addedImports,
1037
+ }
1038
+ writeBlueprint(dataDir, safeId, {
1039
+ ...next,
1040
+ enabled: blueprintHasContent(next),
1041
+ sent: true,
1042
+ })
1043
+ manifest.phase = 'preparing'
1044
+ manifest.workStartedAt = new Date().toISOString()
1045
+ writeManifest(dataDir, manifest)
1046
+ releaseBlueprintSession(dataDir, safeId)
1047
+ return manifest
1048
+ }
1049
+
1050
+ export function maybeStartVisualizerHandshake(dataDir, sessionId) {
1051
+ const safeId = assertSessionId(sessionId)
1052
+ const manifest = requireManifest(dataDir, safeId)
1053
+ if (manifest.phase !== 'blueprint_ask' && manifest.phase !== 'blueprint') {
1054
+ return manifest
1055
+ }
1056
+ const current = readBlueprint(dataDir, safeId)
1057
+ writeBlueprint(dataDir, safeId, {
1058
+ ...current,
1059
+ enabled: blueprintHasContent(current),
1060
+ sent: true,
860
1061
  })
861
1062
  manifest.phase = 'preparing'
862
1063
  manifest.workStartedAt = new Date().toISOString()
@@ -883,6 +1084,7 @@ export function reportPlan(dataDir, input) {
883
1084
  const manifest = existing ?? {
884
1085
  version: 2,
885
1086
  sessionId,
1087
+ name: sessionName(input.name) || sessionName(input.feature),
886
1088
  feature: input.feature,
887
1089
  steps: [],
888
1090
  status: 'active',
@@ -891,11 +1093,15 @@ export function reportPlan(dataDir, input) {
891
1093
  currentStep: 1,
892
1094
  activeDiffId: null,
893
1095
  pendingInstruction: null,
1096
+ initialInstruction: null,
894
1097
  workStartedAt: null,
895
1098
  createdAt: now,
896
1099
  updatedAt: now,
897
1100
  diffs: [],
898
1101
  }
1102
+ if (!sessionName(manifest.name)) {
1103
+ manifest.name = sessionName(input.feature)
1104
+ }
899
1105
  manifest.feature = input.feature
900
1106
  manifest.steps = planSteps(input.stepTitles)
901
1107
  manifest.status = 'active'
@@ -904,7 +1110,13 @@ export function reportPlan(dataDir, input) {
904
1110
  manifest.workStartedAt = null
905
1111
  writeManifest(dataDir, manifest)
906
1112
  focusSession(dataDir, sessionId)
907
- return autoAdvance(dataDir, sessionId)
1113
+ recordSessionAck(
1114
+ dataDir,
1115
+ sessionId,
1116
+ 'plan',
1117
+ `${manifest.steps.length} step(s)`,
1118
+ )
1119
+ return autoAdvance(dataDir, sessionId, input.targetRoot)
908
1120
  }
909
1121
 
910
1122
  if (existing.phase !== 'replanning') {
@@ -921,7 +1133,13 @@ export function reportPlan(dataDir, input) {
921
1133
  existing.workStartedAt = null
922
1134
  writeManifest(dataDir, existing)
923
1135
  focusSession(dataDir, sessionId)
924
- return autoAdvance(dataDir, sessionId)
1136
+ recordSessionAck(
1137
+ dataDir,
1138
+ sessionId,
1139
+ 'plan',
1140
+ `${existing.steps.filter((step) => step.index >= startAt).length} step(s)`,
1141
+ )
1142
+ return autoAdvance(dataDir, sessionId, input.targetRoot)
925
1143
  }
926
1144
 
927
1145
  export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
@@ -961,9 +1179,33 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
961
1179
  manifest.phase = 'working'
962
1180
  manifest.workStartedAt = new Date().toISOString()
963
1181
  writeManifest(dataDir, manifest)
1182
+ const title = manifest.steps.find((item) => item.index === step)?.title
1183
+ recordSessionAck(
1184
+ dataDir,
1185
+ sessionId,
1186
+ 'invoke',
1187
+ title ? `step ${step} — ${title}` : `step ${step}`,
1188
+ )
1189
+ if (targetRoot) snapshotPreStep(dataDir, sessionId, targetRoot)
964
1190
  return manifest
965
1191
  }
966
1192
 
1193
+ export function snapshotPreStep(dataDir, sessionId, targetRoot) {
1194
+ const { preStep } = sessionPaths(dataDir, sessionId)
1195
+ snapshotSourceTree(targetRoot, preStep)
1196
+ return preStep
1197
+ }
1198
+
1199
+ export function readLiveDiff(dataDir, sessionId, targetRoot) {
1200
+ const { preStep } = sessionPaths(dataDir, sessionId)
1201
+ if (!fs.existsSync(preStep)) {
1202
+ throw new Error(
1203
+ `Step ${sessionId} has no invoke snapshot. Wait for VISUAL_CODER_EXECUTE before recording file changes.`,
1204
+ )
1205
+ }
1206
+ return diffSourceTrees(preStep, targetRoot)
1207
+ }
1208
+
967
1209
  export function appendDiff(dataDir, targetRoot, input) {
968
1210
  const sessionId = assertSessionId(input.sessionId)
969
1211
  const manifest = requireManifest(
@@ -990,12 +1232,19 @@ export function appendDiff(dataDir, targetRoot, input) {
990
1232
  throw new Error(`The next diff must implement step ${parent.step + 1}`)
991
1233
  }
992
1234
 
993
- validateContinuation(dataDir, manifest, targetRoot, input.patchText)
1235
+ const patchText = input.patchText ?? readLiveDiff(dataDir, sessionId, targetRoot)
1236
+ if (!patchText.trim()) {
1237
+ throw new Error('No file changes to record for this step')
1238
+ }
1239
+
1240
+ const snapshotRoot = sessionPaths(dataDir, sessionId).preStep
1241
+ const originRoot = fs.existsSync(snapshotRoot) ? snapshotRoot : targetRoot
1242
+ validateContinuation(dataDir, manifest, originRoot, patchText)
994
1243
  captureBaseline(
995
1244
  dataDir,
996
1245
  sessionId,
997
- targetRoot,
998
- parseUnifiedPatch(input.patchText).entries.map((entry) => entry.id),
1246
+ originRoot,
1247
+ parseUnifiedPatch(patchText).entries.map((entry) => entry.id),
999
1248
  )
1000
1249
  if (parent?.status === 'extend') parent.status = 'extended'
1001
1250
 
@@ -1016,7 +1265,7 @@ export function appendDiff(dataDir, targetRoot, input) {
1016
1265
  fs.mkdirSync(paths.diffs, { recursive: true })
1017
1266
  atomicWrite(
1018
1267
  path.join(paths.root, file),
1019
- input.patchText.endsWith('\n') ? input.patchText : `${input.patchText}\n`,
1268
+ patchText.endsWith('\n') ? patchText : `${patchText}\n`,
1020
1269
  )
1021
1270
  manifest.activeDiffId = id
1022
1271
  manifest.phase = 'review'
@@ -1180,37 +1429,6 @@ export function discardInactiveDiffSessions(
1180
1429
  return liveIds
1181
1430
  }
1182
1431
 
1183
- export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
1184
- const kept = []
1185
- for (const sessionId of listStoredSessionIds(dataDir)) {
1186
- const manifest = readManifest(dataDir, sessionId)
1187
- if (!isTerminalSession(manifest)) {
1188
- if (targetRoot && manifest.activeDiffId) {
1189
- try {
1190
- materializeDiff(dataDir, targetRoot, sessionId, manifest.activeDiffId)
1191
- } catch {
1192
- // Keep the stored diffs visible even if disk replay fails.
1193
- }
1194
- }
1195
- kept.push(sessionId)
1196
- continue
1197
- }
1198
- discardStoredSession(dataDir, sessionId, targetRoot, {
1199
- restore:
1200
- manifest?.phase === 'stopped' ||
1201
- manifest?.status === 'rejected' ||
1202
- !manifest,
1203
- })
1204
- }
1205
-
1206
- const active = readActiveSession(dataDir)
1207
- if (active && !kept.includes(active)) writeActiveSession(dataDir, null)
1208
- const locked = readBlueprintSession(dataDir)
1209
- if (locked && !kept.includes(locked)) writeBlueprintSession(dataDir, null)
1210
- unstageDiffSessionArtifacts(dataDir, targetRoot)
1211
- return kept
1212
- }
1213
-
1214
1432
  export function clearDiffSessions(dataDir, targetRoot = null) {
1215
1433
  for (const sessionId of listStoredSessionIds(dataDir)) {
1216
1434
  discardStoredSession(dataDir, sessionId, targetRoot)
@@ -1227,6 +1445,12 @@ export function clearDiffSessions(dataDir, targetRoot = null) {
1227
1445
  unstageDiffSessionArtifacts(dataDir, targetRoot)
1228
1446
  }
1229
1447
 
1448
+ export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
1449
+ // Visualizer startup never reopens an LLM session.
1450
+ clearDiffSessions(dataDir, targetRoot)
1451
+ return []
1452
+ }
1453
+
1230
1454
  export function stopSession(dataDir, sessionId, targetRoot = null) {
1231
1455
  const safeId = assertSessionId(sessionId)
1232
1456
  writeStoppedMarker(dataDir, safeId)
@@ -0,0 +1,121 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { listSourceFiles } from './scan-target.mjs'
5
+
6
+ function splitLines(text) {
7
+ if (text === '') return []
8
+ const lines = text.split('\n')
9
+ if (text.endsWith('\n')) lines.pop()
10
+ return lines
11
+ }
12
+
13
+ export function snapshotSourceTree(fromRoot, toRoot) {
14
+ if (fs.existsSync(toRoot)) fs.rmSync(toRoot, { recursive: true, force: true })
15
+ fs.mkdirSync(toRoot, { recursive: true })
16
+ for (const fileId of listSourceFiles(fromRoot)) {
17
+ const from = path.join(fromRoot, fileId)
18
+ const to = path.join(toRoot, fileId)
19
+ fs.mkdirSync(path.dirname(to), { recursive: true })
20
+ fs.copyFileSync(from, to)
21
+ }
22
+ return toRoot
23
+ }
24
+
25
+ function fileAsAddPatch(fileId, contents) {
26
+ const lines = splitLines(contents)
27
+ const count = lines.length
28
+ const hunk =
29
+ count === 0
30
+ ? []
31
+ : [`@@ -0,0 +1,${count} @@`, ...lines.map((line) => `+${line}`)]
32
+ return [
33
+ `diff --git a/${fileId} b/${fileId}`,
34
+ 'new file mode 100644',
35
+ '--- /dev/null',
36
+ `+++ b/${fileId}`,
37
+ ...hunk,
38
+ '',
39
+ ].join('\n')
40
+ }
41
+
42
+ function fileAsDeletePatch(fileId, contents) {
43
+ const lines = splitLines(contents)
44
+ const count = lines.length
45
+ const hunk =
46
+ count === 0
47
+ ? []
48
+ : [`@@ -1,${count} +0,0 @@`, ...lines.map((line) => `-${line}`)]
49
+ return [
50
+ `diff --git a/${fileId} b/${fileId}`,
51
+ 'deleted file mode 100644',
52
+ `--- a/${fileId}`,
53
+ '+++ /dev/null',
54
+ ...hunk,
55
+ '',
56
+ ].join('\n')
57
+ }
58
+
59
+ function rewriteGitPaths(patch, fileId) {
60
+ return patch
61
+ .split('\n')
62
+ .map((line) => {
63
+ if (line.startsWith('diff --git ')) {
64
+ return `diff --git a/${fileId} b/${fileId}`
65
+ }
66
+ if (line.startsWith('--- ')) {
67
+ return line.includes('/dev/null') ? '--- /dev/null' : `--- a/${fileId}`
68
+ }
69
+ if (line.startsWith('+++ ')) {
70
+ return line.includes('/dev/null') ? '+++ /dev/null' : `+++ b/${fileId}`
71
+ }
72
+ return line
73
+ })
74
+ .join('\n')
75
+ }
76
+
77
+ function gitFileDiff(beforePath, afterPath, fileId) {
78
+ const result = spawnSync(
79
+ 'git',
80
+ ['diff', '--no-index', '--no-color', '--no-ext-diff', '--', beforePath, afterPath],
81
+ {
82
+ encoding: 'utf8',
83
+ maxBuffer: 32 * 1024 * 1024,
84
+ },
85
+ )
86
+ if (result.status !== 0 && result.status !== 1) {
87
+ throw new Error(result.stderr?.trim() || `git diff failed for ${fileId}`)
88
+ }
89
+ const stdout = result.stdout?.trimEnd()
90
+ if (!stdout) return ''
91
+ return `${rewriteGitPaths(stdout, fileId).trimEnd()}\n`
92
+ }
93
+
94
+ export function diffSourceTrees(beforeRoot, afterRoot) {
95
+ const before = new Set(listSourceFiles(beforeRoot))
96
+ const after = new Set(listSourceFiles(afterRoot))
97
+ const ids = [...new Set([...before, ...after])].sort((left, right) =>
98
+ left.localeCompare(right),
99
+ )
100
+ const parts = []
101
+ for (const fileId of ids) {
102
+ const beforePath = path.join(beforeRoot, fileId)
103
+ const afterPath = path.join(afterRoot, fileId)
104
+ const had = before.has(fileId)
105
+ const has = after.has(fileId)
106
+ if (!had && has) {
107
+ parts.push(fileAsAddPatch(fileId, fs.readFileSync(afterPath, 'utf8')))
108
+ continue
109
+ }
110
+ if (had && !has) {
111
+ parts.push(fileAsDeletePatch(fileId, fs.readFileSync(beforePath, 'utf8')))
112
+ continue
113
+ }
114
+ const beforeText = fs.readFileSync(beforePath, 'utf8')
115
+ const afterText = fs.readFileSync(afterPath, 'utf8')
116
+ if (beforeText === afterText) continue
117
+ const patch = gitFileDiff(beforePath, afterPath, fileId)
118
+ if (patch) parts.push(patch)
119
+ }
120
+ return parts.join('\n')
121
+ }