@imenam/mcp-documentor 1.0.71 → 1.0.74

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 (72) hide show
  1. package/dist/src/core/conflict-inspector.d.ts +63 -0
  2. package/dist/src/core/conflict-inspector.js +157 -0
  3. package/dist/src/core/git-sync.d.ts +113 -8
  4. package/dist/src/core/git-sync.js +296 -45
  5. package/dist/src/core/merge-drivers/merge-config.d.ts +1 -0
  6. package/dist/src/core/merge-drivers/merge-config.js +68 -0
  7. package/dist/src/core/merge-drivers/merge-doc.d.ts +1 -0
  8. package/dist/src/core/merge-drivers/merge-doc.js +105 -0
  9. package/dist/src/core/merge-drivers/text-merge.d.ts +15 -0
  10. package/dist/src/core/merge-drivers/text-merge.js +53 -0
  11. package/dist/src/core/merge-drivers/three-way.d.ts +34 -0
  12. package/dist/src/core/merge-drivers/three-way.js +86 -0
  13. package/dist/src/gui-worker.js +21 -0
  14. package/dist/src/index.js +146 -12
  15. package/dist/src/tools/conflicts.d.ts +12 -0
  16. package/dist/src/tools/conflicts.js +190 -0
  17. package/dist/src/tools/sync.js +55 -11
  18. package/dist/src/version.d.ts +1 -1
  19. package/dist/src/version.js +1 -1
  20. package/gui/dist/assets/{arc-Carsq0k3.js → arc-BNuZ3dSL.js} +1 -1
  21. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-caqcwdTw.js → architectureDiagram-3BPJPVTR-D8g3tPQx.js} +1 -1
  22. package/gui/dist/assets/{blockDiagram-GPEHLZMM-0wUO8k2I.js → blockDiagram-GPEHLZMM-limhup8_.js} +1 -1
  23. package/gui/dist/assets/{c4Diagram-AAUBKEIU-DS6q7fMy.js → c4Diagram-AAUBKEIU-Ct39JMIy.js} +1 -1
  24. package/gui/dist/assets/channel-uoEIgtym.js +1 -0
  25. package/gui/dist/assets/{chunk-2J33WTMH-CrJc-G6D.js → chunk-2J33WTMH-BkLJTezj.js} +1 -1
  26. package/gui/dist/assets/{chunk-4BX2VUAB-C5vtPFbB.js → chunk-4BX2VUAB-CR5NbhDR.js} +1 -1
  27. package/gui/dist/assets/{chunk-55IACEB6-BchyQUFM.js → chunk-55IACEB6-CtQYMnJS.js} +1 -1
  28. package/gui/dist/assets/{chunk-727SXJPM-BCIaWtKq.js → chunk-727SXJPM-B2q7tUn8.js} +1 -1
  29. package/gui/dist/assets/{chunk-AQP2D5EJ-BEQJDJt5.js → chunk-AQP2D5EJ-Bbcnrfrp.js} +1 -1
  30. package/gui/dist/assets/{chunk-FMBD7UC4-IfQ3QtrC.js → chunk-FMBD7UC4-D8gd4zRe.js} +1 -1
  31. package/gui/dist/assets/{chunk-ND2GUHAM-4Du33w7t.js → chunk-ND2GUHAM-DiZKzZtG.js} +1 -1
  32. package/gui/dist/assets/{chunk-QZHKN3VN-HR0CP6fa.js → chunk-QZHKN3VN-CrKx-e2_.js} +1 -1
  33. package/gui/dist/assets/classDiagram-4FO5ZUOK-DmhYb54l.js +1 -0
  34. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DmhYb54l.js +1 -0
  35. package/gui/dist/assets/{cose-bilkent-S5V4N54A-C9Re3ENx.js → cose-bilkent-S5V4N54A-BDUCVkTl.js} +1 -1
  36. package/gui/dist/assets/{dagre-BM42HDAG-DZmcvMMp.js → dagre-BM42HDAG-DkoYBHXK.js} +1 -1
  37. package/gui/dist/assets/{diagram-2AECGRRQ-EyJrAGht.js → diagram-2AECGRRQ-BT1YJHon.js} +1 -1
  38. package/gui/dist/assets/{diagram-5GNKFQAL-CHd3T9_b.js → diagram-5GNKFQAL-C00r6F35.js} +1 -1
  39. package/gui/dist/assets/{diagram-KO2AKTUF-CIyFrZSR.js → diagram-KO2AKTUF-B4wcN5a_.js} +1 -1
  40. package/gui/dist/assets/{diagram-LMA3HP47-BlkJALyz.js → diagram-LMA3HP47-B9FqCfel.js} +1 -1
  41. package/gui/dist/assets/{diagram-OG6HWLK6-P8LA7IZE.js → diagram-OG6HWLK6-COPCDzdG.js} +1 -1
  42. package/gui/dist/assets/{erDiagram-TEJ5UH35-BPywgWN1.js → erDiagram-TEJ5UH35-BGQGT2wN.js} +1 -1
  43. package/gui/dist/assets/{flowDiagram-I6XJVG4X-ByL0kGiV.js → flowDiagram-I6XJVG4X-A7jKWUpi.js} +1 -1
  44. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-_pnaYCU4.js → ganttDiagram-6RSMTGT7-NhFkpY-x.js} +1 -1
  45. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-DN67CDGs.js → gitGraphDiagram-PVQCEYII-z0AqyYG4.js} +1 -1
  46. package/gui/dist/assets/{index-Bjc5MOe1.js → index-Dub5OLKk.js} +98 -98
  47. package/gui/dist/assets/index-QkCDSQ2A.css +1 -0
  48. package/gui/dist/assets/{infoDiagram-5YYISTIA-Bm5LEpzH.js → infoDiagram-5YYISTIA-D69UR9u9.js} +1 -1
  49. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DUYSULkT.js → ishikawaDiagram-YF4QCWOH-DSTqIoro.js} +1 -1
  50. package/gui/dist/assets/{journeyDiagram-JHISSGLW-BvJ1_f0L.js → journeyDiagram-JHISSGLW-CekXdcoE.js} +1 -1
  51. package/gui/dist/assets/{kanban-definition-UN3LZRKU-DDYKg6RY.js → kanban-definition-UN3LZRKU-CseGvFK9.js} +1 -1
  52. package/gui/dist/assets/{linear-HlpG9u6P.js → linear-BtVl_9_C.js} +1 -1
  53. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CPa_wo_s.js → mindmap-definition-RKZ34NQL-XLjQIU37.js} +1 -1
  54. package/gui/dist/assets/{pieDiagram-4H26LBE5-BWo0c4DP.js → pieDiagram-4H26LBE5-BcvUzWoW.js} +1 -1
  55. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CLahU-6B.js → quadrantDiagram-W4KKPZXB-BzoYEmNW.js} +1 -1
  56. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BoYbgzQ7.js → requirementDiagram-4Y6WPE33-icKC1_Nd.js} +1 -1
  57. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CGm79aMo.js → sankeyDiagram-5OEKKPKP-mi3i-2dZ.js} +1 -1
  58. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-BkknTD52.js → sequenceDiagram-3UESZ5HK-XrcDkRlM.js} +1 -1
  59. package/gui/dist/assets/{stateDiagram-AJRCARHV-DSBurgwC.js → stateDiagram-AJRCARHV-UXaqrCNT.js} +1 -1
  60. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-Bhdu4lv4.js +1 -0
  61. package/gui/dist/assets/{timeline-definition-PNZ67QCA-CBHyrGuz.js → timeline-definition-PNZ67QCA-yd6607p6.js} +1 -1
  62. package/gui/dist/assets/{vennDiagram-CIIHVFJN-Dn9fr_bR.js → vennDiagram-CIIHVFJN-BsRqUcIp.js} +1 -1
  63. package/gui/dist/assets/{wardley-L42UT6IY-nMuySeAb.js → wardley-L42UT6IY-BmdA7yk0.js} +1 -1
  64. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-Byn2STLI.js → wardleyDiagram-YWT4CUSO-CRdmckD8.js} +1 -1
  65. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-Bi-Y9wp5.js → xychartDiagram-2RQKCTM6-DWpNIQY-.js} +1 -1
  66. package/gui/dist/index.html +2 -2
  67. package/package.json +65 -65
  68. package/gui/dist/assets/channel-D51HexOc.js +0 -1
  69. package/gui/dist/assets/classDiagram-4FO5ZUOK-CU0dQ3zy.js +0 -1
  70. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CU0dQ3zy.js +0 -1
  71. package/gui/dist/assets/index-hjkEDRmc.css +0 -1
  72. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DfKn22XF.js +0 -1
