nano-git 0.2.3 → 0.3.1

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.
@@ -7,8 +7,7 @@ import { Database } from "bun:sqlite";
7
7
  /**
8
8
  * Virtual Workdir SQLite backend
9
9
  */
10
- const WORKDIR_SQLITE_SCHEMA_VERSION = 1;
11
- const WORKDIR_CHANGES_SESSION_ORDER_INDEX = "workdir_changes_session_id_id_idx";
10
+ const WORKDIR_SQLITE_SCHEMA_VERSION = 5;
12
11
  /**
13
12
  * 创建基于 SQLite 的 Virtual Workdir backend
14
13
  *
@@ -95,14 +94,55 @@ function createSqliteVirtualWorkdirStateStore(db, sessionId) {
95
94
  target = excluded.target,
96
95
  directory_overlay = excluded.directory_overlay`);
97
96
  const deleteNodeStmt = db.query("DELETE FROM workdir_nodes WHERE session_id = ? AND node_id = ?");
98
- const listChangesStmt = db.query("SELECT op, path, old_path FROM workdir_changes WHERE session_id = ? ORDER BY id");
99
- const insertChangeStmt = db.query("INSERT INTO workdir_changes (session_id, op, path, old_path) VALUES (?, ?, ?, ?)");
100
97
  const clearNodesStmt = db.query("DELETE FROM workdir_nodes WHERE session_id = ?");
98
+ const listChangesStmt = db.query(`SELECT path, previous_kind, previous_mode, previous_hash, current_kind, current_mode, current_hash, source_kind, source_path
99
+ FROM workdir_changes
100
+ WHERE session_id = ?
101
+ ORDER BY path`);
102
+ const getChangeStmt = db.query(`SELECT path, previous_kind, previous_mode, previous_hash, current_kind, current_mode, current_hash, source_kind, source_path
103
+ FROM workdir_changes
104
+ WHERE session_id = ? AND path = ?`);
105
+ const upsertChangeStmt = db.query(`INSERT INTO workdir_changes (
106
+ session_id, path, previous_kind, previous_mode, previous_hash,
107
+ current_kind, current_mode, current_hash, source_kind, source_path
108
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ ON CONFLICT(session_id, path) DO UPDATE SET
110
+ previous_kind = excluded.previous_kind,
111
+ previous_mode = excluded.previous_mode,
112
+ previous_hash = excluded.previous_hash,
113
+ current_kind = excluded.current_kind,
114
+ current_mode = excluded.current_mode,
115
+ current_hash = excluded.current_hash,
116
+ source_kind = excluded.source_kind,
117
+ source_path = excluded.source_path`);
118
+ const deleteChangeStmt = db.query("DELETE FROM workdir_changes WHERE session_id = ? AND path = ?");
101
119
  const clearChangesStmt = db.query("DELETE FROM workdir_changes WHERE session_id = ?");
120
+ const listDirtyDirsStmt = db.query(`SELECT path, is_dirty, dirty_entry_count, dirty_descendant_count, affected_names, current_tree_hash, hash_state
121
+ FROM workdir_dirty_dirs
122
+ WHERE session_id = ?
123
+ ORDER BY path`);
124
+ const getDirtyDirStmt = db.query(`SELECT path, is_dirty, dirty_entry_count, dirty_descendant_count, affected_names, current_tree_hash, hash_state
125
+ FROM workdir_dirty_dirs
126
+ WHERE session_id = ? AND path = ?`);
127
+ const upsertDirtyDirStmt = db.query(`INSERT INTO workdir_dirty_dirs (
128
+ session_id, path, is_dirty, dirty_entry_count, dirty_descendant_count,
129
+ affected_names, current_tree_hash, hash_state
130
+ )
131
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
132
+ ON CONFLICT(session_id, path) DO UPDATE SET
133
+ is_dirty = excluded.is_dirty,
134
+ dirty_entry_count = excluded.dirty_entry_count,
135
+ dirty_descendant_count = excluded.dirty_descendant_count,
136
+ affected_names = excluded.affected_names,
137
+ current_tree_hash = excluded.current_tree_hash,
138
+ hash_state = excluded.hash_state`);
139
+ const deleteDirtyDirStmt = db.query("DELETE FROM workdir_dirty_dirs WHERE session_id = ? AND path = ?");
140
+ const clearDirtyDirsStmt = db.query("DELETE FROM workdir_dirty_dirs WHERE session_id = ?");
102
141
  const resetTx = db.transaction((baseTree) => {
103
142
  upsertSessionStmt.run(sessionId, baseTree);
104
143
  clearNodesStmt.run(sessionId);
105
144
  clearChangesStmt.run(sessionId);
145
+ clearDirtyDirsStmt.run(sessionId);
106
146
  writeNode(setNodeStmt, sessionId, createRootDirectoryNode(baseTree));
107
147
  });
108
148
  return {
@@ -129,14 +169,31 @@ function createSqliteVirtualWorkdirStateStore(db, sessionId) {
129
169
  deleteNode(id) {
130
170
  deleteNodeStmt.run(sessionId, id);
131
171
  },
132
- appendChange(record) {
133
- insertChangeStmt.run(sessionId, record.op, getRecordPath(record), getRecordOldPath(record));
134
- },
135
172
  listChangeRecords() {
136
173
  return listChangesStmt.all(sessionId).map(readChangeRecord);
137
174
  },
138
- clearChanges() {
139
- clearChangesStmt.run(sessionId);
175
+ getChangeRecord(path) {
176
+ const row = getChangeStmt.get(sessionId, path);
177
+ return row === null ? null : readChangeRecord(row);
178
+ },
179
+ setChangeRecord(record) {
180
+ upsertChangeStmt.run(sessionId, record.path, record.previous?.kind ?? null, record.previous?.mode ?? null, record.previous?.hash ?? null, record.current?.kind ?? null, record.current?.mode ?? null, record.current?.hash ?? null, record.source?.kind ?? null, record.source?.path ?? null);
181
+ },
182
+ deleteChangeRecord(path) {
183
+ deleteChangeStmt.run(sessionId, path);
184
+ },
185
+ listDirtyDirSummaries() {
186
+ return listDirtyDirsStmt.all(sessionId).map(readDirtyDirSummary);
187
+ },
188
+ getDirtyDirSummary(path) {
189
+ const row = getDirtyDirStmt.get(sessionId, path);
190
+ return row === null ? null : readDirtyDirSummary(row);
191
+ },
192
+ setDirtyDirSummary(summary) {
193
+ upsertDirtyDirStmt.run(sessionId, summary.path, summary.isDirty ? 1 : 0, summary.dirtyEntryCount, summary.dirtyDescendantCount, JSON.stringify(summary.affectedNames), summary.currentTreeHash, summary.hashState);
194
+ },
195
+ deleteDirtyDirSummary(path) {
196
+ deleteDirtyDirStmt.run(sessionId, path);
140
197
  },
141
198
  reset(baseTree) {
142
199
  resetTx(baseTree);
@@ -169,16 +226,31 @@ function ensureSchema(db) {
169
226
  `);
170
227
  db.run(`
171
228
  CREATE TABLE IF NOT EXISTS workdir_changes (
172
- id INTEGER PRIMARY KEY AUTOINCREMENT,
173
229
  session_id TEXT NOT NULL,
174
- op TEXT NOT NULL,
175
- path TEXT,
176
- old_path TEXT
230
+ path TEXT NOT NULL,
231
+ previous_kind TEXT,
232
+ previous_mode TEXT,
233
+ previous_hash TEXT,
234
+ current_kind TEXT,
235
+ current_mode TEXT,
236
+ current_hash TEXT,
237
+ source_kind TEXT,
238
+ source_path TEXT,
239
+ PRIMARY KEY (session_id, path)
177
240
  )
178
241
  `);
179
242
  db.run(`
180
- CREATE INDEX IF NOT EXISTS ${WORKDIR_CHANGES_SESSION_ORDER_INDEX}
181
- ON workdir_changes (session_id, id)
243
+ CREATE TABLE IF NOT EXISTS workdir_dirty_dirs (
244
+ session_id TEXT NOT NULL,
245
+ path TEXT NOT NULL,
246
+ is_dirty INTEGER NOT NULL,
247
+ dirty_entry_count INTEGER NOT NULL,
248
+ dirty_descendant_count INTEGER NOT NULL,
249
+ affected_names TEXT NOT NULL,
250
+ current_tree_hash TEXT,
251
+ hash_state TEXT NOT NULL,
252
+ PRIMARY KEY (session_id, path)
253
+ )
182
254
  `);
183
255
  writeSchemaVersion(db, WORKDIR_SQLITE_SCHEMA_VERSION);
184
256
  }
@@ -187,6 +259,7 @@ function hasSession(db, sessionId) {
187
259
  }
188
260
  function deleteSessionRows(db, sessionId) {
189
261
  db.transaction(() => {
262
+ db.query("DELETE FROM workdir_dirty_dirs WHERE session_id = ?").run(sessionId);
190
263
  db.query("DELETE FROM workdir_changes WHERE session_id = ?").run(sessionId);
191
264
  db.query("DELETE FROM workdir_nodes WHERE session_id = ?").run(sessionId);
192
265
  db.query("DELETE FROM workdir_sessions WHERE session_id = ?").run(sessionId);
@@ -286,6 +359,67 @@ function readNodeOrigin(row) {
286
359
  }
287
360
  throw new Error(`Invalid SQLite workdir node origin kind: ${row.origin_kind}`);
288
361
  }
362
+ function readChangeRecord(row) {
363
+ return {
364
+ path: row.path,
365
+ previous: row.previous_kind === null || row.previous_mode === null || row.previous_hash === null ? null : {
366
+ kind: readDiffObjectKind(row.previous_kind),
367
+ mode: readDiffObjectMode(row.previous_mode),
368
+ hash: row.previous_hash
369
+ },
370
+ current: row.current_kind === null || row.current_mode === null || row.current_hash === null ? null : {
371
+ kind: readDiffObjectKind(row.current_kind),
372
+ mode: readDiffObjectMode(row.current_mode),
373
+ hash: row.current_hash
374
+ },
375
+ source: row.source_kind === null || row.source_path === null ? null : {
376
+ kind: readDiffSourceKind(row.source_kind),
377
+ path: row.source_path
378
+ }
379
+ };
380
+ }
381
+ function readDiffObjectKind(raw) {
382
+ if (raw === "blob" || raw === "symlink") return raw;
383
+ throw new Error(`Invalid SQLite workdir diff object kind: ${raw}`);
384
+ }
385
+ function readDiffObjectMode(raw) {
386
+ if (raw === "100644" || raw === "100755" || raw === "120000") return raw;
387
+ throw new Error(`Invalid SQLite workdir diff object mode: ${raw}`);
388
+ }
389
+ function readDiffSourceKind(raw) {
390
+ if (raw === "rename" || raw === "copy") return raw;
391
+ throw new Error(`Invalid SQLite workdir diff source kind: ${raw}`);
392
+ }
393
+ function readDirtyDirSummary(row) {
394
+ const affectedNames = readDirtyDirAffectedNames(row.affected_names);
395
+ return {
396
+ path: row.path,
397
+ isDirty: row.is_dirty !== 0,
398
+ dirtyEntryCount: readDirtyDirCount(row.dirty_entry_count, "dirty_entry_count"),
399
+ dirtyDescendantCount: readDirtyDirCount(row.dirty_descendant_count, "dirty_descendant_count"),
400
+ affectedNames,
401
+ currentTreeHash: row.current_tree_hash === null ? null : sha1(row.current_tree_hash),
402
+ hashState: readDirtyDirHashState(row.hash_state)
403
+ };
404
+ }
405
+ function readDirtyDirCount(raw, field) {
406
+ if (!Number.isInteger(raw) || raw < 0) throw new Error(`Invalid SQLite workdir dirty dir ${field}: ${raw}`);
407
+ return raw;
408
+ }
409
+ function readDirtyDirAffectedNames(raw) {
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(raw);
413
+ } catch {
414
+ throw new Error("Invalid SQLite workdir dirty dir affected_names JSON");
415
+ }
416
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) throw new Error("Invalid SQLite workdir dirty dir affected_names payload");
417
+ return [...parsed].sort((left, right) => left.localeCompare(right));
418
+ }
419
+ function readDirtyDirHashState(raw) {
420
+ if (raw === "stale" || raw === "materialized") return raw;
421
+ throw new Error(`Invalid SQLite workdir dirty dir hash state: ${raw}`);
422
+ }
289
423
  function readDirectoryOverlay(raw) {
290
424
  if (raw === null) return {
291
425
  addedEntries: /* @__PURE__ */ new Map(),
