collabmd 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +13 -11
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  6. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  7. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  8. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  9. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  10. package/public/assets/js/excalidraw-editor.js +1 -1
  11. package/public/assets/js/main.js +102 -75
  12. package/public/assets/js/preview-render-worker.js +15 -15
  13. package/public/index.html +14 -4
  14. package/src/client/application/app-shell/git-feature.js +86 -19
  15. package/src/client/application/app-shell/ui-feature.js +4 -0
  16. package/src/client/application/app-shell-elements.js +1 -0
  17. package/src/client/bootstrap/collabmd-app-shell.js +16 -0
  18. package/src/client/domain/vault-utils.js +2 -2
  19. package/src/client/excalidraw-editor.js +2 -1
  20. package/src/client/infrastructure/editor-session.js +4 -0
  21. package/src/client/infrastructure/editor-view-adapter.js +245 -7
  22. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  23. package/src/client/presentation/backlinks-panel.js +157 -101
  24. package/src/client/presentation/comment-ui-controller.js +233 -10
  25. package/src/client/presentation/file-explorer-controller.js +15 -3
  26. package/src/client/presentation/file-explorer-view.js +152 -11
  27. package/src/client/presentation/git-panel-controller.js +73 -0
  28. package/src/client/presentation/outline-controller.js +25 -0
  29. package/src/client/styles/style.css +184 -9
  30. package/src/domain/wiki-link-resolver.js +16 -5
  31. package/src/domain/workspace-change.js +68 -0
  32. package/src/domain/workspace-room.js +3 -0
  33. package/src/server/create-app-server.js +41 -10
  34. package/src/server/domain/backlink-index.js +94 -1
  35. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  36. package/src/server/domain/collaboration/room-registry.js +64 -10
  37. package/src/server/infrastructure/git/errors.js +4 -1
  38. package/src/server/infrastructure/git/git-service.js +215 -1
  39. package/src/server/infrastructure/git/responses.js +12 -19
  40. package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
  41. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  42. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  43. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  44. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  45. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  46. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  47. package/src/server/infrastructure/http/http-response.js +65 -28
  48. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  49. package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
  50. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  51. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  52. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  53. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
@@ -3,6 +3,7 @@ import {
3
3
  isMermaidFilePath,
4
4
  isPlantUmlFilePath,
5
5
  } from '../../../domain/file-kind.js';
6
+ import { createWorkspaceChange } from '../../../domain/workspace-change.js';
6
7
  import { createRequestError, getRequestErrorStatusCode } from './http-errors.js';
7
8
  import { jsonResponse } from './http-response.js';
8
9
  import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
@@ -36,6 +37,11 @@ function selectWriteOperation(vaultFileStore, filePath, content) {
36
37
  return vaultFileStore.writeMarkdownFile(filePath, content);
37
38
  }
38
39
 