@@ -1,5 +1,7 @@
1
+ import { execFile } from "child_process";
1
2
  import fs from "fs";
2
3
  import path from "path";
4
+ import { fileURLToPath } from "url";
3
5
  import { simpleGit } from "simple-git";
4
6
  import { createLogger } from "@imenam/mcp-gui-interface";
5
7
  const logger = createLogger("GitSync");
@@ -20,6 +22,22 @@ const GITIGNORE_CONTENT = [
20
22
  "local-state.json",
21
23
  "",
22
24
  ].join("\n");
25
+ /**
26
+ * Routes the two file kinds that both machines write concurrently to structured
27
+ * merge drivers. Versioned, so every clone gets the routing; the drivers
28
+ * themselves are registered per-machine by ensureMergeDrivers(). A clone without
29
+ * them just falls back to git's default text merge.
30
+ */
31
+ const GITATTRIBUTES_CONTENT = [
32
+ "# Structured merges - drivers registered by SyncEngine.ensureMergeDrivers()",
33
+ "projects/**/*.md merge=mcpdoc",
34
+ "config.json merge=mcpdocjson",
35
+ "",
36
+ ].join("\n");
37
+ const MERGE_DRIVERS = [
38
+ { name: "mcpdoc", script: "merge-doc.js", label: "mcp-documentor documentation merge" },
39
+ { name: "mcpdocjson", script: "merge-config.js", label: "mcp-documentor config merge" },
40
+ ];
23
41
  export class SyncEngine {
24
42
  storage;
25
43
  git;
@@ -61,6 +79,35 @@ export class SyncEngine {
61
79
  }
62
80
  return cfg;
63
81
  }