@@ -326,38 +460,5 @@ function isDirectoryOverlayPayload(value) {
326
460
  const hasValidDeletedNames = maybe.deletedNames.every((name) => typeof name === "string");
327
461
  return hasValidAddedEntries && hasValidDeletedNames;
328
462
  }
329
- function getRecordPath(record) {
330
- switch (record.op) {
331
- case "add":
332
- case "modify":
333
- case "delete":
334
- case "revert": return record.path;
335
- case "rename":
336
- case "copy": return record.to;
337
- default: return record;
338
- }
339
- }
340
- function getRecordOldPath(record) {
341
- if (record.op === "rename" || record.op === "copy") return record.from;
342
- return null;
343
- }
344
- function readChangeRecord(row) {
345
- switch (row.op) {
346
- case "add":
347
- case "modify":
348
- case "delete":
349
- case "revert": return {
350
- op: row.op,
351
- path: row.path
352
- };
353
- case "rename":
354
- case "copy": return {
355
- op: row.op,
356
- from: row.old_path,
357
- to: row.path
358
- };
359
- default: throw new Error(`Unknown workdir change op: ${row.op}`);
360
- }
361
- }
362
463
  //#endregion
363
464
  export { createSqliteVirtualWorkdirBackend };
@@ -1,6 +1,7 @@
1
1
  import { SHA1 } from "../core/types.mjs";