40
+ function readRequestId(req) {
41
+ const value = String(req.headers['x-collabmd-request-id'] || '').trim();
42
+ return value ? value.slice(0, 120) : null;
43
+ }
44
+
39
45
  function handleVaultError(req, res, error, logMessage, fallbackMessage) {
40
46
  const statusCode = getRequestErrorStatusCode(error);
41
47
  if (statusCode) {
@@ -49,9 +55,8 @@ function handleVaultError(req, res, error, logMessage, fallbackMessage) {
49
55
  }
50
56
 
51
57
  export function createVaultApiCommandHandler({
52
- backlinkIndex,
53
- roomRegistry = null,
54
58
  vaultFileStore,
59
+ workspaceMutationCoordinator = null,
55
60
  }) {
56
61
  return async function handleVaultApiCommand(req, res, requestUrl) {
57
62
  if (requestUrl.pathname === '/api/file' && req.method === 'PUT') {
@@ -68,6 +73,15 @@ export function createVaultApiCommandHandler({
68
73
  return true;
69
74
  }
70
75
 
76
+ await workspaceMutationCoordinator?.apply?.({
77
+ action: 'write-file',
78
+ origin: 'api',
79
+ requestId: readRequestId(req),
80
+ workspaceChange: createWorkspaceChange({
81
+ changedPaths: [body.path],
82
+ }),
83
+ });
84
+
71
85
  jsonResponse(req, res, 200, { ok: true });
72
86
  } catch (error) {
73
87
  handleVaultError(req, res, error, '[api] Failed to write file:', 'Failed to write file');
@@ -97,6 +111,15 @@ export function createVaultApiCommandHandler({
97
111
  return true;
98
112
  }
99
113
 
114
+ await workspaceMutationCoordinator?.apply?.({
115
+ action: 'upload-attachment',
116
+ origin: 'api',
117
+ requestId: readRequestId(req),
118
+ workspaceChange: createWorkspaceChange({
119
+ changedPaths: [result.path],
120
+ }),
121
+ });
122
+
100
123
  jsonResponse(req, res, 201, {
101
124
  markdown: result.markdownSnippet,
102
125
  ok: true,
@@ -121,8 +144,14 @@ export function createVaultApiCommandHandler({
121
144
  jsonResponse(req, res, 409, { error: result.error });
122
145
  return true;
123
146
  }
124
-
125
- backlinkIndex?.onFileCreated(body.path, body.content || '');
147
+ await workspaceMutationCoordinator?.apply?.({
148
+ action: 'create-file',
149
+ origin: 'api',
150
+ requestId: readRequestId(req),
151
+ workspaceChange: createWorkspaceChange({
152
+ changedPaths: [body.path],
153
+ }),
154
+ });
126
155
  jsonResponse(req, res, 201, { ok: true, path: body.path });
127
156
  } catch (error) {
128
157
  handleVaultError(req, res, error, '[api] Failed to create file:', 'Failed to create file');
@@ -137,21 +166,22 @@ export function createVaultApiCommandHandler({
137
166
  return true;
138
167
  }
139
168
 
140
- const activeRoom = roomRegistry?.get(filePath);
141
169
  try {
142
- activeRoom?.markDeleted?.();
143
170
  const result = await vaultFileStore.deleteFile(filePath);
144
171
  if (!result.ok) {
145
- activeRoom?.unmarkDeleted?.();
146
172
  jsonResponse(req, res, 400, { error: result.error });
147
173
  return true;
148
174
  }
149
-
150
- roomRegistry?.delete?.(filePath);
151
- backlinkIndex?.onFileDeleted(filePath);
175
+ await workspaceMutationCoordinator?.apply?.({
176
+ action: 'delete-file',
177
+ origin: 'api',
178
+ requestId: readRequestId(req),
179
+ workspaceChange: createWorkspaceChange({
180
+ deletedPaths: [filePath],
181
+ }),
182
+ });
152
183
  jsonResponse(req, res, 200, { ok: true });
153
184
  } catch (error) {
154
- activeRoom?.unmarkDeleted?.();
155
185
  console.error('[api] Failed to delete file:', error.message);
156
186
  jsonResponse(req, res, 500, { error: 'Failed to delete file' });
157
187
  }
@@ -171,9 +201,14 @@ export function createVaultApiCommandHandler({
171
201
  jsonResponse(req, res, 400, { error: result.error });
172
202
  return true;
173
203
  }
174
-
175
- roomRegistry?.rename(body.oldPath, body.newPath);
176
- backlinkIndex?.onFileRenamed(body.oldPath, body.newPath);
204
+ await workspaceMutationCoordinator?.apply?.({
205
+ action: 'rename-file',
206
+ origin: 'api',
207
+ requestId: readRequestId(req),
208
+ workspaceChange: createWorkspaceChange({
209
+ renamedPaths: [{ oldPath: body.oldPath, newPath: body.newPath }],
210
+ }),
211
+ });
177
212
  jsonResponse(req, res, 200, { ok: true, path: body.newPath });
178
213
  } catch (error) {
179
214
  handleVaultError(req, res, error, '[api] Failed to rename file:', 'Failed to rename file');
@@ -195,6 +230,15 @@ export function createVaultApiCommandHandler({
195
230
  return true;
196
231
  }
197
232
 
233
+ await workspaceMutationCoordinator?.apply?.({
234
+ action: 'create-directory',
235
+ origin: 'api',
236
+ requestId: readRequestId(req),
237
+ workspaceChange: createWorkspaceChange({
238
+ changedPaths: [body.path],
239
+ }),
240
+ });
241
+
198
242
  jsonResponse(req, res, 201, { ok: true });
199
243
  } catch (error) {
200
244
  handleVaultError(req, res, error, '[api] Failed to create directory:', 'Failed to create directory');
@@ -7,15 +7,18 @@ export function createVaultApiHandler({
7
7
  plantUmlRenderer = null,
8
8
  roomRegistry = null,
9
9
  vaultFileStore,
10
+ workspaceMutationCoordinator = null,
10
11
  }) {
11
12
  const handleVaultApiQuery = createVaultApiQueryHandler({
12
13
  backlinkIndex,
13
14
  vaultFileStore,
15
+ workspaceMutationCoordinator,
14
16
  });
15
17
  const handleVaultApiCommand = createVaultApiCommandHandler({
16
18
  backlinkIndex,
17
19
  roomRegistry,
18
20
  vaultFileStore,
21
+ workspaceMutationCoordinator,
19
22
  });
20
23
  const handlePlantUmlApi = createPlantUmlApiHandler({
21
24
  plantUmlRenderer,
@@ -59,11 +59,13 @@ function selectReadOperation(vaultFileStore, filePath) {
59
59
  export function createVaultApiQueryHandler({
60
60
  backlinkIndex,
61
61
  vaultFileStore,
62
+ workspaceMutationCoordinator = null,
62
63
  }) {
63
64
  return async function handleVaultApiQuery(req, res, requestUrl) {
64
65
  if (requestUrl.pathname === '/api/files' && req.method === 'GET') {
65
66
  try {
66
- jsonResponse(req, res, 200, { tree: await vaultFileStore.tree() });
67
+ const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
68
+ jsonResponse(req, res, 200, { tree });
67
69
  } catch (error) {
68
70
  console.error('[api] Failed to read file tree:', error.message);
69
71
  jsonResponse(req, res, 500, { error: 'Failed to read file tree' });
@@ -1,4 +1,8 @@
1
- import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib';
1
+ import {
2
+ brotliCompress,
3
+ constants as zlibConstants,
4
+ gzip,
5
+ } from 'node:zlib';
2
6
 
3
7
  const COMPRESSIBLE_CONTENT_TYPE_PATTERN = /^(?:text\/|application\/(?:javascript|json|xml)|image\/svg\+xml)/i;
4
8
  const MIN_COMPRESSIBLE_BYTES = 1024;
@@ -41,9 +45,9 @@ function resolveCompressionEncoding(acceptEncodingHeader) {
41
45
  return null;
42
46
  }
43
47
 
44
- function maybeCompressBody(req, body, contentType) {
48
+ function prepareBody(req, body, contentType) {
45
49
  if (body === undefined || body === null) {
46
- return { body: null, compressed: false, encoding: null };
50
+ return { body: null, encoding: null };
47
51
  }
48
52
 
49
53
  const bodyBuffer = Buffer.isBuffer(body)
@@ -54,27 +58,48 @@ function maybeCompressBody(req, body, contentType) {
54
58
  bodyBuffer.byteLength < MIN_COMPRESSIBLE_BYTES
55
59
  || !COMPRESSIBLE_CONTENT_TYPE_PATTERN.test(String(contentType || ''))
56
60
  ) {
57
- return { body: bodyBuffer, compressed: false, encoding: null };
61
+ return { body: bodyBuffer, encoding: null };
58
62
  }
59
63
 
60
64
  const encoding = resolveCompressionEncoding(req.headers['accept-encoding']);
61
65
  if (!encoding) {
62
- return { body: bodyBuffer, compressed: false, encoding: null };
66
+ return { body: bodyBuffer, encoding: null };
63
67
  }
64
68
 
65
- const compressedBody = encoding === 'br'
66
- ? brotliCompressSync(bodyBuffer, {
69
+ return { body: bodyBuffer, encoding };
70
+ }
71
+
72
+ function compressBody(bodyBuffer, encoding, callback) {
73
+ if (encoding === 'br') {
74
+ brotliCompress(bodyBuffer, {
67
75
  params: {
68
76
  [zlibConstants.BROTLI_PARAM_QUALITY]: 5,
69
77
  },
70
- })
71
- : gzipSync(bodyBuffer, { level: 6 });
78
+ }, callback);
79
+ return;
80
+ }
81
+
82
+ gzip(bodyBuffer, { level: 6 }, callback);
83
+ }
84
+
85
+ function writeResponseHead(res, statusCode, headers, bodyBuffer) {
86
+ const responseHeaders = { ...headers };
87
+ if (bodyBuffer) {
88
+ responseHeaders['Content-Length'] = String(bodyBuffer.byteLength);
89
+ }
72
90
 
73
- if (compressedBody.byteLength >= bodyBuffer.byteLength) {
74
- return { body: bodyBuffer, compressed: false, encoding: null };
91
+ res.writeHead(statusCode, responseHeaders);
92
+ }
93
+
94
+ function writePreparedBody(req, res, statusCode, headers, bodyBuffer) {
95
+ writeResponseHead(res, statusCode, headers, bodyBuffer);
96
+
97
+ if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
98
+ res.end();
99
+ return;
75
100
  }
76
101
 
77
- return { body: compressedBody, compressed: true, encoding };
102
+ res.end(bodyBuffer ?? undefined);
78
103
  }
79
104
 
80
105
  export function setHeaders(res, headers) {
@@ -116,30 +141,42 @@ export function sendResponse(req, res, {
116
141
  statusCode = 200,
117
142
  } = {}) {
118
143
  const contentType = headers['Content-Type'] || headers['content-type'] || '';
119
- const prepared = maybeCompressBody(req, body, contentType);
144
+ const prepared = prepareBody(req, body, contentType);
120
145
 
121
146
  if (body !== null) {
122
147
  appendVaryHeader(res, 'Accept-Encoding');
123
148
  }
124
149
 
125
- const responseHeaders = { ...headers };
126
-
127
- if (prepared.compressed && prepared.encoding) {
128
- responseHeaders['Content-Encoding'] = prepared.encoding;
129
- }
130
-
131
- if (prepared.body) {
132
- responseHeaders['Content-Length'] = String(prepared.body.byteLength);
133
- }
134
-
135
- res.writeHead(statusCode, responseHeaders);
136
-
137
- if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
138
- res.end();
150
+ if (!prepared.body || !prepared.encoding || req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
151
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
139
152
  return;
140
153
  }
141
154
 
142
- res.end(prepared.body ?? undefined);
155
+ compressBody(prepared.body, prepared.encoding, (error, compressedBody) => {
156
+ if (res.writableEnded || res.destroyed) {
157
+ return;
158
+ }
159
+
160
+ if (error) {
161
+ console.error('[http] Failed to compress response body:', error.message);
162
+ if (!res.headersSent) {
163
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
164
+ return;
165
+ }
166
+ res.destroy(error);
167
+ return;
168
+ }
169
+
170
+ if (!compressedBody || compressedBody.byteLength >= prepared.body.byteLength) {
171
+ writePreparedBody(req, res, statusCode, headers, prepared.body);
172
+ return;
173
+ }
174
+
175
+ writePreparedBody(req, res, statusCode, {
176
+ ...headers,
177
+ 'Content-Encoding': prepared.encoding,
178
+ }, compressedBody);
179
+ });
143
180
  }
144
181
 
145
182
  export function jsonResponse(req, res, statusCode, data) {
@@ -0,0 +1,283 @@
1
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ import { sanitizeVaultPath } from './path-utils.js';
5
+
6
+ const PULL_BACKUP_STORAGE_ROOT = '.collabmd/pull-backups';
7
+ const BACKUP_METADATA_PREFIX = '<!-- collabmd-pull-backup ';
8
+ const BACKUP_METADATA_SUFFIX = ' -->';
9
+
10
+ function normalizeRelativePath(pathValue = '') {
11
+ return String(pathValue).replace(/\\/g, '/');
12
+ }
13
+
14
+ function createBackupId(headRef = null, createdAt = new Date().toISOString()) {
15
+ const timestamp = normalizeRelativePath(createdAt)
16
+ .replace(/[^0-9T]/g, '')
17
+ .replace('T', '-')
18
+ .replace(/Z$/u, '');
19
+ const shortHead = String(headRef ?? 'workspace').trim().slice(0, 7) || 'workspace';
20
+ return `${timestamp}-${shortHead}`;
21
+ }
22
+
23
+ function formatBackupMetadata(metadata = {}) {
24
+ return `${BACKUP_METADATA_PREFIX}${JSON.stringify(metadata)}${BACKUP_METADATA_SUFFIX}`;
25
+ }
26
+
27
+ function parseBackupMetadata(content = '') {
28
+ const firstLine = String(content).split(/\r?\n/u)[0] ?? '';
29
+ if (!firstLine.startsWith(BACKUP_METADATA_PREFIX) || !firstLine.endsWith(BACKUP_METADATA_SUFFIX)) {
30
+ return null;
31
+ }
32
+
33
+ try {
34
+ return JSON.parse(firstLine.slice(BACKUP_METADATA_PREFIX.length, -BACKUP_METADATA_SUFFIX.length));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function formatSummary({
41
+ backupEntries = [],
42
+ backupId,
43
+ branch = null,
44
+ createdAt,
45
+ fileCount = 0,
46
+ headRef = null,
47
+ targetRef = null,
48
+ } = {}) {
49
+ const metadata = formatBackupMetadata({
50
+ backupId,
51
+ branch,
52
+ createdAt,
53
+ fileCount,
54
+ headRef,
55
+ targetRef,
56
+ });
57
+ const entryLines = backupEntries.length === 0
58
+ ? ['No overlapping files could be copied from the worktree.']
59
+ : backupEntries.flatMap((entry) => {
60
+ const lines = [`- Original path: \`${entry.path}\``];
61
+ if (entry.backupPath) {
62
+ lines.push(` Saved copy: \`${entry.backupPath}\``);
63
+ } else {
64
+ lines.push(' Saved copy: none (the file no longer existed in the worktree at backup time)');
65
+ }
66
+ if (entry.stagedPatchPath) {
67
+ lines.push(` Staged patch: \`${entry.stagedPatchPath}\``);
68
+ }
69
+ if (entry.worktreePatchPath) {
70
+ lines.push(` Worktree patch: \`${entry.worktreePatchPath}\``);
71
+ }
72
+ return lines;
73
+ });
74
+
75
+ return [
76
+ metadata,
77
+ '# Pull Backup',
78
+ '',
79
+ `Created: \`${createdAt}\``,
80
+ `Branch: \`${branch || 'HEAD'}\``,
81
+ `Base ref: \`${headRef || 'none'}\``,
82
+ `Pulled to: \`${targetRef || 'none'}\``,
83
+ `Files backed up: \`${fileCount}\``,
84
+ '',
85
+ 'This backup was created because local dirty changes overlapped with incoming remote updates.',
86
+ 'The working tree was updated to the remote version so the latest upstream content is visible immediately.',
87
+ '',
88
+ '## Saved Files',
89
+ '',
90
+ ...entryLines,
91
+ '',
92
+ '## Recovery',
93
+ '',
94
+ 'Compare the current file content against the saved copies above, then manually restore any changes you want to keep.',
95
+ 'Use `git apply --cached <staged patch>` to inspect or restore staged/index-only changes on a safe branch.',
96
+ 'Use `git apply <worktree patch>` to inspect or restore unstaged worktree changes on a safe branch.',
97
+ '',
98
+ ].join('\n');
99
+ }
100
+
101
+ async function ensureLocalGitExclude(vaultDir) {
102
+ const excludePath = resolve(vaultDir, '.git/info/exclude');
103
+ let existingContent = '';
104
+
105
+ try {
106
+ existingContent = await readFile(excludePath, 'utf8');
107
+ } catch (error) {
108
+ if (error?.code !== 'ENOENT') {
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ const lines = existingContent
114
+ .split(/\r?\n/u)
115
+ .map((line) => line.trim())
116
+ .filter(Boolean);
117
+ if (lines.includes('.collabmd') || lines.includes('.collabmd/')) {
118
+ return;
119
+ }
120
+
121
+ const prefix = existingContent.length > 0 && !existingContent.endsWith('\n')
122
+ ? '\n'
123
+ : '';
124
+ await writeFile(excludePath, `${existingContent}${prefix}.collabmd/\n`, 'utf8');
125
+ }
126
+
127
+ export class PullBackupStore {
128
+ constructor({ vaultDir }) {
129
+ this.vaultDir = resolve(vaultDir);
130
+ }
131
+
132
+ getStorageRoot() {
133
+ return resolve(this.vaultDir, PULL_BACKUP_STORAGE_ROOT);
134
+ }
135
+
136
+ getBackupPath(backupId) {
137
+ return resolve(this.getStorageRoot(), backupId);
138
+ }
139
+
140
+ getSummaryPath(backupId) {
141
+ return `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/summary.md`;
142
+ }
143
+
144
+ async createBackup({
145
+ branch = null,
146
+ createdAt = new Date().toISOString(),
147
+ entries = [],
148
+ headRef = null,
149
+ targetRef = null,
150
+ } = {}) {
151
+ await ensureLocalGitExclude(this.vaultDir);
152
+
153
+ const backupId = createBackupId(headRef, createdAt);
154
+ const backupDir = this.getBackupPath(backupId);
155
+ const filesRoot = join(backupDir, 'files');
156
+ const patchesRoot = join(backupDir, 'patches');
157
+ const backupEntries = [];
158
+
159
+ await mkdir(filesRoot, { recursive: true });
160
+ await mkdir(patchesRoot, { recursive: true });
161
+
162
+ for (const entry of entries) {
163
+ const normalizedPath = normalizeRelativePath(entry?.path);
164
+ if (!normalizedPath) {
165
+ continue;
166
+ }
167
+
168
+ const absoluteSourcePath = sanitizeVaultPath(this.vaultDir, normalizedPath);
169
+ const relativeBackupPath = `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/files/${normalizedPath}`;
170
+ const relativeStagedPatchPath = entry?.stagedPatchContent
171
+ ? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.staged.patch`
172
+ : null;
173
+ const relativeWorktreePatchPath = entry?.worktreePatchContent
174
+ ? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.worktree.patch`
175
+ : null;
176
+ let backupPath = null;
177
+
178
+ if (absoluteSourcePath) {
179
+ try {
180
+ await stat(absoluteSourcePath);
181
+ const absoluteBackupPath = resolve(this.vaultDir, relativeBackupPath);
182
+ await mkdir(dirname(absoluteBackupPath), { recursive: true });
183
+ await copyFile(absoluteSourcePath, absoluteBackupPath);
184
+ backupPath = normalizeRelativePath(relativeBackupPath);
185
+ } catch (error) {
186
+ if (error?.code !== 'ENOENT') {
187
+ throw error;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (relativeStagedPatchPath) {
193
+ const absolutePatchPath = resolve(this.vaultDir, relativeStagedPatchPath);
194
+ await mkdir(dirname(absolutePatchPath), { recursive: true });
195
+ await writeFile(absolutePatchPath, entry.stagedPatchContent, 'utf8');
196
+ }
197
+
198
+ if (relativeWorktreePatchPath) {
199
+ const absolutePatchPath = resolve(this.vaultDir, relativeWorktreePatchPath);
200
+ await mkdir(dirname(absolutePatchPath), { recursive: true });
201
+ await writeFile(absolutePatchPath, entry.worktreePatchContent, 'utf8');
202
+ }
203
+
204
+ backupEntries.push({
205
+ backupPath,
206
+ oldPath: normalizeRelativePath(entry?.oldPath ?? ''),
207
+ path: normalizedPath,
208
+ stagedPatchPath: relativeStagedPatchPath ? normalizeRelativePath(relativeStagedPatchPath) : null,
209
+ worktreePatchPath: relativeWorktreePatchPath ? normalizeRelativePath(relativeWorktreePatchPath) : null,
210
+ });
211
+ }
212
+
213
+ const fileCount = backupEntries.filter((entry) => (
214
+ entry.backupPath
215
+ || entry.stagedPatchPath
216
+ || entry.worktreePatchPath
217
+ )).length;
218
+ const summaryPath = this.getSummaryPath(backupId);
219
+ await writeFile(
220
+ resolve(this.vaultDir, summaryPath),
221
+ formatSummary({
222
+ backupEntries,
223
+ backupId,
224
+ branch,
225
+ createdAt,
226
+ fileCount,
227
+ headRef,
228
+ targetRef,
229
+ }),
230
+ 'utf8',
231
+ );
232
+
233
+ return {
234
+ branch,
235
+ createdAt,
236
+ fileCount,
237
+ id: backupId,
238
+ summaryPath,
239
+ };
240
+ }
241
+
242
+ async listBackups() {
243
+ let entries = [];
244
+ try {
245
+ entries = await readdir(this.getStorageRoot(), { withFileTypes: true });
246
+ } catch (error) {
247
+ if (error?.code === 'ENOENT') {
248
+ return [];
249
+ }
250
+ throw error;
251
+ }
252
+
253
+ const backups = await Promise.all(entries
254
+ .filter((entry) => entry.isDirectory())
255
+ .map(async (entry) => {
256
+ const summaryPath = this.getSummaryPath(entry.name);
257
+ try {
258
+ const content = await readFile(resolve(this.vaultDir, summaryPath), 'utf8');
259
+ const metadata = parseBackupMetadata(content);
260
+ if (!metadata) {
261
+ return null;
262
+ }
263
+
264
+ return {
265
+ branch: metadata.branch ?? null,
266
+ createdAt: metadata.createdAt ?? null,
267
+ fileCount: Number(metadata.fileCount ?? 0) || 0,
268
+ id: entry.name,
269
+ summaryPath,
270
+ };
271
+ } catch (error) {
272
+ if (error?.code === 'ENOENT') {
273
+ return null;
274
+ }
275
+ throw error;
276
+ }
277
+ }));
278
+
279
+ return backups
280
+ .filter(Boolean)
281
+ .sort((left, right) => String(right.createdAt || '').localeCompare(String(left.createdAt || '')));
282
+ }
283
+ }