82
+ /**
83
+ * Registers the structured merge drivers in .git/config, which git never
84
+ * versions - so this runs on every machine, not just the one that created the
85
+ * repo. Values are absolute paths to the shipped scripts; git invokes them
86
+ * through a shell, hence the quoting and the forward slashes on Windows.
87
+ */
88
+ async ensureMergeDrivers() {
89
+ const driverDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "merge-drivers");
90
+ const node = process.execPath.replace(/\\/g, "/");
91
+ for (const driver of MERGE_DRIVERS) {
92
+ const script = path.join(driverDir, driver.script).replace(/\\/g, "/");
93
+ const command = `"${node}" "${script}" %O %A %B %P`;
94
+ // `git config --get` exits 1 when the key is unset - that is the "not
95
+ // registered yet" case, not an error.
96
+ const current = await this.gitDirect(["config", "--local", "--get", `merge.${driver.name}.driver`]);
97
+ if (current.stdout === command)
98
+ continue;
99
+ await this.gitDirect(["config", "--local", `merge.${driver.name}.name`, driver.label]);
100
+ const set = await this.gitDirect(["config", "--local", `merge.${driver.name}.driver`, command]);
101
+ if (set.ok) {
102
+ logger.info(`Registered merge driver "${driver.name}" -> ${script}`);
103
+ }
104
+ else {
105
+ // Without the driver git falls back to its default text merge: more
106
+ // conflicts, but nothing broken. Not worth failing every sync over.
107
+ logger.warn(`Could not register merge driver "${driver.name}": ${set.stderr}`);
108
+ }
109
+ }
110
+ }
64
111
  /**
65
112
  * Ensures the data dir is a git repo with a .gitignore and an authenticated
66
113
  * origin. The token is stored only in .git/config (which git never commits),
@@ -74,10 +121,18 @@ export class SyncEngine {
74
121
  if (!fs.existsSync(gitignorePath)) {
75
122
  fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf-8");
76
123
  }
124
+ // Rewritten when it drifts, so an upgraded server updates the routing of
125
+ // repos that were initialized by an older version.
126
+ const attributesPath = path.join(this.dataDir, ".gitattributes");
127
+ const currentAttributes = fs.existsSync(attributesPath) ? fs.readFileSync(attributesPath, "utf-8") : "";
128
+ if (currentAttributes !== GITATTRIBUTES_CONTENT) {
129
+ fs.writeFileSync(attributesPath, GITATTRIBUTES_CONTENT, "utf-8");
130
+ }
77
131
  if (!fs.existsSync(path.join(this.dataDir, ".git"))) {
78
132
  await this.git.init();
79
133
  logger.info(`Initialized git repo in ${this.dataDir}`);
80
134
  }
135
+ await this.ensureMergeDrivers();
81
136
  // (Re)point origin at the authenticated URL.
82
137
  const remotes = await this.git.getRemotes(true);
83
138
  const origin = remotes.find((r) => r.name === "origin");
@@ -87,14 +142,18 @@ export class SyncEngine {
87
142
  else if (origin.refs.fetch !== authed) {
88
143
  await this.git.remote(["set-url", "origin", authed]);
89
144
  }
90
- // Make sure we're on the configured branch.
91
- const branches = await this.git.branchLocal();
92
- if (branches.current !== cfg.branch) {
93
- try {
94
- await this.git.checkoutLocalBranch(cfg.branch);
95
- }
96
- catch {
97
- await this.git.checkout(cfg.branch);
145
+ // Make sure we're on the configured branch - unless an operation is in
146
+ // flight, where a detached HEAD is the expected state and checking the
147
+ // branch back out would abort the rebase git is halfway through.
148
+ if ((await this.operationInProgress()) === "none") {
149
+ const branches = await this.git.branchLocal();
150
+ if (branches.current !== cfg.branch) {
151
+ try {
152
+ await this.git.checkoutLocalBranch(cfg.branch);
153
+ }
154
+ catch {
155
+ await this.git.checkout(cfg.branch);
156
+ }
98
157
  }
99
158
  }
100
159
  }
@@ -103,6 +162,7 @@ export class SyncEngine {
103
162
  const s = await this.git.status();
104
163
  return {
105
164
  branch: s.current ?? "",
165
+ operation: await this.operationInProgress(),
106
166
  ahead: s.ahead,
107
167
  behind: s.behind,
108
168
  dirty: s.files.map((f) => f.path),
@@ -110,12 +170,21 @@ export class SyncEngine {
110
170
  };
111
171
  }
112
172
  /**
113
- * Defensive pull: only fast-forwards a clean working tree. Never auto-merges
114
- * a divergent history, so local data is never silently overwritten.
173
+ * Defensive pull. A clean tree that is purely behind fast-forwards; a
174
+ * divergent one is rebased, so local commits replay on top of the remote and
175
+ * the history stays linear - which is what makes per-doc `git log` attribution
176
+ * readable when reporting who changed what. An uncommitted local change is
177
+ * never risked: the pull reports the divergence and applies nothing.
115
178
  */