2
- import { InternalChangeRecord } from "./change-log.mjs";
3
2
  import { NodeId } from "./ids.mjs";
3
+ import { NormalizedChangeRecord } from "./change-index.mjs";
4
+ import { DirtyDirSummary } from "./dirty-dir.mjs";
4
5
  import { SessionNode } from "./nodes.mjs";
5
6
 
6
7
  //#region src/workdir/state-store.d.ts
@@ -27,16 +28,26 @@ interface VirtualWorkdirStateStore {
27
28
  setNode(node: SessionNode): void;
28
29
  /** 删除节点 */
29
30
  deleteNode(id: NodeId): void;
30
- /** 追加内部变更记录 */
31
- appendChange(record: InternalChangeRecord): void;
32
- /** 列出内部变更记录快照 */
33
- listChangeRecords(): readonly InternalChangeRecord[];
34
- /** 清空变更记录 */
35
- clearChanges(): void;
31
+ /** 列出全部规范化变更记录 */
32
+ listChangeRecords(): readonly NormalizedChangeRecord[];
33
+ /** 按路径读取规范化变更记录 */
34
+ getChangeRecord(path: string): NormalizedChangeRecord | null;
35
+ /** 写入或覆盖规范化变更记录 */
36
+ setChangeRecord(record: NormalizedChangeRecord): void;
37
+ /** 删除规范化变更记录 */
38
+ deleteChangeRecord(path: string): void;
39
+ /** 列出全部脏目录摘要 */
40
+ listDirtyDirSummaries(): readonly DirtyDirSummary[];
41
+ /** 按目录路径读取脏目录摘要 */
42
+ getDirtyDirSummary(path: string): DirtyDirSummary | null;
43
+ /** 写入或覆盖脏目录摘要 */
44
+ setDirtyDirSummary(summary: DirtyDirSummary): void;
45
+ /** 删除脏目录摘要 */
46
+ deleteDirtyDirSummary(path: string): void;
36
47
  /**
37
48
  * 重置为新的基线 tree
38
49
  *
39
- * 需要同时清空节点状态与变更记录,并重新建立根节点。
50
+ * 需要同时清空节点状态,并重新建立根节点。
40
51
  */
41
52
  reset(baseTree: SHA1): void;
42
53
  }
