@devosurf/vynt 0.1.3 → 0.1.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devosurf/vynt",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "bin",
package/src/bridge.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
2
2
  import { watch, type FSWatcher } from "node:fs"
3
- import { readFile } from "node:fs/promises"
3
+ import { readFile, unlink } from "node:fs/promises"
4
4
  import { dirname, resolve } from "node:path"
5
5
  import { fileURLToPath } from "node:url"
6
6
  import { applySelectionsToWorkspace, getLatestSnapshot, rollbackWorkspaceToSnapshot } from "./switch.js"
@@ -187,12 +187,18 @@ function markVariantSelectedInState(state: VariantStore, objectiveId: string, va
187
187
  }
188
188
  }
189
189
 
190
- function finalizeVariantInObjectiveState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
190
+ function finalizeVariantInObjectiveState(
191
+ state: VariantStore,
192
+ objectiveId: string,
193
+ variantId: string,
194
+ ): { nextState: VariantStore; removedVariants: VariantArtifact[] } {
191
195
  const objectiveItem = findObjective(state, objectiveId)
192
- findVariant(objectiveItem, variantId)
196
+ const winner = findVariant(objectiveItem, variantId)
193
197
  const now = new Date().toISOString()
198
+ const removedVariants = objectiveItem.variants.filter((variant) => variant.id !== variantId)
194
199
 
195
200
  return {
201
+ nextState: {
196
202
  ...state,
197
203
  activeProfileId: undefined,
198
204
  objectives: state.objectives.map((item) => {
@@ -204,23 +210,49 @@ function finalizeVariantInObjectiveState(state: VariantStore, objectiveId: strin
204
210
  winnerVariantId: variantId,
205
211
  status: "finalized" as const,
206
212
  updatedAt: now,
207
- variants: objectiveItem.variants.map((variant) => {
208
- if (variant.id === variantId) {
209
- return {
210
- ...variant,
211
- status: "selected" as const,
212
- updatedAt: now,
213
- }
214
- }
215
-
216
- return {
217
- ...variant,
218
- status: "archived" as const,
213
+ variants: [
214
+ {
215
+ ...winner,
216
+ status: "selected" as const,
219
217
  updatedAt: now,
220
- }
221
- }),
218
+ },
219
+ ],
222
220
  }
223
221
  }),
222
+ },
223
+ removedVariants,
224
+ }
225
+ }
226
+
227
+ function collectPatchFiles(state: VariantStore): Set<string> {
228
+ const files = new Set<string>()
229
+ for (const objective of state.objectives) {
230
+ for (const variant of objective.variants) {
231
+ files.add(variant.patchFile)
232
+ }
233
+ }
234
+ return files
235
+ }
236
+
237
+ async function pruneRemovedVariantPatchFiles(
238
+ nextState: VariantStore,
239
+ removedVariants: VariantArtifact[],
240
+ ): Promise<void> {
241
+ if (removedVariants.length === 0) return
242
+ const retainedPatchFiles = collectPatchFiles(nextState)
243
+ const deleteCandidates = new Set<string>()
244
+
245
+ for (const variant of removedVariants) {
246
+ if (!retainedPatchFiles.has(variant.patchFile)) {
247
+ deleteCandidates.add(variant.patchFile)
248
+ }
249
+ }
250
+
251
+ for (const patchFile of deleteCandidates) {
252
+ try {
253
+ await unlink(patchFile)
254
+ } catch {
255
+ }
224
256
  }
225
257
  }
226
258
 
@@ -351,14 +383,9 @@ async function applySelectionsWithActiveRollback(
351
383
  throw error
352
384
  }
353
385
 
354
- const allowDirtyFiles = collectActiveSelectionChangedFiles(state)
355
- if (allowDirtyFiles.length === 0) {
356
- throw new Error(
357
- `${message}. No active changedFiles available for safe auto-rollback; run rollback first or register variants with changed files.`,
358
- )
359
- }
360
-
361
- await rollbackWorkspaceToSnapshot(projectRoot, undefined, { allowDirtyFiles })
386
+ await rollbackWorkspaceToSnapshot(projectRoot, undefined, {
387
+ allowDirtyFiles: collectActiveSelectionChangedFiles(state),
388
+ })
362
389
  return applySelectionsToWorkspace(projectRoot, state.baseRef, selections)
363
390
  }
364
391
  }
@@ -743,7 +770,9 @@ export async function startBridgeServer(options: StartBridgeServerOptions): Prom
743
770
  },
744
771
  ])
745
772
 
746
- const nextState = finalizeVariantInObjectiveState(state, target.objective.id, target.variant.id)
773
+ const finalized = finalizeVariantInObjectiveState(state, target.objective.id, target.variant.id)
774
+ await pruneRemovedVariantPatchFiles(finalized.nextState, finalized.removedVariants)
775
+ const nextState = finalized.nextState
747
776
  await saveState(projectRoot, nextState)
748
777
 