116
179
  async pull() {
117
180
  const cfg = this.requireConfig();
118
181
  await this.ensureRepo();
182
+ // A merge or rebase left half-finished would otherwise look like a dirty
183
+ // tree and be reported as a plain divergence.
184
+ if ((await this.operationInProgress()) !== "none") {
185
+ const pending = await this.git.status();
186
+ return { status: "conflict", conflicted: pending.conflicted };
187
+ }
119
188
  await this.git.fetch("origin", cfg.branch);
120
189
  const remoteRef = `refs/remotes/origin/${cfg.branch}`;
121
190
  // No remote branch yet (fresh repo) — nothing to pull.
@@ -136,80 +205,262 @@ export class SyncEngine {
136
205
  }
137
206
  // Ensure upstream tracking so status ahead/behind is meaningful.
138
207
  await this.git.branch(["--set-upstream-to", `origin/${cfg.branch}`, cfg.branch]).catch(() => { });
139
- const status = await this.git.status();
140
- if (status.behind === 0)
208
+ let status = await this.git.status();
209
+ if (status.behind === 0 && status.files.length === 0)
141
210
  return { status: "up-to-date" };
142
- // Uncommitted local changes block a safe merge — never risk clobbering them.
143
- // Surface as diverged so the user commits/pushes (or stashes) first.
144
- if (status.files.length > 0) {
145
- return { status: "diverged", ahead: status.ahead, behind: status.behind };
146
- }
211
+ // Uncommitted local edits cannot be rebased over, and reporting the
212
+ // divergence instead used to strand the caller: the pull asked for a push,
213
+ // which the remote rejected and which asked for a pull. Committing them
214
+ // resolves it - the edits become a commit the rebase can replay, nothing is
215
+ // published, and nothing can be clobbered.
216
+ const autoCommitted = status.files.length > 0 ? await this.commitLocalChanges() : false;
217
+ if (autoCommitted)
218
+ status = await this.git.status();
147
219
  const before = await this.git.revparse(["HEAD"]).catch(() => "");
148
220
  // Pure behind → fast-forward.
149
221
  if (status.ahead === 0) {
150
222
  await this.git.merge(["--ff-only", `origin/${cfg.branch}`]);
151
223
  const after = await this.git.revparse(["HEAD"]).catch(() => "");
152
- return { status: "updated", changed: await this.diffNames(before, after) };
224
+ return { status: "updated", changed: await this.diffNames(before, after), autoCommitted };
153
225
  }
154
- // Diverged with a clean tree → attempt a real merge. A conflict stops with
155
- // markers in place (both versions preserved) so it can be resolved + pushed.
226
+ // Diverged with a clean tree → replay local commits on top of the remote. A
227
+ // conflict stops the rebase with both versions preserved in the index, so it
228
+ // can be resolved and the rebase continued.
156
229
  try {
157
- await this.git.merge([`origin/${cfg.branch}`]);
230
+ await this.git.raw(["rebase", `origin/${cfg.branch}`]);
158
231
  }
159
232
  catch {
160
233
  const after = await this.git.status();
161
234
  if (after.conflicted.length > 0)
162
- return { status: "conflict", conflicted: after.conflicted };
163
- throw new Error("Merge failed without recorded conflicts. Resolve manually in the data directory.");
235
+ return { status: "conflict", conflicted: after.conflicted, autoCommitted };
236
+ throw new Error("Rebase failed without recorded conflicts. Inspect the data directory, or run `git rebase --abort` there to get back to the previous state.");
164
237
  }
165
- const merged = await this.git.revparse(["HEAD"]).catch(() => "");
166
- return { status: "updated", changed: await this.diffNames(before, merged) };
238
+ const rebased = await this.git.revparse(["HEAD"]).catch(() => "");
239
+ return { status: "updated", changed: await this.diffNames(before, rebased), autoCommitted };
167
240
  }
168
241
  async diffNames(before, after) {
169
242
  if (!before || !after || before === after)
170
243
  return [];
171
244
  return (await this.git.raw(["diff", "--name-only", before, after])).split("\n").filter(Boolean);
172
245
  }
246
+ /**
247
+ * Stages and commits whatever the working tree holds. Committing is what makes
248
+ * local edits safe to rebase over, so both push and pull go through here
249
+ * rather than asking the caller to tidy up first.
250
+ *
251
+ * .gitignore (written by ensureRepo) excludes all derived and machine-local
252
+ * state, so `add -A` only ever stages versioned files.
253
+ */
254
+ async commitLocalChanges(message) {
255
+ await this.git.add(["-A"]);
256
+ const status = await this.git.status();
257
+ const hasWork = status.staged.length > 0 || status.created.length > 0 || status.deleted.length > 0 || status.modified.length > 0;
258
+ if (!hasWork)
259
+ return false;
260
+ const machine = process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown-machine";
261
+ await this.git.commit(message?.trim() || `docs sync from ${machine} · ${new Date().toISOString()}`);
262
+ return true;
263
+ }
173
264
  /** Stages versioned files, commits if there is anything to commit, and pushes. */
