@superblocksteam/workspace-sync 0.0.0-stage → 2.0.0-SNAPSHOT.e3d0ac5dd38ba62e
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/LICENSE.txt +87 -0
- package/README.md +51 -2
- package/dist/atomic-write.d.ts +10 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +35 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/branch-write-guard.d.ts +32 -0
- package/dist/branch-write-guard.d.ts.map +1 -0
- package/dist/branch-write-guard.js +38 -0
- package/dist/branch-write-guard.js.map +1 -0
- package/dist/control.d.ts +237 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/control.js +242 -0
- package/dist/control.js.map +1 -0
- package/dist/dbfs-transport.d.ts +54 -0
- package/dist/dbfs-transport.d.ts.map +1 -0
- package/dist/dbfs-transport.js +21 -0
- package/dist/dbfs-transport.js.map +1 -0
- package/dist/download.d.ts +54 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +475 -0
- package/dist/download.js.map +1 -0
- package/dist/draft-helpers.d.ts +127 -0
- package/dist/draft-helpers.d.ts.map +1 -0
- package/dist/draft-helpers.js +840 -0
- package/dist/draft-helpers.js.map +1 -0
- package/dist/draft-journal-store.d.ts +30 -0
- package/dist/draft-journal-store.d.ts.map +1 -0
- package/dist/draft-journal-store.js +7 -0
- package/dist/draft-journal-store.js.map +1 -0
- package/dist/draft-state-report-metrics.d.ts +5 -0
- package/dist/draft-state-report-metrics.d.ts.map +1 -0
- package/dist/draft-state-report-metrics.js +36 -0
- package/dist/draft-state-report-metrics.js.map +1 -0
- package/dist/file-draft-journal-store.d.ts +26 -0
- package/dist/file-draft-journal-store.d.ts.map +1 -0
- package/dist/file-draft-journal-store.js +123 -0
- package/dist/file-draft-journal-store.js.map +1 -0
- package/dist/flush-scheduler.d.ts +24 -0
- package/dist/flush-scheduler.d.ts.map +1 -0
- package/dist/flush-scheduler.js +36 -0
- package/dist/flush-scheduler.js.map +1 -0
- package/dist/hash-cache.d.ts +16 -0
- package/dist/hash-cache.d.ts.map +1 -0
- package/dist/hash-cache.js +62 -0
- package/dist/hash-cache.js.map +1 -0
- package/dist/hash-dir-tree.d.ts +10 -0
- package/dist/hash-dir-tree.d.ts.map +1 -0
- package/dist/hash-dir-tree.js +53 -0
- package/dist/hash-dir-tree.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/list-dir.d.ts +31 -0
- package/dist/list-dir.d.ts.map +1 -0
- package/dist/list-dir.js +153 -0
- package/dist/list-dir.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +16 -0
- package/dist/logger.js.map +1 -0
- package/dist/native-draft-session.d.ts +28 -0
- package/dist/native-draft-session.d.ts.map +1 -0
- package/dist/native-draft-session.js +154 -0
- package/dist/native-draft-session.js.map +1 -0
- package/dist/rpc-client.d.ts +7 -0
- package/dist/rpc-client.d.ts.map +1 -0
- package/dist/rpc-client.js +2 -0
- package/dist/rpc-client.js.map +1 -0
- package/dist/safe-directory-reader.d.ts +28 -0
- package/dist/safe-directory-reader.d.ts.map +1 -0
- package/dist/safe-directory-reader.js +136 -0
- package/dist/safe-directory-reader.js.map +1 -0
- package/dist/server-tree.d.ts +56 -0
- package/dist/server-tree.d.ts.map +1 -0
- package/dist/server-tree.js +179 -0
- package/dist/server-tree.js.map +1 -0
- package/dist/session-draft-journal-store.d.ts +42 -0
- package/dist/session-draft-journal-store.d.ts.map +1 -0
- package/dist/session-draft-journal-store.js +177 -0
- package/dist/session-draft-journal-store.js.map +1 -0
- package/dist/snapshot/take-snapshot.d.ts +41 -0
- package/dist/snapshot/take-snapshot.d.ts.map +1 -0
- package/dist/snapshot/take-snapshot.js +244 -0
- package/dist/snapshot/take-snapshot.js.map +1 -0
- package/dist/snapshot/types.d.ts +25 -0
- package/dist/snapshot/types.d.ts.map +1 -0
- package/dist/snapshot/types.js +3 -0
- package/dist/snapshot/types.js.map +1 -0
- package/dist/snapshot/upload-snapshot.d.ts +33 -0
- package/dist/snapshot/upload-snapshot.d.ts.map +1 -0
- package/dist/snapshot/upload-snapshot.js +113 -0
- package/dist/snapshot/upload-snapshot.js.map +1 -0
- package/dist/socket-dbfs-transport.d.ts +32 -0
- package/dist/socket-dbfs-transport.d.ts.map +1 -0
- package/dist/socket-dbfs-transport.js +129 -0
- package/dist/socket-dbfs-transport.js.map +1 -0
- package/dist/sync-credential-provider.d.ts +27 -0
- package/dist/sync-credential-provider.d.ts.map +1 -0
- package/dist/sync-credential-provider.js +22 -0
- package/dist/sync-credential-provider.js.map +1 -0
- package/dist/sync-engine.d.ts +141 -0
- package/dist/sync-engine.d.ts.map +1 -0
- package/dist/sync-engine.js +447 -0
- package/dist/sync-engine.js.map +1 -0
- package/dist/sync-paths.d.ts +61 -0
- package/dist/sync-paths.d.ts.map +1 -0
- package/dist/sync-paths.js +107 -0
- package/dist/sync-paths.js.map +1 -0
- package/dist/sync-telemetry.d.ts +12 -0
- package/dist/sync-telemetry.d.ts.map +1 -0
- package/dist/sync-telemetry.js +33 -0
- package/dist/sync-telemetry.js.map +1 -0
- package/dist/testing.d.ts +81 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +192 -0
- package/dist/testing.js.map +1 -0
- package/dist/workspace-state-store.d.ts +52 -0
- package/dist/workspace-state-store.d.ts.map +1 -0
- package/dist/workspace-state-store.js +45 -0
- package/dist/workspace-state-store.js.map +1 -0
- package/package.json +60 -3
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
import * as fsp from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createPatch } from "diff";
|
|
4
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
5
|
+
import { SafeDirectoryReader } from "./safe-directory-reader.js";
|
|
6
|
+
const EDIT_EXT = ".sbswap.edit";
|
|
7
|
+
const ADD_EXT = ".sbswap.add";
|
|
8
|
+
const DELETE_EXT = ".sbswap.delete";
|
|
9
|
+
const MARKER_EXT = {
|
|
10
|
+
add: ADD_EXT,
|
|
11
|
+
delete: DELETE_EXT,
|
|
12
|
+
edit: EDIT_EXT,
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Gets the marker file path for a given file path and app root directory.
|
|
16
|
+
* Marker files are stored in .superblocks/drafts/ with the same relative structure.
|
|
17
|
+
*
|
|
18
|
+
* @param filePath - The absolute path to the file.
|
|
19
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
20
|
+
* @param markerType - The type of marker file ('.sbswap.edit' or '.sbswap.add').
|
|
21
|
+
* @returns The absolute path to the marker file.
|
|
22
|
+
*/
|
|
23
|
+
function getMarkerFilePath(filePath, appRootDirPath, markerType) {
|
|
24
|
+
const relativePath = path.relative(appRootDirPath, filePath);
|
|
25
|
+
return path.join(getDraftsDirectoryPath(appRootDirPath), `${relativePath}${markerType}`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Gets the drafts directory path for the app root.
|
|
29
|
+
*
|
|
30
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
31
|
+
* @returns The absolute path to the drafts directory.
|
|
32
|
+
*/
|
|
33
|
+
function getDraftsDirectoryPath(appRootDirPath) {
|
|
34
|
+
return path.join(appRootDirPath, ".superblocks", "drafts");
|
|
35
|
+
}
|
|
36
|
+
async function getMarkerState(filePath, appRootDirPath) {
|
|
37
|
+
const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
|
|
38
|
+
const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
|
|
39
|
+
const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
|
|
40
|
+
let state = "NEW";
|
|
41
|
+
try {
|
|
42
|
+
await fsp.access(filePath);
|
|
43
|
+
state = "FILE_EXISTS";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
//
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await fsp.access(editMarkerPath);
|
|
50
|
+
state = "EDIT_MARKER";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
//
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await fsp.access(addMarkerPath);
|
|
57
|
+
state = "ADD_MARKER";
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
//
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await fsp.access(deleteMarkerPath);
|
|
64
|
+
state = "DELETE_MARKER";
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
//
|
|
68
|
+
}
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Creates a draft edit or addition marker file only.
|
|
73
|
+
*
|
|
74
|
+
* @param filePath - The absolute path to the file for which to create the respective marker.
|
|
75
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
76
|
+
*/
|
|
77
|
+
export async function createDraftMarkerOnly(filePath, appRootDirPath) {
|
|
78
|
+
const state = await getMarkerState(filePath, appRootDirPath);
|
|
79
|
+
switch (state) {
|
|
80
|
+
case "FILE_EXISTS": {
|
|
81
|
+
const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
|
|
82
|
+
// Ensure the drafts directory structure exists
|
|
83
|
+
const editMarkerDir = path.dirname(editMarkerPath);
|
|
84
|
+
await fsp.mkdir(editMarkerDir, { recursive: true });
|
|
85
|
+
// Copy original file to .sbswap.edit marker in drafts directory
|
|
86
|
+
await fsp.copyFile(filePath, editMarkerPath);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case "NEW": {
|
|
90
|
+
const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
|
|
91
|
+
// Ensure the drafts directory structure exists
|
|
92
|
+
const addMarkerDir = path.dirname(addMarkerPath);
|
|
93
|
+
await fsp.mkdir(addMarkerDir, { recursive: true });
|
|
94
|
+
// Create marker to indicate this is a draft addition
|
|
95
|
+
await fsp.writeFile(addMarkerPath, "");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case "EDIT_MARKER":
|
|
99
|
+
case "ADD_MARKER":
|
|
100
|
+
default:
|
|
101
|
+
// we already have a marker file, nothing to do
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Creates a draft edit or addition based on the presence of an existing file.
|
|
107
|
+
* This makes the draft content visible to the build system while preserving the original.
|
|
108
|
+
*
|
|
109
|
+
* @param filePath - The absolute path to the file to edit or add.
|
|
110
|
+
* @param draftContent - The content of the draft edit or addition.
|
|
111
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
112
|
+
* @returns A promise that resolves to "edit" if the file already exists, or "add" if the file does not exist.
|
|
113
|
+
*/
|
|
114
|
+
export async function createDraft(filePath, draftContent, appRootDirPath) {
|
|
115
|
+
const state = await getMarkerState(filePath, appRootDirPath);
|
|
116
|
+
switch (state) {
|
|
117
|
+
case "ADD_MARKER": {
|
|
118
|
+
// This is an iteration on an existing draft addition - just update content
|
|
119
|
+
const dirPath = path.dirname(filePath);
|
|
120
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
121
|
+
await fsp.writeFile(filePath, draftContent);
|
|
122
|
+
return "add";
|
|
123
|
+
}
|
|
124
|
+
case "EDIT_MARKER": {
|
|
125
|
+
// This is an iteration on an existing draft edit - just update content
|
|
126
|
+
const dirPath = path.dirname(filePath);
|
|
127
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
128
|
+
await fsp.writeFile(filePath, draftContent);
|
|
129
|
+
return "edit";
|
|
130
|
+
}
|
|
131
|
+
case "DELETE_MARKER": {
|
|
132
|
+
// File was previously deleted as a draft, but now we're writing to it again
|
|
133
|
+
// Convert the DELETE marker to an EDIT marker to preserve the original backup
|
|
134
|
+
const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
|
|
135
|
+
const editMarkerPath = getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT);
|
|
136
|
+
try {
|
|
137
|
+
// Move the backup from .sbswap.delete to .sbswap.edit
|
|
138
|
+
await fsp.rename(deleteMarkerPath, editMarkerPath);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// If marker doesn't exist or rename fails, continue anyway
|
|
142
|
+
// The file will be treated as a new draft edit without a backup
|
|
143
|
+
}
|
|
144
|
+
// Write new content to the file
|
|
145
|
+
const dirPath = path.dirname(filePath);
|
|
146
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
147
|
+
await fsp.writeFile(filePath, draftContent);
|
|
148
|
+
return "edit";
|
|
149
|
+
}
|
|
150
|
+
case "FILE_EXISTS": {
|
|
151
|
+
// This is a new draft edit
|
|
152
|
+
await createDraftEdit(filePath, draftContent, appRootDirPath);
|
|
153
|
+
return "edit";
|
|
154
|
+
}
|
|
155
|
+
case "NEW": {
|
|
156
|
+
// This is a new draft addition
|
|
157
|
+
await createDraftAdd(filePath, draftContent, appRootDirPath);
|
|
158
|
+
return "add";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Creates a draft edit by copying the original file to the drafts directory and writing draft content to the original location.
|
|
164
|
+
* This makes the draft content visible to the build system while preserving the original.
|
|
165
|
+
* If a draft edit already exists, this will just update the draft content.
|
|
166
|
+
*
|
|
167
|
+
* @param filePath - The absolute path to the file to edit.
|
|
168
|
+
* @param draftContent - The content of the draft edit.
|
|
169
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
170
|
+
* @returns A promise that resolves when the draft edit is created.
|
|
171
|
+
*/
|
|
172
|
+
export async function createDraftEdit(filePath, draftContent, appRootDirPath) {
|
|
173
|
+
await new DraftEdit(filePath, getMarkerFilePath(filePath, appRootDirPath, EDIT_EXT)).apply(draftContent);
|
|
174
|
+
}
|
|
175
|
+
class DraftEdit {
|
|
176
|
+
filePath;
|
|
177
|
+
markerPath;
|
|
178
|
+
constructor(filePath, markerPath) {
|
|
179
|
+
this.filePath = filePath;
|
|
180
|
+
this.markerPath = markerPath;
|
|
181
|
+
}
|
|
182
|
+
async apply(content) {
|
|
183
|
+
const file = await this.openOriginal();
|
|
184
|
+
try {
|
|
185
|
+
const status = await file.stat();
|
|
186
|
+
if (!status.isFile())
|
|
187
|
+
throw new Error(`Cannot create draft edit: original is not a regular file: ${this.filePath}`);
|
|
188
|
+
await this.preserveOriginal(await file.readFile(), status.mode);
|
|
189
|
+
await this.writeDraft(file, Buffer.isBuffer(content) ? content : Buffer.from(content));
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
await file.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async openOriginal() {
|
|
196
|
+
try {
|
|
197
|
+
return await fsp.open(this.filePath, fsp.constants.O_RDWR |
|
|
198
|
+
fsp.constants.O_NOFOLLOW |
|
|
199
|
+
fsp.constants.O_NONBLOCK);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
203
|
+
throw new Error(`Cannot create draft edit: original file does not exist: ${this.filePath}`);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async preserveOriginal(content, mode) {
|
|
208
|
+
const directory = path.dirname(this.markerPath);
|
|
209
|
+
await fsp.mkdir(directory, { recursive: true });
|
|
210
|
+
const temporary = await fsp.mkdtemp(path.join(directory, ".draft-"));
|
|
211
|
+
try {
|
|
212
|
+
const original = path.join(temporary, "original");
|
|
213
|
+
await fsp.writeFile(original, content, { flag: "wx" });
|
|
214
|
+
await fsp.chmod(original, mode);
|
|
215
|
+
try {
|
|
216
|
+
await fsp.link(original, this.markerPath);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (!(error instanceof Error &&
|
|
220
|
+
"code" in error &&
|
|
221
|
+
error.code === "EEXIST"))
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await fsp.rm(temporary, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async writeDraft(file, content) {
|
|
230
|
+
let offset = 0;
|
|
231
|
+
while (offset < content.length) {
|
|
232
|
+
const { bytesWritten } = await file.writev([content.subarray(offset)], offset);
|
|
233
|
+
if (!bytesWritten)
|
|
234
|
+
throw new Error("Cannot create draft edit: write made no progress");
|
|
235
|
+
offset += bytesWritten;
|
|
236
|
+
}
|
|
237
|
+
await file.truncate(content.length);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Creates a draft addition by writing new file content and creating a .sbswap.add marker.
|
|
242
|
+
* This makes the new file visible to the build system while marking it as a draft addition.
|
|
243
|
+
* If a draft addition already exists, this will just update the content.
|
|
244
|
+
*
|
|
245
|
+
* @param filePath - The absolute path where the new file should be created.
|
|
246
|
+
* @param content - The content of the new file.
|
|
247
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
248
|
+
* @returns A promise that resolves when the draft addition is created.
|
|
249
|
+
*/
|
|
250
|
+
export async function createDraftAdd(filePath, content, appRootDirPath) {
|
|
251
|
+
const addMarkerPath = getMarkerFilePath(filePath, appRootDirPath, ADD_EXT);
|
|
252
|
+
// Check if this is an iteration on an existing draft addition
|
|
253
|
+
try {
|
|
254
|
+
await fsp.access(addMarkerPath);
|
|
255
|
+
// Marker exists, this is an iteration - just update content
|
|
256
|
+
const dirPath = path.dirname(filePath);
|
|
257
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
258
|
+
await fsp.writeFile(filePath, content);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Marker doesn't exist, proceed with normal draft addition creation
|
|
263
|
+
}
|
|
264
|
+
// Check if file already exists (and it's not a draft)
|
|
265
|
+
try {
|
|
266
|
+
await fsp.access(filePath);
|
|
267
|
+
// If we get here, the file exists, which is not allowed for new draft additions
|
|
268
|
+
throw new Error(`Cannot create draft add: file already exists: ${filePath}`);
|
|
269
|
+
}
|
|
270
|
+
catch (accessError) {
|
|
271
|
+
// If access fails, the file doesn't exist, which is what we want
|
|
272
|
+
if (accessError instanceof Error &&
|
|
273
|
+
accessError.message.includes("Cannot create draft add")) {
|
|
274
|
+
throw accessError; // Re-throw our own error
|
|
275
|
+
}
|
|
276
|
+
// Otherwise, continue - the file doesn't exist, which is expected
|
|
277
|
+
}
|
|
278
|
+
// Ensure directory exists for the new file
|
|
279
|
+
const dirPath = path.dirname(filePath);
|
|
280
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
281
|
+
// Ensure the drafts directory structure exists
|
|
282
|
+
const addMarkerDir = path.dirname(addMarkerPath);
|
|
283
|
+
await fsp.mkdir(addMarkerDir, { recursive: true });
|
|
284
|
+
// Write new file content
|
|
285
|
+
await fsp.writeFile(filePath, content);
|
|
286
|
+
// Create marker to indicate this is a draft addition
|
|
287
|
+
await fsp.writeFile(addMarkerPath, "");
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Writes a marker for a file that already holds its draft content, leaving the
|
|
291
|
+
* file untouched. `original` is the pre-draft content of an edit or a delete.
|
|
292
|
+
*/
|
|
293
|
+
export async function writeDraftMarker(relativePath, appRootDirPath, kind, original = Buffer.alloc(0)) {
|
|
294
|
+
const markerPath = getMarkerFilePath(path.join(appRootDirPath, relativePath), appRootDirPath, MARKER_EXT[kind]);
|
|
295
|
+
await fsp.mkdir(path.dirname(markerPath), { recursive: true });
|
|
296
|
+
const existing = await fsp.lstat(markerPath).catch((error) => {
|
|
297
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
298
|
+
return undefined;
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
if (existing?.isFile() && (await draftMarkerMatches(markerPath, original)))
|
|
302
|
+
return;
|
|
303
|
+
await writeFileAtomic(markerPath, original, {
|
|
304
|
+
mode: 0o666,
|
|
305
|
+
preserveMode: existing?.isFile() ? existing.mode & 0o777 : undefined,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async function draftMarkerMatches(markerPath, original) {
|
|
309
|
+
const file = await fsp
|
|
310
|
+
.open(markerPath, fsp.constants.O_RDONLY |
|
|
311
|
+
fsp.constants.O_NOFOLLOW |
|
|
312
|
+
fsp.constants.O_NONBLOCK)
|
|
313
|
+
.catch((error) => {
|
|
314
|
+
if (error instanceof Error &&
|
|
315
|
+
"code" in error &&
|
|
316
|
+
(error.code === "ENOENT" ||
|
|
317
|
+
error.code === "ELOOP" ||
|
|
318
|
+
error.code === "EACCES"))
|
|
319
|
+
return undefined;
|
|
320
|
+
throw error;
|
|
321
|
+
});
|
|
322
|
+
if (!file)
|
|
323
|
+
return false;
|
|
324
|
+
try {
|
|
325
|
+
const status = await file.stat();
|
|
326
|
+
if (!status.isFile() || status.size !== original.length)
|
|
327
|
+
return false;
|
|
328
|
+
const content = Buffer.alloc(original.length + 1);
|
|
329
|
+
let offset = 0;
|
|
330
|
+
while (offset < content.length) {
|
|
331
|
+
const { bytesRead } = await file.read(content, offset, content.length - offset);
|
|
332
|
+
if (!bytesRead)
|
|
333
|
+
break;
|
|
334
|
+
offset += bytesRead;
|
|
335
|
+
}
|
|
336
|
+
return (offset === original.length && content.subarray(0, offset).equals(original));
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
await file.close();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export class DraftMarkers {
|
|
343
|
+
root;
|
|
344
|
+
constructor(root) {
|
|
345
|
+
this.root = root;
|
|
346
|
+
}
|
|
347
|
+
async prepare() {
|
|
348
|
+
await this.assertSafePath("");
|
|
349
|
+
await fsp.mkdir(getDraftsDirectoryPath(this.root), { recursive: true });
|
|
350
|
+
}
|
|
351
|
+
async originals(targets, before, maxBytes, maxFiles) {
|
|
352
|
+
const originals = new Map([...before].filter(([relative]) => this.withinTargets(relative, targets)));
|
|
353
|
+
const reader = await SafeDirectoryReader.open(this.root);
|
|
354
|
+
let bytes = 0;
|
|
355
|
+
try {
|
|
356
|
+
for (const markerPath of await this.markerFiles(targets, maxFiles)) {
|
|
357
|
+
const extension = Object.values(MARKER_EXT).find((extension) => markerPath.endsWith(extension));
|
|
358
|
+
if (!extension)
|
|
359
|
+
continue;
|
|
360
|
+
const relative = path
|
|
361
|
+
.relative(getDraftsDirectoryPath(this.root), markerPath)
|
|
362
|
+
.slice(0, -extension.length);
|
|
363
|
+
if (extension === ADD_EXT) {
|
|
364
|
+
originals.delete(relative);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const original = await reader.readFile(path.relative(this.root, markerPath), maxBytes - bytes);
|
|
368
|
+
if (!original)
|
|
369
|
+
throw new Error("Draft marker disappeared during snapshot");
|
|
370
|
+
bytes += original.length;
|
|
371
|
+
originals.set(relative, original);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
await reader.close();
|
|
377
|
+
}
|
|
378
|
+
return originals;
|
|
379
|
+
}
|
|
380
|
+
async assertSafePath(relative) {
|
|
381
|
+
let directory = this.root;
|
|
382
|
+
for (const segment of [
|
|
383
|
+
".superblocks",
|
|
384
|
+
"drafts",
|
|
385
|
+
...relative.split(path.sep).slice(0, -1),
|
|
386
|
+
]) {
|
|
387
|
+
directory = path.join(directory, segment);
|
|
388
|
+
const stats = await fsp.lstat(directory).catch((error) => {
|
|
389
|
+
if (error.code === "ENOENT")
|
|
390
|
+
return undefined;
|
|
391
|
+
throw error;
|
|
392
|
+
});
|
|
393
|
+
if (stats?.isSymbolicLink())
|
|
394
|
+
throw new Error("Draft marker path cannot traverse symbolic links");
|
|
395
|
+
}
|
|
396
|
+
for (const extension of Object.values(MARKER_EXT)) {
|
|
397
|
+
const stats = await fsp
|
|
398
|
+
.lstat(getMarkerFilePath(path.join(this.root, relative), this.root, extension))
|
|
399
|
+
.catch((error) => {
|
|
400
|
+
if (error.code === "ENOENT")
|
|
401
|
+
return undefined;
|
|
402
|
+
throw error;
|
|
403
|
+
});
|
|
404
|
+
if (stats?.isSymbolicLink())
|
|
405
|
+
throw new Error("Draft marker path cannot traverse symbolic links");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async markerFiles(targets, maxFiles) {
|
|
409
|
+
const files = new Set();
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
const visit = async (absolute) => {
|
|
412
|
+
if (seen.has(absolute))
|
|
413
|
+
return;
|
|
414
|
+
seen.add(absolute);
|
|
415
|
+
if (seen.size > maxFiles)
|
|
416
|
+
throw new Error("Native filesystem mutation exceeds the draft snapshot limit");
|
|
417
|
+
const stats = await fsp.lstat(absolute).catch((error) => {
|
|
418
|
+
if (error.code === "ENOENT")
|
|
419
|
+
return undefined;
|
|
420
|
+
throw error;
|
|
421
|
+
});
|
|
422
|
+
if (!stats)
|
|
423
|
+
return;
|
|
424
|
+
if (stats.isSymbolicLink())
|
|
425
|
+
throw new Error("Draft marker path cannot traverse symbolic links");
|
|
426
|
+
if (stats.isDirectory()) {
|
|
427
|
+
for (const entry of await fsp.readdir(absolute))
|
|
428
|
+
await visit(path.join(absolute, entry));
|
|
429
|
+
}
|
|
430
|
+
else if (stats.isFile()) {
|
|
431
|
+
files.add(absolute);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
throw new Error("Draft marker path must contain regular files");
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
for (const target of targets) {
|
|
438
|
+
await this.assertSafePath(target);
|
|
439
|
+
let parent = getDraftsDirectoryPath(this.root);
|
|
440
|
+
for (const segment of target.split(path.sep).slice(0, -1)) {
|
|
441
|
+
parent = path.join(parent, segment);
|
|
442
|
+
const stats = await fsp.lstat(parent).catch((error) => {
|
|
443
|
+
if (error.code === "ENOENT")
|
|
444
|
+
return undefined;
|
|
445
|
+
throw error;
|
|
446
|
+
});
|
|
447
|
+
if (stats?.isSymbolicLink())
|
|
448
|
+
throw new Error("Draft marker path cannot traverse symbolic links");
|
|
449
|
+
}
|
|
450
|
+
await visit(path.join(getDraftsDirectoryPath(this.root), target));
|
|
451
|
+
for (const extension of Object.values(MARKER_EXT)) {
|
|
452
|
+
await visit(getMarkerFilePath(path.join(this.root, target), this.root, extension));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return files;
|
|
456
|
+
}
|
|
457
|
+
async reconcile(relative, original, current) {
|
|
458
|
+
await this.assertSafePath(relative);
|
|
459
|
+
const unchanged = original === undefined
|
|
460
|
+
? current === undefined
|
|
461
|
+
: current !== undefined && original.equals(current);
|
|
462
|
+
const kind = unchanged
|
|
463
|
+
? undefined
|
|
464
|
+
: original === undefined
|
|
465
|
+
? "add"
|
|
466
|
+
: current === undefined
|
|
467
|
+
? "delete"
|
|
468
|
+
: "edit";
|
|
469
|
+
if (kind)
|
|
470
|
+
await writeDraftMarker(relative, this.root, kind, original);
|
|
471
|
+
for (const [candidate, extension] of Object.entries(MARKER_EXT)) {
|
|
472
|
+
if (candidate !== kind)
|
|
473
|
+
await retireMarker(relative, extension, this.root);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
withinTargets(relative, targets) {
|
|
477
|
+
return targets.some((target) => relative === target || relative.startsWith(`${target}${path.sep}`));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Creates a draft deletion by backing up the file content and removing it from the workspace.
|
|
482
|
+
*
|
|
483
|
+
* @param filePath - The absolute path to the file to delete.
|
|
484
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
485
|
+
* @returns A promise that resolves when the draft deletion is created.
|
|
486
|
+
*/
|
|
487
|
+
export async function createDraftDelete(filePath, appRootDirPath) {
|
|
488
|
+
const deleteMarkerPath = getMarkerFilePath(filePath, appRootDirPath, DELETE_EXT);
|
|
489
|
+
// Check if file exists
|
|
490
|
+
try {
|
|
491
|
+
await fsp.access(filePath);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
throw new Error(`Cannot create draft delete: file does not exist: ${filePath}`);
|
|
495
|
+
}
|
|
496
|
+
// Check if this is an iteration on an existing draft deletion
|
|
497
|
+
try {
|
|
498
|
+
await fsp.access(deleteMarkerPath);
|
|
499
|
+
// Marker exists - file is already deleted, nothing to do
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// Marker doesn't exist, proceed with normal draft deletion
|
|
504
|
+
}
|
|
505
|
+
// Ensure the drafts directory structure exists
|
|
506
|
+
const deleteMarkerDir = path.dirname(deleteMarkerPath);
|
|
507
|
+
await fsp.mkdir(deleteMarkerDir, { recursive: true });
|
|
508
|
+
// Copy original file to .sbswap.delete marker in drafts directory
|
|
509
|
+
await fsp.copyFile(filePath, deleteMarkerPath);
|
|
510
|
+
// Delete the file from the workspace
|
|
511
|
+
await fsp.unlink(filePath);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Recursively checks if any draft files exist in the drafts directory.
|
|
515
|
+
* Looks for .sbswap.edit, .sbswap.add, and .sbswap.delete marker files.
|
|
516
|
+
*
|
|
517
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
518
|
+
* @returns A promise that resolves to true if any draft files are found, false otherwise.
|
|
519
|
+
*/
|
|
520
|
+
export async function hasSwapFiles(appRootDirPath) {
|
|
521
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
522
|
+
try {
|
|
523
|
+
await fsp.access(draftsDir);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Drafts directory doesn't exist, so no draft files
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
return await hasSwapFilesInDirectory(draftsDir, appRootDirPath);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* {@link hasSwapFiles} for decisions that clear the server's draft flag: only
|
|
533
|
+
* a missing drafts directory means no drafts. A directory or entry that
|
|
534
|
+
* cannot be read counts as a draft, so an unreadable tree never reads as
|
|
535
|
+
* reviewed.
|
|
536
|
+
*/
|
|
537
|
+
export async function hasDraftsFailClosed(appRootDirPath) {
|
|
538
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
539
|
+
try {
|
|
540
|
+
await fsp.lstat(draftsDir);
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
return !isMissing(error);
|
|
544
|
+
}
|
|
545
|
+
return await hasSwapFilesInDirectory(draftsDir, appRootDirPath, true);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Throws unless the drafts directory is gone. Accept, reject and clear all
|
|
549
|
+
* end by removing it, and their helpers swallow removal and read errors, so
|
|
550
|
+
* this is the check that they finished.
|
|
551
|
+
*/
|
|
552
|
+
export async function assertDraftsSettled(appRootDirPath, action) {
|
|
553
|
+
try {
|
|
554
|
+
await fsp.lstat(getDraftsDirectoryPath(appRootDirPath));
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
if (isMissing(error))
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
throw new Error(`Draft ${action} left draft markers in place`);
|
|
561
|
+
}
|
|
562
|
+
function isMissing(error) {
|
|
563
|
+
return error?.code === "ENOENT";
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Helper function to recursively check for swap files with actual changes in a directory.
|
|
567
|
+
* Only returns true for files that have meaningful content differences, not just marker files.
|
|
568
|
+
*
|
|
569
|
+
* @param directoryPath - The path to the directory to scan for draft files.
|
|
570
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
571
|
+
* @returns A promise that resolves to true if any draft files with changes are found, false otherwise.
|
|
572
|
+
*/
|
|
573
|
+
async function hasSwapFilesInDirectory(directoryPath, appRootDirPath, failClosed = false) {
|
|
574
|
+
try {
|
|
575
|
+
const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
|
|
576
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
577
|
+
for (const entry of entries) {
|
|
578
|
+
if (entry.isFile()) {
|
|
579
|
+
const swapFilePath = path.join(directoryPath, entry.name);
|
|
580
|
+
if (entry.name.endsWith(EDIT_EXT)) {
|
|
581
|
+
const markerRelativePath = path.relative(draftsDir, swapFilePath);
|
|
582
|
+
const originalRelativePath = markerRelativePath.slice(0, -EDIT_EXT.length);
|
|
583
|
+
const originalPath = path.join(appRootDirPath, originalRelativePath);
|
|
584
|
+
try {
|
|
585
|
+
// Should work ok for files up to ~100 MB
|
|
586
|
+
const [draftContent, originalContent] = await Promise.all([
|
|
587
|
+
fsp.readFile(swapFilePath, "utf8"),
|
|
588
|
+
fsp.readFile(originalPath, "utf8"),
|
|
589
|
+
]);
|
|
590
|
+
if (draftContent !== originalContent) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// If we can't read files, assume it's a change to be safe
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else if (entry.name.endsWith(ADD_EXT)) {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
else if (entry.name.endsWith(DELETE_EXT)) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (entry.isDirectory()) {
|
|
607
|
+
const subdirPath = path.join(directoryPath, entry.name);
|
|
608
|
+
if (await hasSwapFilesInDirectory(subdirPath, appRootDirPath, failClosed)) {
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
// If directory doesn't exist or can't be read, no draft files (fail
|
|
617
|
+
// closed: only a missing directory)
|
|
618
|
+
return failClosed && !isMissing(error);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Discards all draft changes by removing draft additions, then restoring the
|
|
623
|
+
* originals of edits and deletions. Each marker is retired once its step
|
|
624
|
+
* succeeds, so a retry after a failed restore resumes where it stopped.
|
|
625
|
+
*
|
|
626
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
627
|
+
* @returns A promise that resolves when all drafts have been discarded.
|
|
628
|
+
*/
|
|
629
|
+
export async function discardAllDrafts(appRootDirPath) {
|
|
630
|
+
const { edits, additions, deletions } = await getDraftInfo(appRootDirPath);
|
|
631
|
+
// Additions go first: a draft can replace an original file with a directory, or the reverse.
|
|
632
|
+
for (const relativePath of additions) {
|
|
633
|
+
await fsp.rm(path.join(appRootDirPath, relativePath), { force: true });
|
|
634
|
+
await retireMarker(relativePath, ADD_EXT, appRootDirPath);
|
|
635
|
+
}
|
|
636
|
+
for (const relativePath of edits) {
|
|
637
|
+
await restoreOriginal(relativePath, EDIT_EXT, appRootDirPath);
|
|
638
|
+
await retireMarker(relativePath, EDIT_EXT, appRootDirPath);
|
|
639
|
+
}
|
|
640
|
+
for (const relativePath of deletions) {
|
|
641
|
+
await restoreOriginal(relativePath, DELETE_EXT, appRootDirPath);
|
|
642
|
+
await retireMarker(relativePath, DELETE_EXT, appRootDirPath);
|
|
643
|
+
}
|
|
644
|
+
await cleanUpDraftsDirectory(appRootDirPath);
|
|
645
|
+
}
|
|
646
|
+
async function retireMarker(relativePath, markerType, appRootDirPath) {
|
|
647
|
+
await fsp.rm(getMarkerFilePath(path.join(appRootDirPath, relativePath), appRootDirPath, markerType), { force: true });
|
|
648
|
+
}
|
|
649
|
+
async function restoreOriginal(relativePath, markerType, appRootDirPath) {
|
|
650
|
+
const filePath = path.join(appRootDirPath, relativePath);
|
|
651
|
+
const dirPath = path.dirname(filePath);
|
|
652
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
653
|
+
const realRelativeDirPath = path.relative(await fsp.realpath(appRootDirPath), await fsp.realpath(dirPath));
|
|
654
|
+
if (realRelativeDirPath === ".." ||
|
|
655
|
+
realRelativeDirPath.startsWith(`..${path.sep}`) ||
|
|
656
|
+
path.isAbsolute(realRelativeDirPath)) {
|
|
657
|
+
throw new Error(`Cannot restore a file outside the app root: ${relativePath}`);
|
|
658
|
+
}
|
|
659
|
+
// Clear the path first, so the copy does not follow a symlink or hit an emptied directory.
|
|
660
|
+
const stats = await fsp.lstat(filePath).catch(() => undefined);
|
|
661
|
+
if (stats?.isDirectory()) {
|
|
662
|
+
await removeEmptyDirectories(filePath);
|
|
663
|
+
}
|
|
664
|
+
else if (stats) {
|
|
665
|
+
await fsp.unlink(filePath);
|
|
666
|
+
}
|
|
667
|
+
await fsp.copyFile(getMarkerFilePath(filePath, appRootDirPath, markerType), filePath);
|
|
668
|
+
}
|
|
669
|
+
async function removeEmptyDirectories(dirPath) {
|
|
670
|
+
for (const entry of await fsp.readdir(dirPath, { withFileTypes: true })) {
|
|
671
|
+
if (!entry.isDirectory()) {
|
|
672
|
+
throw new Error(`Cannot restore a file over a directory: ${dirPath}`);
|
|
673
|
+
}
|
|
674
|
+
await removeEmptyDirectories(path.join(dirPath, entry.name));
|
|
675
|
+
}
|
|
676
|
+
await fsp.rmdir(dirPath);
|
|
677
|
+
}
|
|
678
|
+
export async function cleanUpDraftsDirectory(appRootDirPath) {
|
|
679
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
680
|
+
try {
|
|
681
|
+
await fsp.rm(draftsDir, { recursive: true });
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Ignore cleanup errors
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Commits all draft changes by removing .sbswap.edit and .sbswap.add markers.
|
|
689
|
+
* This leaves the current filesystem state as the committed state.
|
|
690
|
+
*
|
|
691
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
692
|
+
* @returns A promise that resolves when all drafts have been committed.
|
|
693
|
+
*/
|
|
694
|
+
export async function commitAllDrafts(appRootDirPath) {
|
|
695
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
696
|
+
try {
|
|
697
|
+
await fsp.access(draftsDir);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// Drafts directory doesn't exist, nothing to commit
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// Simply remove the entire drafts directory
|
|
704
|
+
try {
|
|
705
|
+
await fsp.rm(draftsDir, { recursive: true });
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// Ignore deletion errors
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Gets information about all draft files in the drafts directory.
|
|
713
|
+
*
|
|
714
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
715
|
+
* @returns A promise that resolves to information about draft files.
|
|
716
|
+
*/
|
|
717
|
+
export async function getDraftInfo(appRootDirPath) {
|
|
718
|
+
const edits = [];
|
|
719
|
+
const additions = [];
|
|
720
|
+
const deletions = [];
|
|
721
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
722
|
+
try {
|
|
723
|
+
await fsp.access(draftsDir);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// Drafts directory doesn't exist, no drafts
|
|
727
|
+
return { edits, additions, deletions };
|
|
728
|
+
}
|
|
729
|
+
async function scanDirectory(currentPath) {
|
|
730
|
+
try {
|
|
731
|
+
const entries = await fsp.readdir(currentPath, { withFileTypes: true });
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
734
|
+
if (entry.isFile()) {
|
|
735
|
+
if (entry.name.endsWith(EDIT_EXT)) {
|
|
736
|
+
const markerRelativePath = path.relative(draftsDir, fullPath);
|
|
737
|
+
const originalRelativePath = markerRelativePath.slice(0, -EDIT_EXT.length);
|
|
738
|
+
edits.push(originalRelativePath);
|
|
739
|
+
}
|
|
740
|
+
else if (entry.name.endsWith(ADD_EXT)) {
|
|
741
|
+
const markerRelativePath = path.relative(draftsDir, fullPath);
|
|
742
|
+
const addedFileRelativePath = markerRelativePath.slice(0, -ADD_EXT.length);
|
|
743
|
+
additions.push(addedFileRelativePath);
|
|
744
|
+
}
|
|
745
|
+
else if (entry.name.endsWith(DELETE_EXT)) {
|
|
746
|
+
const markerRelativePath = path.relative(draftsDir, fullPath);
|
|
747
|
+
const deletedFileRelativePath = markerRelativePath.slice(0, -DELETE_EXT.length);
|
|
748
|
+
deletions.push(deletedFileRelativePath);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (entry.isDirectory()) {
|
|
752
|
+
await scanDirectory(fullPath);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Ignore errors reading directories
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
await scanDirectory(draftsDir);
|
|
761
|
+
return { edits, additions, deletions };
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Generates a git-like diff showing all draft changes.
|
|
765
|
+
*
|
|
766
|
+
* @param appRootDirPath - The absolute path to the app root directory.
|
|
767
|
+
* @param specificPaths - An optional array of relative paths to limit the diff to.
|
|
768
|
+
* @returns A promise that resolves to a git-like diff string.
|
|
769
|
+
*/
|
|
770
|
+
export async function generateDraftDiff(appRootDirPath, specificPaths = []) {
|
|
771
|
+
const draftsDir = getDraftsDirectoryPath(appRootDirPath);
|
|
772
|
+
try {
|
|
773
|
+
await fsp.access(draftsDir);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
return "No draft changes found.";
|
|
777
|
+
}
|
|
778
|
+
const diffLines = [];
|
|
779
|
+
let { edits, additions, deletions } = await getDraftInfo(appRootDirPath);
|
|
780
|
+
edits = edits.filter((edit) => specificPaths.length === 0 ||
|
|
781
|
+
specificPaths.some((path) => edit.startsWith(path)));
|
|
782
|
+
additions = additions.filter((add) => specificPaths.length === 0 ||
|
|
783
|
+
specificPaths.some((path) => add.startsWith(path)));
|
|
784
|
+
deletions = deletions.filter((del) => specificPaths.length === 0 ||
|
|
785
|
+
specificPaths.some((path) => del.startsWith(path)));
|
|
786
|
+
if (edits.length === 0 && additions.length === 0 && deletions.length === 0) {
|
|
787
|
+
return "No draft changes found.";
|
|
788
|
+
}
|
|
789
|
+
// Process draft edits
|
|
790
|
+
for (const editPath of edits) {
|
|
791
|
+
const originalPath = path.join(appRootDirPath, editPath);
|
|
792
|
+
const markerPath = getMarkerFilePath(originalPath, appRootDirPath, EDIT_EXT);
|
|
793
|
+
try {
|
|
794
|
+
const currentContent = await fsp.readFile(originalPath, "utf8");
|
|
795
|
+
const originalContent = await fsp.readFile(markerPath, "utf8");
|
|
796
|
+
const patch = createPatch(editPath, originalContent, currentContent);
|
|
797
|
+
diffLines.push(patch);
|
|
798
|
+
diffLines.push(""); // Empty line between files
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
// Skip files that can't be read
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// Process draft additions
|
|
806
|
+
for (const addPath of additions) {
|
|
807
|
+
const filePath = path.join(appRootDirPath, addPath);
|
|
808
|
+
try {
|
|
809
|
+
const content = await fsp.readFile(filePath, "utf8");
|
|
810
|
+
const patch = createPatch(addPath, "", content);
|
|
811
|
+
diffLines.push(patch);
|
|
812
|
+
diffLines.push(""); // Empty line between files
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
// Skip files that can't be read
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Process draft deletions
|
|
820
|
+
// note: this is pretty verbose showing each line as deleted but it matches how we do diffs for added files
|
|
821
|
+
for (const deletePath of deletions) {
|
|
822
|
+
const markerPath = getMarkerFilePath(path.join(appRootDirPath, deletePath), appRootDirPath, DELETE_EXT);
|
|
823
|
+
try {
|
|
824
|
+
const originalContent = await fsp.readFile(markerPath, "utf8");
|
|
825
|
+
const patch = createPatch(deletePath, originalContent, "");
|
|
826
|
+
diffLines.push(patch);
|
|
827
|
+
diffLines.push(""); // Empty line between files
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// Skip files that can't be read
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Remove trailing empty line
|
|
835
|
+
if (diffLines[diffLines.length - 1] === "") {
|
|
836
|
+
diffLines.pop();
|
|
837
|
+
}
|
|
838
|
+
return diffLines.join("\n");
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=draft-helpers.js.map
|