749
778
  emitEvent("finalize.succeeded", {
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process"
2
- import { access, mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises"
3
3
  import { basename, join, resolve } from "node:path"
4
4
  import process from "node:process"
5
5
  import { createInterface } from "node:readline/promises"
@@ -544,6 +544,38 @@ function findProfile(state: VariantStore, profileId: string): PreviewProfile {
544
544
  return found
545
545
  }
546
546
 
547
+ function collectPatchFiles(state: VariantStore): Set<string> {
548
+ const files = new Set<string>()
549
+ for (const objective of state.objectives) {
550
+ for (const variant of objective.variants) {
551
+ files.add(variant.patchFile)
552
+ }
553
+ }
554
+ return files
555
+ }
556
+
557
+ async function pruneRemovedVariantPatchFiles(
558
+ nextState: VariantStore,
559
+ removedVariants: VariantArtifact[],
560
+ ): Promise<void> {
561
+ if (removedVariants.length === 0) return
562
+ const retainedPatchFiles = collectPatchFiles(nextState)
563
+ const deleteCandidates = new Set<string>()
564
+
565
+ for (const variant of removedVariants) {
566
+ if (!retainedPatchFiles.has(variant.patchFile)) {
567
+ deleteCandidates.add(variant.patchFile)
568
+ }
569
+ }
570
+
571
+ for (const patchFile of deleteCandidates) {
572
+ try {
573
+ await unlink(patchFile)
574
+ } catch {
575
+ }
576
+ }
577
+ }
578
+
547
579
  function findVariantAcrossObjectives(
548
580
  state: VariantStore,
549
581
  variantId: string,
@@ -829,17 +861,26 @@ function createProgram(cwd: string): Command {
829
861
  .action(async (objectiveId: string, variantId: string) => {
830
862
  const state = requireState(await loadState(cwd))
831
863
  const current = findObjective(state, objectiveId)
832
- findVariant(current, variantId)
864
+ const winner = findVariant(current, variantId)
865
+ const removedVariants = current.variants.filter((variant) => variant.id !== variantId)
833
866
 
834
867
  const now = new Date().toISOString()
835
868
  const next = upsertObjective(state, {
836
869
  ...current,
837
870
  status: "finalized",
838
871
  winnerVariantId: variantId,
839
- activeVariantId: current.activeVariantId ?? variantId,
872
+ activeVariantId: variantId,
840
873
  updatedAt: now,
874
+ variants: [
875
+ {
876
+ ...winner,
877
+ status: "selected",
878
+ updatedAt: now,
879
+ },
880
+ ],
841
881
  })
842
882
 
883
+ await pruneRemovedVariantPatchFiles(next, removedVariants)
843
884
  await saveState(cwd, next)
844
885
  process.stdout.write(`Objective ${objectiveId} finalized with winner ${variantId}\n`)
845
886
  })
package/src/switch.ts CHANGED
@@ -24,10 +24,6 @@ export interface StoredSwitchSnapshot extends SwitchSnapshot {
24
24
  filePath: string
25
25
  }
26
26
 
27
- export interface RollbackWorkspaceOptions {
28
- allowDirtyFiles?: string[]
29
- }
30
-
31
27
  async function runGit(projectRoot: string, args: string[]): Promise<string> {
32
28
  try {
33
29
  const { stdout } = await execFileAsync("git", args, {
@@ -94,18 +90,65 @@ async function listDirtyTrackedFiles(projectRoot: string): Promise<string[]> {
94
90
  return [...new Set(files)]
95
91
  }
96
92
 
97
- async function ensureRollbackSafety(projectRoot: string, allowDirtyFiles: string[]): Promise<void> {
93
+ async function listFilesFromPatch(patchFile: string): Promise<string[]> {
94
+ const raw = await readFile(patchFile, "utf8")
95
+ const matches = raw.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)
96
+ const files = new Set<string>()
97
+
98
+ for (const match of matches) {
99
+ const fromPath = normalizeRepoPath(match[1] ?? "")
100
+ const toPath = normalizeRepoPath(match[2] ?? "")
101
+
102
+ if (toPath.length > 0 && toPath !== "dev/null") {
103
+ files.add(toPath)
104
+ continue
105
+ }
106
+
107
+ if (fromPath.length > 0 && fromPath !== "dev/null") {
108
+ files.add(fromPath)
109
+ }
110
+ }
111
+
112
+ return [...files]
113
+ }
114
+
115
+ async function collectRollbackTargetFiles(snapshot: StoredSwitchSnapshot): Promise<string[]> {
116
+ const fromMetadata = snapshot.selections
117
+ .flatMap((selection) => selection.changedFiles ?? [])
118
+ .map(normalizeRepoPath)
119
+ .filter((file) => file.length > 0)
120
+
121
+ const fromPatchFiles = await Promise.all(snapshot.selections.map((selection) => listFilesFromPatch(selection.patchFile)))
122
+ const flattenedPatchFiles = fromPatchFiles.flat().map(normalizeRepoPath).filter((file) => file.length > 0)
123
+
124
+ return [...new Set([...fromMetadata, ...flattenedPatchFiles])]
125
+ }
126
+
127
+ async function ensureRollbackSafety(
128
+ projectRoot: string,
129
+ rollbackTargetFiles: string[],
130
+ allowDirtyFiles: string[],
131
+ ): Promise<void> {
98
132
  const dirtyFiles = await listDirtyTrackedFiles(projectRoot)
99
133
  if (dirtyFiles.length === 0) return
100
134
 
101
- const allowed = new Set(allowDirtyFiles.map(normalizeRepoPath))
102
- const disallowed = dirtyFiles.filter((file) => !allowed.has(normalizeRepoPath(file)))
135
+ const normalizedTargets = new Set(rollbackTargetFiles.map(normalizeRepoPath).filter((file) => file.length > 0))
136
+ if (normalizedTargets.size === 0) {
137
+ await ensureCleanWorkspace(projectRoot)
138
+ return
139
+ }
140
+
141
+ const overlapping = dirtyFiles.filter((file) => normalizedTargets.has(normalizeRepoPath(file)))
142
+ if (overlapping.length === 0) return
143
+
144
+ const allowed = new Set(allowDirtyFiles.map(normalizeRepoPath).filter((file) => file.length > 0))
145
+ const disallowed = overlapping.filter((file) => !allowed.has(normalizeRepoPath(file)))
103
146
  if (disallowed.length === 0) return
104
147
 
105
148
  const listed = disallowed.slice(0, 5).join(", ")
106
149
  const remainder = disallowed.length > 5 ? `, +${disallowed.length - 5} more` : ""
107
150
  throw new Error(
108
- `Rollback would overwrite uncommitted changes outside active variant files: ${listed}${remainder}. Commit or stash these changes before rollback.`,
151
+ `Rollback would overwrite uncommitted changes in files touched by the snapshot: ${listed}${remainder}. Commit or stash these changes before rollback.`,
109
152
  )
110
153
  }
111
154
 
@@ -159,6 +202,9 @@ function parseSnapshot(content: unknown, filePath: string): StoredSwitchSnapshot
159
202
  objectiveId: item.objectiveId,
160
203
  variantId: item.variantId,
161
204
  patchFile: item.patchFile,
205
+ changedFiles: Array.isArray(item.changedFiles)
206
+ ? item.changedFiles.filter((file): file is string => typeof file === "string")
207
+ : undefined,
162
208
  }
163
209
  })
164
210
 
@@ -329,10 +375,14 @@ export async function rollbackWorkspaceToSnapshot(
329
375
  throw new Error("No snapshots found. Run apply/profile apply first.")
330
376
  }
331
377
 
332
- await ensureRollbackSafety(projectRoot, options.allowDirtyFiles ?? [])
378
+ const rollbackTargetFiles = await collectRollbackTargetFiles(snapshot)
379
+ await ensureRollbackSafety(projectRoot, rollbackTargetFiles, options.allowDirtyFiles ?? [])
333
380
 
334
381
  await verifyBaseRef(projectRoot, snapshot.head)
335
- await restoreWorkspaceToBase(projectRoot, snapshot.head)
382
+ await restoreWorkspaceToBase(projectRoot, snapshot.head, rollbackTargetFiles)
336
383
 
337
384
  return { snapshot }
338
385
  }
386
+ export interface RollbackWorkspaceOptions {
387
+ allowDirtyFiles?: string[]
388
+ }
@@ -576,13 +576,35 @@
576
576
  objectiveOverlayRoot.style.width = `${Math.max(0, width)}px`;
577
577
  objectiveOverlayRoot.style.height = `${Math.max(0, height)}px`;
578
578
 
579
- const panelBottom = -50;
580
- if (y + height + panelBottom + 48 > window.innerHeight) {
581
- objectivePanel.style.bottom = "auto";
582
- objectivePanel.style.top = "-50px";
579
+ const panelGap = 8;
580
+ const panelWidth = 220;
581
+ const panelHeight = 40;
582
+ const rightSpace = window.innerWidth - (x + width);
583
+ const leftSpace = x;
584
+ const alignedTop = Math.max(0, Math.min(Math.max(0, height - panelHeight), (height - panelHeight) / 2));
585
+
586
+ objectivePanel.style.bottom = "auto";
587
+ objectivePanel.style.top = `${alignedTop}px`;
588
+
589
+ if (rightSpace >= panelWidth + panelGap) {
590
+ objectivePanel.style.left = `${width + panelGap}px`;
591
+ objectivePanel.style.transform = "none";
592
+ return;
593
+ }
594
+
595
+ if (leftSpace >= panelWidth + panelGap) {
596
+ objectivePanel.style.left = `${-panelGap}px`;
597
+ objectivePanel.style.transform = "translateX(-100%)";
598
+ return;
599
+ }
600
+
601
+ objectivePanel.style.left = "50%";
602
+ objectivePanel.style.transform = "translateX(-50%)";
603
+ if (y + height + panelGap + panelHeight > window.innerHeight) {
604
+ objectivePanel.style.top = `${-panelGap}px`;
605
+ objectivePanel.style.transform = "translate(-50%, -100%)";
583
606
  } else {
584
- objectivePanel.style.top = "auto";
585
- objectivePanel.style.bottom = "-50px";
607
+ objectivePanel.style.top = `${height + panelGap}px`;
586
608
  }
587
609
  }
588
610