174
265
  async push(message) {
175
266
  const cfg = this.requireConfig();
176
267
  await this.ensureRepo();
177
- // .gitignore (written by ensureRepo) excludes all derived/local state,
178
- // so -A safely stages only versioned files (projects/, config.json, .gitignore).
179
- await this.git.add(["-A"]);
180
- const status = await this.git.status();
181
- let committed = false;
182
- if (status.staged.length > 0 || status.created.length > 0 || status.deleted.length > 0 || status.modified.length > 0) {
183
- const machine = process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown-machine";
184
- const msg = message?.trim() || `docs sync from ${machine} · ${new Date().toISOString()}`;
185
- await this.git.commit(msg);
186
- committed = true;
268
+ // `add -A` would happily stage conflict markers and commit them as content,
269
+ // so an unfinished merge or rebase has to stop the push outright.
270
+ const operation = await this.operationInProgress();
271
+ const pending = await this.git.status();
272
+ if (operation !== "none" || pending.conflicted.length > 0) {
273
+ throw new Error(`A ${operation === "none" ? "conflict" : operation} is still unresolved (${pending.conflicted.length} conflicted file(s)). ` +
274
+ "Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
187
275
  }
276
+ const committed = await this.commitLocalChanges(message);
188
277
  try {
189
278
  await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
190
279
  }
191
280
  catch (e) {
192
281
  const msg = e instanceof Error ? e.message : String(e);
193
282
  if (/fetch first|non-fast-forward|\[rejected\]/i.test(msg)) {
194
- throw new Error("Push rejected: the remote has commits you don't have locally. Run pull_docs to integrate them, then push again.");
283
+ throw new Error("Push rejected: the remote has commits you don't have locally. Run sync_docs, which integrates them and pushes in one step.");
195
284
  }
196
285
  throw e;
197
286
  }
198
287
  return { pushed: true, committed, message: committed ? "Committed and pushed." : "Nothing new to commit; pushed existing commits." };
199
288
  }
200
- /** Bulk-resolve conflicts by taking one side, then stage the resolved files. */
201
- async resolveConflict(strategy, paths) {
289
+ /**
290
+ * Detects whether a merge or a rebase is mid-flight. Read through
291
+ * `rev-parse --git-path` rather than assuming `.git` is a directory, so this
292
+ * still works in a worktree or a submodule.
293
+ */
294
+ async operationInProgress() {
295
+ const marker = async (name) => {
296
+ const rel = await this.git.raw(["rev-parse", "--git-path", name]).catch(() => "");
297
+ if (!rel.trim())
298
+ return false;
299
+ return fs.existsSync(path.resolve(this.dataDir, rel.trim()));
300
+ };
301
+ if ((await marker("rebase-merge")) || (await marker("rebase-apply")))
302
+ return "rebase";
303
+ if (await marker("MERGE_HEAD"))
304
+ return "merge";
305
+ return "none";
306
+ }
307
+ /** Index stages recorded for a conflicted path: 1 = base, 2 = ours, 3 = theirs. */
308
+ async conflictStages(filePath) {
309
+ const out = await this.git.raw(["ls-files", "-u", "--", filePath]).catch(() => "");
310
+ const stages = new Set();
311
+ for (const line of out.split("\n")) {
312
+ const match = line.match(/^\S+ \S+ (\d)\t/);
313
+ if (match)
314
+ stages.add(Number(match[1]));
315
+ }
316
+ return stages;
317
+ }
318
+ /**
319
+ * Finishes the in-flight merge or rebase once every conflict is staged.
320
+ * A rebase can stop again on the next replayed commit, so the caller is told
321
+ * what is still outstanding rather than being promised completion.
322
+ *
323
+ * `git rebase --continue` insists on an editor for the replayed commit
324
+ * message, and simple-git blocks both `-c core.editor` and GIT_EDITOR as
325
+ * injection vectors. This one command therefore goes out directly with a no-op
326
+ * editor - the value is a fixed literal here, never user input.
327
+ */
328
+ async continueOperation() {
329
+ const operation = await this.operationInProgress();
330
+ if (operation === "none")
331
+ return { operation, done: true, remaining: [] };
332
+ const before = await this.git.status();
333
+ if (before.conflicted.length > 0) {
334
+ return { operation, done: false, remaining: before.conflicted };
335
+ }
336
+ // A rebase legitimately stops again on the next commit's conflicts, so a
337
+ // non-zero exit is not necessarily a failure - the state read back below is
338
+ // the real report.
339
+ const run = await this.gitDirect(operation === "rebase" ? ["rebase", "--continue"] : ["commit", "--no-edit"], {
340
+ GIT_EDITOR: "true",
341
+ });
342
+ const error = run.ok ? undefined : run.stderr;
343
+ const still = await this.operationInProgress();
344
+ const after = await this.git.status();
345
+ const done = still === "none";
346
+ return { operation, done, remaining: after.conflicted, ...(!done && error ? { error } : {}) };
347
+ }
348
+ /**
349
+ * Runs one git command outside simple-git, which rejects a handful of config
350
+ * keys and environment variables as injection vectors - merge.*.driver and
351
+ * GIT_EDITOR among them. Everything passed here is a fixed literal or a path
352
+ * derived from the install location, never agent or user input.
353
+ */
354
+ gitDirect(args, env = {}) {
355
+ return new Promise((resolve) => {
356
+ execFile("git", args, { cwd: this.dataDir, env: { ...process.env, ...env } }, (error, stdout, stderr) => resolve({ ok: !error, stdout: stdout.trim(), stderr: (stderr || error?.message || "").trim() }));
357
+ });
358
+ }
359
+ /**
360
+ * The refs holding each side during the operation in flight. A rebase replays
361
+ * our commits onto the remote, so HEAD is the *remote* there - the mapping
362
+ * that makes every side label absolute.
363
+ */
364
+ sideRefs(operation) {
365
+ return operation === "rebase"
366
+ ? { local: "REBASE_HEAD", remote: "HEAD" }
367
+ : { local: "HEAD", remote: "MERGE_HEAD" };
368
+ }
369
+ async lastCommitFor(ref, filePath) {
370
+ const out = await this.git
371
+ .raw(["log", "-1", "--format=%an%x00%aI%x00%s", ref, "--", filePath])
372
+ .catch(() => "");
373
+ const [author, date, subject] = out.trim().split("\u0000");
374
+ return author ? { author, date: date ?? "", subject: subject ?? "" } : undefined;
375
+ }
376
+ /**
377
+ * Every conflicted file with its shape and its two authors - enough for the
378
+ * agent to decide what to look at without opening a single document.
379
+ */
380
+ async listConflicts() {
202
381
  this.requireConfig();
382
+ const operation = await this.operationInProgress();
383
+ const status = await this.git.status();
384
+ const refs = this.sideRefs(operation);
385
+ const localStage = operation === "rebase" ? 3 : 2;
386
+ const files = [];
387
+ for (const filePath of status.conflicted) {
388
+ const stages = await this.conflictStages(filePath);
389
+ let kind = "content";
390
+ let deletedOn;
391
+ if (!stages.has(1)) {
392
+ kind = "add-add";
393
+ }
394
+ else if (!stages.has(2) || !stages.has(3)) {
395
+ kind = "delete-modify";
396
+ const missing = stages.has(2) ? 3 : 2;
397
+ deletedOn = missing === localStage ? "local" : "remote";
398
+ }
399
+ files.push({
400
+ path: filePath,
401
+ kind,
402
+ ...(deletedOn ? { deletedOn } : {}),
403
+ local: await this.lastCommitFor(refs.local, filePath),
404
+ remote: await this.lastCommitFor(refs.remote, filePath),
405
+ });
406
+ }
407
+ return { operation, files };
408
+ }
409
+ /** Reads a conflicted file from the working tree, refusing to escape the data dir. */
410
+ readWorkingFile(relativePath) {
411
+ const absolute = path.resolve(this.dataDir, relativePath);
412
+ if (!absolute.startsWith(path.resolve(this.dataDir))) {
413
+ throw new Error(`Path "${relativePath}" is outside the documentation data directory.`);
414
+ }
415
+ if (!fs.existsSync(absolute))
416
+ throw new Error(`File "${relativePath}" does not exist.`);
417
+ return fs.readFileSync(absolute, "utf-8");
418
+ }
419
+ /** Writes a resolved file back and stages it when nothing is left conflicted in it. */
420
+ async writeResolvedFile(relativePath, content, stage) {
421
+ const absolute = path.resolve(this.dataDir, relativePath);
422
+ if (!absolute.startsWith(path.resolve(this.dataDir))) {
423
+ throw new Error(`Path "${relativePath}" is outside the documentation data directory.`);
424
+ }
425
+ fs.writeFileSync(absolute, content, "utf-8");
426
+ if (stage)
427
+ await this.git.add([relativePath]);
428
+ }
429
+ /**
430
+ * Bulk-resolve conflicts by keeping one side, then stage the result.
431
+ *
432
+ * The git flag depends on the operation in flight: a merge replays the remote
433
+ * onto our branch (--ours = local), while a rebase replays our commits onto
434
+ * the remote (--ours = remote). Picking the flag from the strategy alone would
435
+ * silently keep the wrong side under rebase.
436
+ */
437
+ async resolveConflict(side, paths) {
438
+ this.requireConfig();
439
+ const operation = await this.operationInProgress();
203
440
  const status = await this.git.status();
204
441
  const targets = paths && paths.length > 0 ? paths : status.conflicted;
205
442
  if (targets.length === 0)
206
- return [];
207
- // During a merge, "ours" = current branch (mine), "theirs" = incoming.
208
- const sideFlag = strategy === "mine" ? "--ours" : "--theirs";
209
- for (const p of targets) {
210
- await this.git.raw(["checkout", sideFlag, "--", p]);
211
- await this.git.add([p]);
443
+ return { resolved: [], operation };
444
+ const localFlag = operation === "rebase" ? "--theirs" : "--ours";
445
+ const remoteFlag = operation === "rebase" ? "--ours" : "--theirs";
446
+ const sideFlag = side === "local" ? localFlag : remoteFlag;
447
+ const wantedStage = sideFlag === "--ours" ? 2 : 3;
448
+ const resolved = [];
449
+ for (const filePath of targets) {
450
+ const stages = await this.conflictStages(filePath);
451
+ if (stages.size === 0)
452
+ continue;
453
+ if (stages.has(wantedStage)) {
454
+ await this.git.raw(["checkout", sideFlag, "--", filePath]);
455
+ await this.git.add([filePath]);
456
+ }
457
+ else {
458
+ // delete/modify conflict: the side we are keeping deleted the file, so
459
+ // there is nothing to check out - record the deletion instead.
460
+ await this.git.raw(["rm", "-f", "--", filePath]);
461
+ }
462
+ resolved.push(filePath);
212
463
  }
213
- return targets;
464
+ return { resolved, operation };
214
465
  }
215
466
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Git merge driver for the versioned config.json.
3
+ *
4
+ * Invoked by git as: node merge-config.js %O %A %B
5
+ *
6
+ * The project list is shared state that both machines append to, so a textual
7
+ * merge conflicts on the JSON punctuation (a `union` merge would even produce
8
+ * invalid JSON). Merging the parsed structure instead means two machines that
9
+ * each create a project converge without any conflict, and the file stays valid
10
+ * by construction.
11
+ *
12
+ * Exit 0 = merged cleanly, exit 1 = conflicts remain.
13
+ */
14
+ import fs from "fs";
15
+ import { mergeScalar, mergeSet } from "./three-way.js";
16
+ import { CLEAN, CONFLICT, read, fallback } from "./text-merge.js";
17
+ function parse(text) {
18
+ if (text.trim() === "")
19
+ return {};
20
+ try {
21
+ const parsed = JSON.parse(text);
22
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function projects(config) {
29
+ return Array.isArray(config.projects) ? config.projects : [];
30
+ }
31
+ function main() {
32
+ const [basePath, oursPath, theirsPath] = process.argv.slice(2);
33
+ if (!oursPath || !theirsPath)
34
+ return CONFLICT;
35
+ const base = parse(read(basePath));
36
+ const ours = parse(read(oursPath));
37
+ const theirs = parse(read(theirsPath));
38
+ // Not the JSON we know how to reason about — don't guess.
39
+ if (!base || !ours || !theirs)
40
+ return fallback(basePath, oursPath, theirsPath);
41
+ // Key order mirrors StorageEngine.saveConfig so a merged file is byte-identical
42
+ // to what the next save would produce.
43
+ const result = { projects: mergeSet(projects(base), projects(ours), projects(theirs)) };
44
+ // A completed migration cannot be undone, so `true` absorbs a divergence
45
+ // instead of conflicting.
46
+ const migrated = mergeScalar(base.foldersMigrated, ours.foldersMigrated, theirs.foldersMigrated);
47
+ const migratedValue = migrated.conflict ? true : migrated.value;
48
+ if (migratedValue !== undefined)
49
+ result.foldersMigrated = migratedValue;
50
+ let conflicted = false;
51
+ for (const key of [...Object.keys(ours), ...Object.keys(theirs)]) {
52
+ if (key === "projects" || key === "foldersMigrated" || key in result)
53
+ continue;
54
+ const merged = mergeScalar(base[key], ours[key], theirs[key]);
55
+ if (merged.conflict) {
56
+ // An unknown key this driver has no rule for: keep ours and let the
57
+ // conflict surface rather than silently picking a side.
58
+ conflicted = true;
59
+ result[key] = merged.ours;
60
+ }
61
+ else if (merged.value !== undefined) {
62
+ result[key] = merged.value;
63
+ }
64
+ }
65
+ fs.writeFileSync(oursPath, JSON.stringify(result, null, 2), "utf-8");
66
+ return conflicted ? CONFLICT : CLEAN;
67
+ }
68
+ process.exit(main());
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Git merge driver for documentation markdown files.
3
+ *
4
+ * Invoked by git as: node merge-doc.js %O %A %B %P
5
+ * %O = common ancestor, %A = our version (MUST be overwritten with the result),
6
+ * %B = their version, %P = the path being merged.
7
+ *
8
+ * Frontmatter is merged field by field with domain rules (updatedAt = latest,
9
+ * createdAt = earliest, list fields = 3-way set merge), so the bookkeeping
10
+ * metadata that every write touches never conflicts on its own. Only the body
11
+ * goes through git's textual merge.
12
+ *
13
+ * Exit 0 = merged cleanly, exit 1 = conflicts remain (markers are in the body).
14
+ */
15
+ import fs from "fs";
16
+ import matter from "gray-matter";
17
+ import { mergeScalar, mergeSet, mergeKeyedList, laterDate, earlierDate } from "./three-way.js";
18
+ import { CLEAN, CONFLICT, read, mergeText, fallback } from "./text-merge.js";
19
+ /** Keeps the freshest snapshot of a tracked file when both sides re-snapshotted it. */
20
+ function pickTrackedFile(ours, theirs) {
21
+ return laterDate(ours.lastModifiedAt, theirs.lastModifiedAt) === theirs.lastModifiedAt ? theirs : ours;
22
+ }
23
+ function mergeFrontmatter(base, ours, theirs) {
24
+ const conflicts = [];
25
+ const data = {};
26
+ const list = (o, k) => (Array.isArray(o[k]) ? o[k] : []);
27
+ const tracked = (o) => (Array.isArray(o.tracked_files) ? o.tracked_files : []);
28
+ // Field order mirrors StorageEngine.saveDoc so a merged file is byte-identical
29
+ // to what the next save would produce — no spurious diff on the following sync.
30
+ const title = mergeScalar(base.title, ours.title, theirs.title);
31
+ if (title.conflict) {
32
+ conflicts.push({ field: "title", ours: title.ours, theirs: title.theirs });
33
+ data.title = title.ours;
34
+ }
35
+ else {
36
+ data.title = title.value ?? "";
37
+ }
38
+ data.tracked_files = mergeKeyedList(tracked(base), tracked(ours), tracked(theirs), (f) => f.path, pickTrackedFile);
39
+ data.tags = mergeSet(list(base, "tags"), list(ours, "tags"), list(theirs, "tags"));
40
+ const relatedDocs = mergeSet(list(base, "related_docs"), list(ours, "related_docs"), list(theirs, "related_docs"));
41
+ if (relatedDocs.length > 0)
42
+ data.related_docs = relatedDocs;
43
+ // Bookkeeping timestamps: widest interval wins, never a conflict.
44
+ data.createdAt = earlierDate(ours.createdAt, theirs.createdAt) ?? "";
45
+ data.updatedAt = laterDate(ours.updatedAt, theirs.updatedAt) ?? "";
46
+ const known = new Set(["title", "tracked_files", "tags", "related_docs", "createdAt", "updatedAt"]);
47
+ for (const key of [...Object.keys(ours), ...Object.keys(theirs)]) {
48
+ if (known.has(key) || key in data)
49
+ continue;
50
+ const merged = mergeScalar(base[key], ours[key], theirs[key]);
51
+ if (merged.conflict) {
52
+ conflicts.push({ field: key, ours: merged.ours, theirs: merged.theirs });
53
+ data[key] = merged.ours;
54
+ }
55
+ else if (merged.value !== undefined) {
56
+ data[key] = merged.value;
57
+ }
58
+ }
59
+ return { data, conflicts };
60
+ }
61
+ /**
62
+ * Frontmatter conflicts cannot carry git markers — that would make the YAML
63
+ * unparseable and break every reader. They are surfaced as a marker block at the
64
+ * top of the body instead: visible to a human, greppable by tooling, and the
65
+ * document still loads.
66
+ */
67
+ function conflictBlock(conflicts) {
68
+ const render = (v) => (typeof v === "string" ? v : JSON.stringify(v));
69
+ const lines = ["<<<<<<< ours (frontmatter)"];
70
+ for (const c of conflicts)
71
+ lines.push(`${c.field}: ${render(c.ours)}`);
72
+ lines.push("=======");
73
+ for (const c of conflicts)
74
+ lines.push(`${c.field}: ${render(c.theirs)}`);
75
+ lines.push(">>>>>>> theirs (frontmatter)", "");
76
+ return lines.join("\n");
77
+ }
78
+ function main() {
79
+ const [basePath, oursPath, theirsPath] = process.argv.slice(2);
80
+ if (!oursPath || !theirsPath)
81
+ return CONFLICT;
82
+ let base, ours, theirs;
83
+ try {
84
+ base = matter(read(basePath));
85
+ ours = matter(read(oursPath));
86
+ theirs = matter(read(theirsPath));
87
+ }
88
+ catch {
89
+ // Malformed YAML on any side — don't guess, fall back to a plain text merge.
90
+ return fallback(basePath, oursPath, theirsPath);
91
+ }
92
+ // A plain markdown file with no frontmatter on any side is not one of our
93
+ // docs (the driver would otherwise *add* a frontmatter block to it).
94
+ const hasFrontmatter = [base, ours, theirs].some((side) => Object.keys(side.data).length > 0);
95
+ if (!hasFrontmatter)
96
+ return fallback(basePath, oursPath, theirsPath);
97
+ const body = mergeText(base.content, ours.content, theirs.content);
98
+ if (!body)
99
+ return fallback(basePath, oursPath, theirsPath);
100
+ const front = mergeFrontmatter(base.data, ours.data, theirs.data);
101
+ const content = front.conflicts.length > 0 ? conflictBlock(front.conflicts) + body.text : body.text;
102
+ fs.writeFileSync(oursPath, matter.stringify(content, front.data), "utf-8");
103
+ return body.conflict || front.conflicts.length > 0 ? CONFLICT : CLEAN;
104
+ }
105
+ process.exit(main());
@@ -0,0 +1,15 @@
1
+ export declare const CLEAN = 0;
2
+ export declare const CONFLICT = 1;
3
+ /** A driver argument may point at a missing file (add/add merges have no ancestor). */
4
+ export declare function read(file: string | undefined): string;
5
+ /** Runs git's own textual merge over three strings. Returns null if git itself failed. */
6
+ export declare function mergeText(base: string, ours: string, theirs: string): {
7
+ text: string;
8
+ conflict: boolean;
9
+ } | null;
10
+ /**
11
+ * Last resort when structured merging is impossible: merge the raw files
12
+ * textually, exactly as git would have without this driver. Never leaves %A
13
+ * untouched — git requires the result to be written there either way.
14
+ */
15
+ export declare function fallback(basePath: string, oursPath: string, theirsPath: string): number;