@@ -1,6 +1,8 @@
1
1
  import { writeObject } from "../objects/raw.mjs";
2
- import { isDirectoryOverlayDirty } from "./nodes.mjs";
2
+ import { readRepoTree } from "./origin.mjs";
3
3
  import { listDirectoryChildren } from "./session-internal.mjs";
4
+ import { createNamedOriginChildLookup, observeListedDirectoryChild, observeNamedDirectoryChild, planAffectedDirectoryChildren } from "./directory-view.mjs";
5
+ import { materializeDirtyDirSummary } from "./dirty-dir.mjs";
4
6
  //#region src/workdir/write-tree.ts
5
7
  /**
6
8
  * Virtual Workdir overlay -> tree 最小化编译
@@ -35,63 +37,125 @@ function writeTreeFromSession(source, state) {
35
37
  *
36
38
  * 返回新 tree 的 SHA-1(若目录无任何变化则直接复用 origin hash)。
37
39
  */
38
- function compileDirectory(writeSource, readSource, state, dirNode) {
40
+ function compileDirectory(writeSource, readSource, state, dirNode, dirPath = "") {
39
41
  if (dirNode.state.kind !== "directory") throw new Error("compileDirectory called on non-directory node");
40
- const children = listDirectoryChildren(readSource, state, dirNode, "");
42
+ const summary = state.getDirtyDirSummary(dirPath);
43
+ if (summary !== null && summary.hashState === "materialized" && summary.currentTreeHash !== null) return summary.currentTreeHash;
44
+ if (summary === null && dirNode.origin.kind === "repo-tree") return dirNode.origin.hash;
41
45
  let anyChanged = false;
42
- const newEntries = [];
43
- for (const child of children) {
44
- const node = state.getNode(child.nodeId);
45
- if (node === null) continue;
46
- if (node.state.kind === "directory") {
47
- const newHash = compileDirectory(writeSource, readSource, state, node);
48
- if (newHash !== (node.origin.kind === "repo-tree" ? node.origin.hash : null)) anyChanged = true;
49
- newEntries.push({
46
+ const newEntries = collectCompiledEntries(writeSource, readSource, state, dirNode, dirPath, summary, (changed) => {
47
+ if (changed) anyChanged = true;
48
+ });
49
+ if (summary !== null && (summary.dirtyEntryCount > 0 || summary.dirtyDescendantCount > 0)) anyChanged = true;
50
+ if (!anyChanged && dirNode.origin.kind === "repo-tree") {
51
+ state.setDirtyDirSummary(materializeDirtyDirSummary(summary, dirPath, dirNode.origin.hash));
52
+ return dirNode.origin.hash;
53
+ }
54
+ newEntries.sort((a, b) => a.name.localeCompare(b.name));
55
+ const treeHash = writeObject(writeSource, {
56
+ type: "tree",
57
+ entries: newEntries
58
+ });
59
+ state.setDirtyDirSummary(materializeDirtyDirSummary(summary, dirPath, treeHash));
60
+ return treeHash;
61
+ }
62
+ function collectCompiledEntries(writeSource, readSource, state, dirNode, dirPath, summary, markChanged) {
63
+ if (dirNode.state.kind !== "directory") throw new Error("collectCompiledEntries called on non-directory node");
64
+ if (dirNode.origin.kind !== "repo-tree" || summary === null) return listDirectoryChildren(readSource, state, dirNode, dirPath).flatMap((child) => {
65
+ const observedChild = observeListedDirectoryChild(state, dirPath, child);
66
+ if (observedChild === null) return [];
67
+ const entry = compileChildEntry(writeSource, readSource, state, observedChild);
68
+ if (entry === null) return [];
69
+ markChanged(entry.changed);
70
+ return [entry.entry];
71
+ });
72
+ const originLookup = createNamedOriginChildLookup(readRepoTree(readSource, dirNode.origin.hash, dirPath).entries);
73
+ const childPlan = planAffectedDirectoryChildren(originLookup, collectAffectedChildNames(summary));
74
+ const out = [];
75
+ for (const planEntry of childPlan) {
76
+ if (!planEntry.shouldCompile) {
77
+ if (planEntry.originEntry === null) throw new Error(`collectCompiledEntries: missing origin entry for '${planEntry.name}'`);
78
+ out.push(planEntry.originEntry);
79
+ continue;
80
+ }
81
+ const compiled = compileNamedChildEntry(writeSource, readSource, state, dirNode, dirPath, planEntry.name, originLookup);
82
+ if (compiled === null) continue;
83
+ markChanged(compiled.changed);
84
+ out.push(compiled.entry);
85
+ }
86
+ return out;
87
+ }
88
+ function collectAffectedChildNames(summary) {
89
+ return new Set(summary.affectedNames);
90
+ }
91
+ function compileNamedChildEntry(writeSource, readSource, state, dirNode, dirPath, name, originLookup) {
92
+ if (dirNode.state.kind !== "directory") throw new Error("compileNamedChildEntry called on non-directory node");
93
+ const observedChild = observeNamedDirectoryChild(state, dirNode, dirPath, originLookup, name);
94
+ if (observedChild === null) return null;
95
+ return compileChildEntry(writeSource, readSource, state, observedChild);
96
+ }
97
+ function compileChildEntry(writeSource, readSource, state, child) {
98
+ const node = child.node;
99
+ if (node.state.kind === "directory") {
100
+ const newHash = compileDirectory(writeSource, readSource, state, node, child.path);
101
+ const originHash = node.origin.kind === "repo-tree" ? node.origin.hash : null;
102
+ return {
103
+ entry: {
50
104
  mode: "40000",
51
105
  name: child.name,
52
106
  hash: newHash
107
+ },
108
+ changed: newHash !== originHash
109
+ };
110
+ }
111
+ if (node.state.kind === "file") {
112
+ if (node.state.content !== void 0) {
113
+ const hash = writeObject(writeSource, {
114
+ type: "blob",
115
+ content: node.state.content
53
116
  });
54
- } else if (node.state.kind === "file") {
55
- if (node.state.content !== void 0) {
56
- const hash = writeObject(writeSource, {
57
- type: "blob",
58
- content: node.state.content
59
- });
60
- anyChanged = true;
61
- newEntries.push({
117
+ return {
118
+ entry: {
62
119
  mode: node.state.mode,
63
120
  name: child.name,
64
121
  hash
65
- });
66
- } else if (node.origin.kind === "repo-blob") newEntries.push({
122
+ },
123
+ changed: node.origin.kind !== "repo-blob" || hash !== node.origin.hash
124
+ };
125
+ }
126
+ if (node.origin.kind === "repo-blob") return {
127
+ entry: {
67
128
  mode: node.state.mode,
68
129
  name: child.name,
69
130
  hash: node.origin.hash
70
- });
71
- } else if (node.state.target !== void 0) {
72
- const hash = writeObject(writeSource, {
73
- type: "blob",
74
- content: node.state.target
75
- });
76
- anyChanged = true;
77
- newEntries.push({
131
+ },
132
+ changed: false
133
+ };
134
+ return null;
135
+ }
136
+ if (node.state.target !== void 0) {
137
+ const hash = writeObject(writeSource, {
138
+ type: "blob",
139
+ content: node.state.target
140
+ });
141
+ return {
142
+ entry: {
78
143
  mode: "120000",
79
144
  name: child.name,
80
145
  hash
81
- });
82
- } else if (node.origin.kind === "repo-blob") newEntries.push({
146
+ },
147
+ changed: node.origin.kind !== "repo-blob" || hash !== node.origin.hash
148
+ };
149
+ }
150
+ if (node.origin.kind === "repo-blob") return {
151
+ entry: {
83
152
  mode: "120000",
84
153
  name: child.name,
85
154
  hash: node.origin.hash
86
- });
87
- }
88
- if (isDirectoryOverlayDirty(dirNode.state.overlay)) anyChanged = true;
89
- if (!anyChanged && dirNode.origin.kind === "repo-tree") return dirNode.origin.hash;
90
- newEntries.sort((a, b) => a.name.localeCompare(b.name));
91
- return writeObject(writeSource, {
92
- type: "tree",
93
- entries: newEntries
94
- });
155
+ },
156
+ changed: false
157
+ };
158
+ return null;
95
159
  }
96
160
  //#endregion
97
161
  export { writeTreeFromSession };