@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 +1 -1
- package/src/bridge.ts +55 -26
- package/src/cli.ts +44 -3
- package/src/switch.ts +60 -10
- package/web/vynt-toolbar.js +28 -6
package/package.json
CHANGED
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(
|
|
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:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/web/vynt-toolbar.js
CHANGED
|
@@ -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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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 =
|
|
585
|
-
objectivePanel.style.bottom = "-50px";
|
|
607
|
+
objectivePanel.style.top = `${height + panelGap}px`;
|
|
586
608
|
}
|
|
587
609
|
}
|
|
588
610
|
|