@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.
- package/dist/src/core/conflict-inspector.d.ts +63 -0
- package/dist/src/core/conflict-inspector.js +157 -0
- package/dist/src/core/git-sync.d.ts +113 -8
- package/dist/src/core/git-sync.js +296 -45
- package/dist/src/core/merge-drivers/merge-config.d.ts +1 -0
- package/dist/src/core/merge-drivers/merge-config.js +68 -0
- package/dist/src/core/merge-drivers/merge-doc.d.ts +1 -0
- package/dist/src/core/merge-drivers/merge-doc.js +105 -0
- package/dist/src/core/merge-drivers/text-merge.d.ts +15 -0
- package/dist/src/core/merge-drivers/text-merge.js +53 -0
- package/dist/src/core/merge-drivers/three-way.d.ts +34 -0
- package/dist/src/core/merge-drivers/three-way.js +86 -0
- package/dist/src/gui-worker.js +21 -0
- package/dist/src/index.js +146 -12
- package/dist/src/tools/conflicts.d.ts +12 -0
- package/dist/src/tools/conflicts.js +190 -0
- package/dist/src/tools/sync.js +55 -11
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/{arc-Carsq0k3.js → arc-BNuZ3dSL.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-caqcwdTw.js → architectureDiagram-3BPJPVTR-D8g3tPQx.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-0wUO8k2I.js → blockDiagram-GPEHLZMM-limhup8_.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-DS6q7fMy.js → c4Diagram-AAUBKEIU-Ct39JMIy.js} +1 -1
- package/gui/dist/assets/channel-uoEIgtym.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-CrJc-G6D.js → chunk-2J33WTMH-BkLJTezj.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-C5vtPFbB.js → chunk-4BX2VUAB-CR5NbhDR.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-BchyQUFM.js → chunk-55IACEB6-CtQYMnJS.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BCIaWtKq.js → chunk-727SXJPM-B2q7tUn8.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-BEQJDJt5.js → chunk-AQP2D5EJ-Bbcnrfrp.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-IfQ3QtrC.js → chunk-FMBD7UC4-D8gd4zRe.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-4Du33w7t.js → chunk-ND2GUHAM-DiZKzZtG.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-HR0CP6fa.js → chunk-QZHKN3VN-CrKx-e2_.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-DmhYb54l.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DmhYb54l.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-C9Re3ENx.js → cose-bilkent-S5V4N54A-BDUCVkTl.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-DZmcvMMp.js → dagre-BM42HDAG-DkoYBHXK.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-EyJrAGht.js → diagram-2AECGRRQ-BT1YJHon.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-CHd3T9_b.js → diagram-5GNKFQAL-C00r6F35.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CIyFrZSR.js → diagram-KO2AKTUF-B4wcN5a_.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-BlkJALyz.js → diagram-LMA3HP47-B9FqCfel.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-P8LA7IZE.js → diagram-OG6HWLK6-COPCDzdG.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BPywgWN1.js → erDiagram-TEJ5UH35-BGQGT2wN.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-ByL0kGiV.js → flowDiagram-I6XJVG4X-A7jKWUpi.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-_pnaYCU4.js → ganttDiagram-6RSMTGT7-NhFkpY-x.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-DN67CDGs.js → gitGraphDiagram-PVQCEYII-z0AqyYG4.js} +1 -1
- package/gui/dist/assets/{index-Bjc5MOe1.js → index-Dub5OLKk.js} +98 -98
- package/gui/dist/assets/index-QkCDSQ2A.css +1 -0
- package/gui/dist/assets/{infoDiagram-5YYISTIA-Bm5LEpzH.js → infoDiagram-5YYISTIA-D69UR9u9.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DUYSULkT.js → ishikawaDiagram-YF4QCWOH-DSTqIoro.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-BvJ1_f0L.js → journeyDiagram-JHISSGLW-CekXdcoE.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-DDYKg6RY.js → kanban-definition-UN3LZRKU-CseGvFK9.js} +1 -1
- package/gui/dist/assets/{linear-HlpG9u6P.js → linear-BtVl_9_C.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CPa_wo_s.js → mindmap-definition-RKZ34NQL-XLjQIU37.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-BWo0c4DP.js → pieDiagram-4H26LBE5-BcvUzWoW.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CLahU-6B.js → quadrantDiagram-W4KKPZXB-BzoYEmNW.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BoYbgzQ7.js → requirementDiagram-4Y6WPE33-icKC1_Nd.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CGm79aMo.js → sankeyDiagram-5OEKKPKP-mi3i-2dZ.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-BkknTD52.js → sequenceDiagram-3UESZ5HK-XrcDkRlM.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-DSBurgwC.js → stateDiagram-AJRCARHV-UXaqrCNT.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-Bhdu4lv4.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-CBHyrGuz.js → timeline-definition-PNZ67QCA-yd6607p6.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-Dn9fr_bR.js → vennDiagram-CIIHVFJN-BsRqUcIp.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-nMuySeAb.js → wardley-L42UT6IY-BmdA7yk0.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-Byn2STLI.js → wardleyDiagram-YWT4CUSO-CRdmckD8.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-Bi-Y9wp5.js → xychartDiagram-2RQKCTM6-DWpNIQY-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +65 -65
- package/gui/dist/assets/channel-D51HexOc.js +0 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-CU0dQ3zy.js +0 -1
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CU0dQ3zy.js +0 -1
- package/gui/dist/assets/index-hjkEDRmc.css +0 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DfKn22XF.js +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the git merge drivers: reading driver arguments and
|
|
3
|
+
* delegating to git's own textual 3-way merge.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import path from "path";
|
|
9
|
+
export const CLEAN = 0;
|
|
10
|
+
export const CONFLICT = 1;
|
|
11
|
+
/** A driver argument may point at a missing file (add/add merges have no ancestor). */
|
|
12
|
+
export function read(file) {
|
|
13
|
+
if (!file || !fs.existsSync(file))
|
|
14
|
+
return "";
|
|
15
|
+
return fs.readFileSync(file, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
/** Runs git's own textual merge over three strings. Returns null if git itself failed. */
|
|
18
|
+
export function mergeText(base, ours, theirs) {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-documentor-merge-"));
|
|
20
|
+
try {
|
|
21
|
+
const files = {
|
|
22
|
+
base: path.join(dir, "base"),
|
|
23
|
+
ours: path.join(dir, "ours"),
|
|
24
|
+
theirs: path.join(dir, "theirs"),
|
|
25
|
+
};
|
|
26
|
+
fs.writeFileSync(files.base, base, "utf-8");
|
|
27
|
+
fs.writeFileSync(files.ours, ours, "utf-8");
|
|
28
|
+
fs.writeFileSync(files.theirs, theirs, "utf-8");
|
|
29
|
+
const res = spawnSync("git",
|
|
30
|
+
// --diff3 keeps the common ancestor in the markers, so conflict inspection
|
|
31
|
+
// can show what each side changed rather than just what they disagree on.
|
|
32
|
+
["merge-file", "--diff3", "--stdout", "-L", "ours", "-L", "base", "-L", "theirs", files.ours, files.base, files.theirs], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
|
|
33
|
+
// git merge-file returns the conflict count, or a negative value (255) on error.
|
|
34
|
+
if (res.status === null || res.status < 0 || res.status === 255)
|
|
35
|
+
return null;
|
|
36
|
+
return { text: res.stdout, conflict: res.status > 0 };
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Last resort when structured merging is impossible: merge the raw files
|
|
44
|
+
* textually, exactly as git would have without this driver. Never leaves %A
|
|
45
|
+
* untouched — git requires the result to be written there either way.
|
|
46
|
+
*/
|
|
47
|
+
export function fallback(basePath, oursPath, theirsPath) {
|
|
48
|
+
const merged = mergeText(read(basePath), read(oursPath), read(theirsPath));
|
|
49
|
+
if (!merged)
|
|
50
|
+
return CONFLICT;
|
|
51
|
+
fs.writeFileSync(oursPath, merged.text, "utf-8");
|
|
52
|
+
return merged.conflict ? CONFLICT : CLEAN;
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic 3-way merge primitives shared by the git merge drivers.
|
|
3
|
+
*
|
|
4
|
+
* All helpers follow git's contract: `base` is the common ancestor, `ours` is
|
|
5
|
+
* the side being merged into, `theirs` is the incoming side. A value that only
|
|
6
|
+
* one side changed is taken from that side; a value both sides changed to the
|
|
7
|
+
* same thing is not a conflict.
|
|
8
|
+
*/
|
|
9
|
+
export type Merged<T> = {
|
|
10
|
+
conflict: false;
|
|
11
|
+
value: T;
|
|
12
|
+
} | {
|
|
13
|
+
conflict: true;
|
|
14
|
+
ours: T;
|
|
15
|
+
theirs: T;
|
|
16
|
+
};
|
|
17
|
+
/** Classic 3-way merge of a single value. Conflicts only when both sides diverged differently. */
|
|
18
|
+
export declare function mergeScalar<T>(base: T | undefined, ours: T | undefined, theirs: T | undefined): Merged<T | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Set merge that honours deletions: an entry disappears only if a side that had
|
|
21
|
+
* it in `base` removed it. Plain union would resurrect deleted entries forever.
|
|
22
|
+
* Order is stable — base order first, then additions from ours, then theirs.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mergeSet(base?: string[], ours?: string[], theirs?: string[]): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Set merge over objects identified by `keyOf`. Entries present on both sides
|
|
27
|
+
* with diverging content are resolved by `pick` rather than reported as a
|
|
28
|
+
* conflict — callers supply a domain rule (e.g. keep the freshest snapshot).
|
|
29
|
+
*/
|
|
30
|
+
export declare function mergeKeyedList<T>(base: T[] | undefined, ours: T[] | undefined, theirs: T[] | undefined, keyOf: (item: T) => string, pick: (ours: T, theirs: T) => T): T[];
|
|
31
|
+
/** Later of two date-ish strings. Falls back to lexicographic order for unparseable values. */
|
|
32
|
+
export declare function laterDate(a?: string, b?: string): string | undefined;
|
|
33
|
+
/** Earlier of two date-ish strings. Falls back to lexicographic order. */
|
|
34
|
+
export declare function earlierDate(a?: string, b?: string): string | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic 3-way merge primitives shared by the git merge drivers.
|
|
3
|
+
*
|
|
4
|
+
* All helpers follow git's contract: `base` is the common ancestor, `ours` is
|
|
5
|
+
* the side being merged into, `theirs` is the incoming side. A value that only
|
|
6
|
+
* one side changed is taken from that side; a value both sides changed to the
|
|
7
|
+
* same thing is not a conflict.
|
|
8
|
+
*/
|
|
9
|
+
function eq(a, b) {
|
|
10
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
11
|
+
}
|
|
12
|
+
/** Classic 3-way merge of a single value. Conflicts only when both sides diverged differently. */
|
|
13
|
+
export function mergeScalar(base, ours, theirs) {
|
|
14
|
+
if (eq(ours, theirs))
|
|
15
|
+
return { conflict: false, value: ours };
|
|
16
|
+
if (eq(ours, base))
|
|
17
|
+
return { conflict: false, value: theirs };
|
|
18
|
+
if (eq(theirs, base))
|
|
19
|
+
return { conflict: false, value: ours };
|
|
20
|
+
return { conflict: true, ours, theirs };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Set merge that honours deletions: an entry disappears only if a side that had
|
|
24
|
+
* it in `base` removed it. Plain union would resurrect deleted entries forever.
|
|
25
|
+
* Order is stable — base order first, then additions from ours, then theirs.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeSet(base = [], ours = [], theirs = []) {
|
|
28
|
+
const baseSet = new Set(base);
|
|
29
|
+
const ourSet = new Set(ours);
|
|
30
|
+
const theirSet = new Set(theirs);
|
|
31
|
+
const result = [];
|
|
32
|
+
for (const item of base) {
|
|
33
|
+
const removed = !ourSet.has(item) || !theirSet.has(item);
|
|
34
|
+
if (!removed)
|
|
35
|
+
result.push(item);
|
|
36
|
+
}
|
|
37
|
+
for (const item of [...ours, ...theirs]) {
|
|
38
|
+
if (!baseSet.has(item) && !result.includes(item))
|
|
39
|
+
result.push(item);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Set merge over objects identified by `keyOf`. Entries present on both sides
|
|
45
|
+
* with diverging content are resolved by `pick` rather than reported as a
|
|
46
|
+
* conflict — callers supply a domain rule (e.g. keep the freshest snapshot).
|
|
47
|
+
*/
|
|
48
|
+
export function mergeKeyedList(base = [], ours = [], theirs = [], keyOf, pick) {
|
|
49
|
+
const index = (list) => new Map(list.map((item) => [keyOf(item), item]));
|
|
50
|
+
const baseMap = index(base);
|
|
51
|
+
const ourMap = index(ours);
|
|
52
|
+
const theirMap = index(theirs);
|
|
53
|
+
const keys = mergeSet([...baseMap.keys()], [...ourMap.keys()], [...theirMap.keys()]);
|
|
54
|
+
const result = [];
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const o = ourMap.get(key);
|
|
57
|
+
const t = theirMap.get(key);
|
|
58
|
+
if (o && t)
|
|
59
|
+
result.push(eq(o, t) ? o : pick(o, t));
|
|
60
|
+
else if (o)
|
|
61
|
+
result.push(o);
|
|
62
|
+
else if (t)
|
|
63
|
+
result.push(t);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/** Later of two date-ish strings. Falls back to lexicographic order for unparseable values. */
|
|
68
|
+
export function laterDate(a, b) {
|
|
69
|
+
if (!a)
|
|
70
|
+
return b;
|
|
71
|
+
if (!b)
|
|
72
|
+
return a;
|
|
73
|
+
const da = Date.parse(a);
|
|
74
|
+
const db = Date.parse(b);
|
|
75
|
+
if (Number.isNaN(da) || Number.isNaN(db))
|
|
76
|
+
return a > b ? a : b;
|
|
77
|
+
return da >= db ? a : b;
|
|
78
|
+
}
|
|
79
|
+
/** Earlier of two date-ish strings. Falls back to lexicographic order. */
|
|
80
|
+
export function earlierDate(a, b) {
|
|
81
|
+
if (!a)
|
|
82
|
+
return b;
|
|
83
|
+
if (!b)
|
|
84
|
+
return a;
|
|
85
|
+
return laterDate(a, b) === a ? b : a;
|
|
86
|
+
}
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -192,6 +192,7 @@ const READ_ONLY_WRITE_ROUTES = [
|
|
|
192
192
|
{ method: "PUT", prefix: "/api/doc/" },
|
|
193
193
|
{ method: "POST", prefix: "/api/images" },
|
|
194
194
|
{ method: "POST", prefix: "/api/configurations/apply" },
|
|
195
|
+
{ method: "POST", prefix: "/api/sync" },
|
|
195
196
|
];
|
|
196
197
|
app.use("*", async (c, next) => {
|
|
197
198
|
if (isReadOnly()) {
|
|
@@ -314,6 +315,26 @@ app.get("/api/config", async (c) => {
|
|
|
314
315
|
return c.json({ error: result.error }, 500);
|
|
315
316
|
return c.json(result.data);
|
|
316
317
|
});
|
|
318
|
+
app.get("/api/sync/status", async (c) => {
|
|
319
|
+
const result = await ipcRequest("SYNC_STATUS", {}, 20000);
|
|
320
|
+
if (result.error)
|
|
321
|
+
return c.json({ error: result.error }, 500);
|
|
322
|
+
return c.json(result.data);
|
|
323
|
+
});
|
|
324
|
+
app.post("/api/sync", async (c) => {
|
|
325
|
+
let message;
|
|
326
|
+
try {
|
|
327
|
+
message = (await c.req.json())?.message;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Body is optional — the commit message falls back to an auto-generated one.
|
|
331
|
+
}
|
|
332
|
+
// Network round-trips (fetch + push) plus a possible embedding reindex.
|
|
333
|
+
const result = await ipcRequest("SYNC_DOCS", { message }, 180000);
|
|
334
|
+
if (result.error)
|
|
335
|
+
return c.json({ error: result.error }, 500);
|
|
336
|
+
return c.json(result.data);
|
|
337
|
+
});
|
|
317
338
|
app.get("/api/projects", async (c) => {
|
|
318
339
|
const result = await ipcRequest("LIST_PROJECTS");
|
|
319
340
|
if (result.error)
|
package/dist/src/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import { uploadImage } from "./tools/upload-image.js";
|
|
|
43
43
|
import { getImage } from "./tools/get-image.js";
|
|
44
44
|
import { SyncEngine } from "./core/git-sync.js";
|
|
45
45
|
import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
|
|
46
|
+
import { listConflicts, getConflictHunks, resolveConflictHunks } from "./tools/conflicts.js";
|
|
46
47
|
import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
|
|
47
48
|
const __filename = fileURLToPath(import.meta.url);
|
|
48
49
|
const __dirname = path.dirname(__filename);
|
|
@@ -167,6 +168,12 @@ function handleIPCMessage(msg) {
|
|
|
167
168
|
else if (msg.type === "GET_FILE_SECTION") {
|
|
168
169
|
handleGetFileSection(msg);
|
|
169
170
|
}
|
|
171
|
+
else if (msg.type === "SYNC_STATUS") {
|
|
172
|
+
handleSyncStatusIPC(msg);
|
|
173
|
+
}
|
|
174
|
+
else if (msg.type === "SYNC_DOCS") {
|
|
175
|
+
handleSyncDocsIPC(msg);
|
|
176
|
+
}
|
|
170
177
|
else {
|
|
171
178
|
logger.debug(`Unhandled message from GUI: ${msg.type}`);
|
|
172
179
|
}
|
|
@@ -197,7 +204,14 @@ async function handleGetInitialState(msg) {
|
|
|
197
204
|
broadcastToGUI({
|
|
198
205
|
type: "INITIAL_STATE",
|
|
199
206
|
correlationId: msg.correlationId,
|
|
200
|
-
data: {
|
|
207
|
+
data: {
|
|
208
|
+
config,
|
|
209
|
+
docs,
|
|
210
|
+
projects: config.projects,
|
|
211
|
+
bundles,
|
|
212
|
+
readOnly: isReadOnly(),
|
|
213
|
+
syncEnabled: syncEngine.isConfigured(),
|
|
214
|
+
},
|
|
201
215
|
timestamp: new Date().toISOString(),
|
|
202
216
|
});
|
|
203
217
|
}
|
|
@@ -210,7 +224,7 @@ async function handleGetConfig(msg) {
|
|
|
210
224
|
broadcastToGUI({
|
|
211
225
|
type: "CONFIG_RESPONSE",
|
|
212
226
|
correlationId: msg.correlationId,
|
|
213
|
-
data: { ...config, version: getConfig().version, readOnly: isReadOnly() },
|
|
227
|
+
data: { ...config, version: getConfig().version, readOnly: isReadOnly(), syncEnabled: syncEngine.isConfigured() },
|
|
214
228
|
timestamp: new Date().toISOString(),
|
|
215
229
|
});
|
|
216
230
|
}
|
|
@@ -636,6 +650,81 @@ async function handleGitDiff(msg) {
|
|
|
636
650
|
});
|
|
637
651
|
}
|
|
638
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Sync state for the GUI button. `configured` is false when the environment has
|
|
655
|
+
* no MCP_DOCUMENTOR_SYNC_REMOTE — the GUI then hides the button entirely.
|
|
656
|
+
*/
|
|
657
|
+
async function handleSyncStatusIPC(msg) {
|
|
658
|
+
try {
|
|
659
|
+
if (!syncEngine.isConfigured()) {
|
|
660
|
+
broadcastToGUI({
|
|
661
|
+
type: "SYNC_STATUS_RESPONSE",
|
|
662
|
+
correlationId: msg.correlationId,
|
|
663
|
+
data: { configured: false },
|
|
664
|
+
timestamp: new Date().toISOString(),
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const s = await syncEngine.status();
|
|
669
|
+
broadcastToGUI({
|
|
670
|
+
type: "SYNC_STATUS_RESPONSE",
|
|
671
|
+
correlationId: msg.correlationId,
|
|
672
|
+
data: { configured: true, ...s },
|
|
673
|
+
timestamp: new Date().toISOString(),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
broadcastToGUI({
|
|
678
|
+
type: "SYNC_STATUS_RESPONSE",
|
|
679
|
+
correlationId: msg.correlationId,
|
|
680
|
+
error: err.message,
|
|
681
|
+
timestamp: new Date().toISOString(),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async function handleSyncDocsIPC(msg) {
|
|
686
|
+
if (isReadOnly()) {
|
|
687
|
+
broadcastToGUI({
|
|
688
|
+
type: "SYNC_DOCS_RESPONSE",
|
|
689
|
+
correlationId: msg.correlationId,
|
|
690
|
+
error: "READ_ONLY mode: sync is disabled",
|
|
691
|
+
timestamp: new Date().toISOString(),
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
// Reuse the tool so the GUI and the agent get identical pull/push semantics.
|
|
697
|
+
const result = await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, {
|
|
698
|
+
message: msg.data?.message,
|
|
699
|
+
});
|
|
700
|
+
const text = result.content.map((c) => c.text).join("\n");
|
|
701
|
+
if (result.isError) {
|
|
702
|
+
broadcastToGUI({
|
|
703
|
+
type: "SYNC_DOCS_RESPONSE",
|
|
704
|
+
correlationId: msg.correlationId,
|
|
705
|
+
error: text,
|
|
706
|
+
timestamp: new Date().toISOString(),
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
broadcastToGUI({
|
|
711
|
+
type: "SYNC_DOCS_RESPONSE",
|
|
712
|
+
correlationId: msg.correlationId,
|
|
713
|
+
data: { message: text },
|
|
714
|
+
timestamp: new Date().toISOString(),
|
|
715
|
+
});
|
|
716
|
+
// A pull can add/remove docs — push a fresh tree to every connected GUI.
|
|
717
|
+
await handleGetInitialState({});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
broadcastToGUI({
|
|
721
|
+
type: "SYNC_DOCS_RESPONSE",
|
|
722
|
+
correlationId: msg.correlationId,
|
|
723
|
+
error: err.message,
|
|
724
|
+
timestamp: new Date().toISOString(),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
639
728
|
async function handleUpdateConfig(msg) {
|
|
640
729
|
if (isReadOnly()) {
|
|
641
730
|
broadcastToGUI({
|
|
@@ -1201,17 +1290,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1201
1290
|
},
|
|
1202
1291
|
{
|
|
1203
1292
|
name: "sync_status",
|
|
1204
|
-
description: "Show the synchronization status: current branch, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
|
|
1293
|
+
description: "Show the synchronization status: current branch, whether a merge or rebase is in progress, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
|
|
1205
1294
|
inputSchema: { type: "object", properties: {} },
|
|
1206
1295
|
},
|
|
1207
1296
|
{
|
|
1208
1297
|
name: "pull_docs",
|
|
1209
|
-
description: "Pull documentation updates from the GitHub remote. Defensive:
|
|
1298
|
+
description: "Pull documentation updates from the GitHub remote. Defensive: fast-forwards when purely behind, and rebases local commits on top of the remote when the history diverged. Never touches an uncommitted local change. A conflict stops the rebase with both versions preserved - resolve it with resolve_sync_conflict. Reindexes embeddings for new documents after a successful update.",
|
|
1210
1299
|
inputSchema: { type: "object", properties: {} },
|
|
1211
1300
|
},
|
|
1212
1301
|
{
|
|
1213
1302
|
name: "push_docs",
|
|
1214
|
-
description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote.",
|
|
1303
|
+
description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote. Refuses to run while a conflict is unresolved, so conflict markers are never committed as documentation content.",
|
|
1215
1304
|
inputSchema: {
|
|
1216
1305
|
type: "object",
|
|
1217
1306
|
properties: {
|
|
@@ -1221,7 +1310,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1221
1310
|
},
|
|
1222
1311
|
{
|
|
1223
1312
|
name: "sync_docs",
|
|
1224
|
-
description: "Full synchronization: pull (
|
|
1313
|
+
description: "Full synchronization: pull (fast-forward or rebase) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first with resolve_sync_conflict.",
|
|
1225
1314
|
inputSchema: {
|
|
1226
1315
|
type: "object",
|
|
1227
1316
|
properties: {
|
|
@@ -1231,14 +1320,56 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1231
1320
|
},
|
|
1232
1321
|
{
|
|
1233
1322
|
name: "resolve_sync_conflict",
|
|
1234
|
-
description: "Bulk-resolve sync
|
|
1323
|
+
description: "Bulk-resolve sync conflicts by keeping one side for the conflicted files, stage them, and carry the merge or rebase to completion when nothing is left conflicted. Sides are absolute ('local' vs 'remote'), not git's ours/theirs, which swap meaning between a merge and a rebase. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
|
|
1235
1324
|
inputSchema: {
|
|
1236
1325
|
type: "object",
|
|
1237
1326
|
properties: {
|
|
1238
|
-
|
|
1327
|
+
side: { type: "string", enum: ["local", "remote"], description: "'local' keeps this machine's version, 'remote' keeps the incoming version from the other machine" },
|
|
1239
1328
|
paths: { type: "array", items: { type: "string" }, description: "Specific conflicted file paths to resolve (defaults to all conflicted files)" },
|
|
1240
1329
|
},
|
|
1241
|
-
required: ["
|
|
1330
|
+
required: ["side"],
|
|
1331
|
+
},
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
name: "list_conflicts",
|
|
1335
|
+
description: "List every file left conflicted by a sync, without opening any of them: which project and document each one is, whether it is a content / delete-modify / add-add conflict, how many hunks it has and in which markdown sections, and who last changed it on each side. Sides are absolute: 'local' is this machine, 'remote' is the other machine. Start here when a pull reports a conflict.",
|
|
1336
|
+
inputSchema: { type: "object", properties: {} },
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
name: "get_conflict_hunks",
|
|
1340
|
+
description: "Return only the passages two machines disagree on in one conflicted document, as numbered hunks: the section each falls in, the common ancestor, and both sides. Output is capped per side so a large document never has to be read in full. Use this instead of get_doc when resolving a sync conflict.",
|
|
1341
|
+
inputSchema: {
|
|
1342
|
+
type: "object",
|
|
1343
|
+
properties: {
|
|
1344
|
+
path: { type: "string", description: "Conflicted file path as reported by list_conflicts (e.g. 'projects/alpha/api.md')" },
|
|
1345
|
+
hunks: { type: "array", items: { type: "number" }, description: "Specific hunk numbers to return (defaults to all)" },
|
|
1346
|
+
max_chars_per_side: { type: "number", description: "Character budget per side, per hunk (default 1200). Anything beyond it is withheld and counted." },
|
|
1347
|
+
},
|
|
1348
|
+
required: ["path"],
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
name: "resolve_conflict_hunks",
|
|
1353
|
+
description: "Resolve a conflicted document hunk by hunk. Each decision keeps the local side, the remote side, both (local first), neither, or your own replacement text. The file is only staged once every hunk has been decided, so conflict markers can never be committed as documentation content. When the file is the last one conflicted, the merge or rebase is carried to completion.",
|
|
1354
|
+
inputSchema: {
|
|
1355
|
+
type: "object",
|
|
1356
|
+
properties: {
|
|
1357
|
+
path: { type: "string", description: "Conflicted file path as reported by list_conflicts" },
|
|
1358
|
+
decisions: {
|
|
1359
|
+
type: "array",
|
|
1360
|
+
description: "One entry per hunk you are deciding. Undecided hunks keep their markers.",
|
|
1361
|
+
items: {
|
|
1362
|
+
type: "object",
|
|
1363
|
+
properties: {
|
|
1364
|
+
hunk: { type: "number", description: "Hunk number from get_conflict_hunks" },
|
|
1365
|
+
take: { type: "string", enum: ["local", "remote", "both", "none", "custom"], description: "'local' this machine, 'remote' the other machine, 'both' concatenates local then remote, 'none' drops the passage, 'custom' uses the provided content. 'both' is refused on frontmatter hunks, where it would duplicate a YAML key." },
|
|
1366
|
+
content: { type: "string", description: "Replacement text, required when take is 'custom'" },
|
|
1367
|
+
},
|
|
1368
|
+
required: ["hunk", "take"],
|
|
1369
|
+
},
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
required: ["path", "decisions"],
|
|
1242
1373
|
},
|
|
1243
1374
|
},
|
|
1244
1375
|
{
|
|
@@ -1342,6 +1473,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1342
1473
|
return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
|
|
1343
1474
|
case "resolve_sync_conflict":
|
|
1344
1475
|
return await resolveSyncConflict(syncEngine, args);
|
|
1476
|
+
case "list_conflicts":
|
|
1477
|
+
return await listConflicts(syncEngine);
|
|
1478
|
+
case "get_conflict_hunks":
|
|
1479
|
+
return await getConflictHunks(syncEngine, args);
|
|
1480
|
+
case "resolve_conflict_hunks":
|
|
1481
|
+
return await resolveConflictHunks(syncEngine, args);
|
|
1345
1482
|
case "reconnect_gui":
|
|
1346
1483
|
return await reconnectGui(args);
|
|
1347
1484
|
default:
|
|
@@ -1368,9 +1505,6 @@ async function syncPullOnStartup() {
|
|
|
1368
1505
|
await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
|
|
1369
1506
|
}
|
|
1370
1507
|
}
|
|
1371
|
-
else if (res.status === "diverged") {
|
|
1372
|
-
logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
|
|
1373
|
-
}
|
|
1374
1508
|
else if (res.status === "conflict") {
|
|
1375
1509
|
logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
|
|
1376
1510
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SyncEngine } from "../core/git-sync.js";
|
|
2
|
+
type ToolResult = {
|
|
3
|
+
content: {
|
|
4
|
+
type: "text";
|
|
5
|
+
text: string;
|
|
6
|
+
}[];
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function listConflicts(syncEngine: SyncEngine): Promise<ToolResult>;
|
|
10
|
+
export declare function getConflictHunks(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
|
|
11
|
+
export declare function resolveConflictHunks(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parseConflicts, applyResolutions, preview, hasConflictMarkers } from "../core/conflict-inspector.js";
|
|
4
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
5
|
+
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
6
|
+
const NOT_CONFIGURED = "Sync is not configured. Set MCP_DOCUMENTOR_SYNC_REMOTE (and MCP_DOCUMENTOR_SYNC_TOKEN) in the server environment.";
|
|
7
|
+
/** Documents live at projects/<project>/<id>.md — recover both for reporting. */
|
|
8
|
+
function describeTarget(filePath) {
|
|
9
|
+
const match = filePath.match(/^projects\/([^/]+)\/(.+)\.md$/);
|
|
10
|
+
return match ? `project "${match[1]}", doc "${match[2]}"` : filePath;
|
|
11
|
+
}
|
|
12
|
+
function describeSide(label, side) {
|
|
13
|
+
if (!side)
|
|
14
|
+
return ` ${label}: (no commit recorded)`;
|
|
15
|
+
return ` ${label}: ${side.author} · ${side.date} · "${side.subject}"`;
|
|
16
|
+
}
|
|
17
|
+
export async function listConflicts(syncEngine) {
|
|
18
|
+
try {
|
|
19
|
+
if (!syncEngine.isConfigured())
|
|
20
|
+
return err(NOT_CONFIGURED);
|
|
21
|
+
const { operation, files } = await syncEngine.listConflicts();
|
|
22
|
+
if (files.length === 0)
|
|
23
|
+
return ok("No conflicts. Nothing to resolve.");
|
|
24
|
+
const lines = [
|
|
25
|
+
`${files.length} conflicted file(s) during a ${operation}.`,
|
|
26
|
+
`"local" is this machine's version, "remote" is the other machine's.`,
|
|
27
|
+
"",
|
|
28
|
+
];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
lines.push(`${file.path} (${describeTarget(file.path)})`);
|
|
31
|
+
if (file.kind === "delete-modify") {
|
|
32
|
+
lines.push(` Kind: delete/modify — the ${file.deletedOn} side deleted this document while the other edited it.`);
|
|
33
|
+
}
|
|
34
|
+
else if (file.kind === "add-add") {
|
|
35
|
+
lines.push(` Kind: add/add — both sides created this document independently; there is no common ancestor.`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Counting hunks needs the working file, which only exists for content
|
|
39
|
+
// conflicts (a deleted side leaves nothing on disk).
|
|
40
|
+
try {
|
|
41
|
+
const { hunks } = parseConflicts(syncEngine.readWorkingFile(file.path), operation);
|
|
42
|
+
const sections = [...new Set(hunks.map((h) => h.section))];
|
|
43
|
+
lines.push(` Kind: content — ${hunks.length} conflicting hunk(s) in: ${sections.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
lines.push(` Kind: content`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push(describeSide("local ", file.local), describeSide("remote", file.remote), "");
|
|
50
|
+
}
|
|
51
|
+
lines.push(`Next: get_conflict_hunks to see just the disagreeing passages, resolve_conflict_hunks to decide hunk by hunk,`, `or resolve_sync_conflict to keep one whole side for every file at once.`);
|
|
52
|
+
return ok(lines.join("\n"));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return err(`Error listing conflicts: ${e instanceof Error ? e.message : String(e)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const GetHunksArgs = z.object({
|
|
59
|
+
path: z.string(),
|
|
60
|
+
hunks: z.array(z.number().int().positive()).optional(),
|
|
61
|
+
max_chars_per_side: z.number().int().positive().max(20000).default(1200),
|
|
62
|
+
});
|
|
63
|
+
export async function getConflictHunks(syncEngine, args) {
|
|
64
|
+
try {
|
|
65
|
+
if (!syncEngine.isConfigured())
|
|
66
|
+
return err(NOT_CONFIGURED);
|
|
67
|
+
const v = GetHunksArgs.parse(args);
|
|
68
|
+
const operation = await syncEngine.operationInProgress();
|
|
69
|
+
const { hunks } = parseConflicts(syncEngine.readWorkingFile(v.path), operation);
|
|
70
|
+
if (hunks.length === 0)
|
|
71
|
+
return ok(`No conflict markers in "${v.path}".`);
|
|
72
|
+
const wanted = v.hunks?.length ? hunks.filter((h) => v.hunks.includes(h.index)) : hunks;
|
|
73
|
+
if (wanted.length === 0)
|
|
74
|
+
return ok(`None of the requested hunks exist in "${v.path}" (it has ${hunks.length}).`);
|
|
75
|
+
const lines = [`${v.path} — ${wanted.length} of ${hunks.length} hunk(s), during a ${operation}.`, ""];
|
|
76
|
+
for (const hunk of wanted) {
|
|
77
|
+
const scope = hunk.inFrontmatter ? " [frontmatter — \"both\" is not available, it would duplicate a key]" : "";
|
|
78
|
+
lines.push(`Hunk ${hunk.index} — section "${hunk.section}" (line ${hunk.line})${scope}`);
|
|
79
|
+
if (hunk.base)
|
|
80
|
+
lines.push(` [base — what both sides started from]`, preview(hunk.base, v.max_chars_per_side));
|
|
81
|
+
lines.push(` [local — this machine]`, preview(hunk.local, v.max_chars_per_side));
|
|
82
|
+
lines.push(` [remote — the other machine]`, preview(hunk.remote, v.max_chars_per_side));
|
|
83
|
+
lines.push("");
|
|
84
|
+
}
|
|
85
|
+
lines.push(`Resolve with resolve_conflict_hunks: take "local", "remote", "both", "none" (drop it), or "custom" with your own text.`);
|
|
86
|
+
return ok(lines.join("\n"));
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
return err(`Error reading conflict hunks: ${e instanceof Error ? e.message : String(e)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const ResolveHunksArgs = z.object({
|
|
93
|
+
path: z.string(),
|
|
94
|
+
decisions: z
|
|
95
|
+
.array(z.object({
|
|
96
|
+
hunk: z.number().int().positive(),
|
|
97
|
+
take: z.enum(["local", "remote", "both", "none", "custom"]),
|
|
98
|
+
content: z.string().optional(),
|
|
99
|
+
}))
|
|
100
|
+
.min(1),
|
|
101
|
+
});
|
|
102
|
+
export async function resolveConflictHunks(syncEngine, args) {
|
|
103
|
+
try {
|
|
104
|
+
if (!syncEngine.isConfigured())
|
|
105
|
+
return err(NOT_CONFIGURED);
|
|
106
|
+
const v = ResolveHunksArgs.parse(args);
|
|
107
|
+
const missingContent = v.decisions.filter((d) => d.take === "custom" && d.content === undefined);
|
|
108
|
+
if (missingContent.length > 0) {
|
|
109
|
+
return err(`Hunk(s) ${missingContent.map((d) => d.hunk).join(", ")} use take "custom" but no content was provided.`);
|
|
110
|
+
}
|
|
111
|
+
const operation = await syncEngine.operationInProgress();
|
|
112
|
+
const decisions = v.decisions.map((d) => d.take === "custom" ? { index: d.hunk, take: "custom", content: d.content } : { index: d.hunk, take: d.take });
|
|
113
|
+
const original = syncEngine.readWorkingFile(v.path);
|
|
114
|
+
if (!hasConflictMarkers(original)) {
|
|
115
|
+
return err(`"${v.path}" has no conflict markers — there is nothing to resolve hunk by hunk.`);
|
|
116
|
+
}
|
|
117
|
+
// Concatenating both sides of a frontmatter hunk repeats the key it holds,
|
|
118
|
+
// which makes the YAML unparseable for every reader of the document.
|
|
119
|
+
const { hunks } = parseConflicts(original, operation);
|
|
120
|
+
const badBoth = v.decisions.filter((d) => d.take === "both" && hunks.find((h) => h.index === d.hunk)?.inFrontmatter);
|
|
121
|
+
if (badBoth.length > 0) {
|
|
122
|
+
return err(`Hunk(s) ${badBoth.map((d) => d.hunk).join(", ")} concern frontmatter, where "both" would duplicate a key and break the YAML. ` +
|
|
123
|
+
`Use "local", "remote", or "custom" with the single value you want.`);
|
|
124
|
+
}
|
|
125
|
+
// A hoisted frontmatter block lives in the body but describes YAML keys.
|
|
126
|
+
// Applying its chosen side literally would paste "title: X" into the prose
|
|
127
|
+
// and leave the real frontmatter untouched, so those hunks are dropped from
|
|
128
|
+
// the body and their values folded back into the frontmatter instead.
|
|
129
|
+
const frontmatterUpdates = {};
|
|
130
|
+
const effective = decisions.map((decision) => {
|
|
131
|
+
const hunk = hunks.find((h) => h.index === decision.index);
|
|
132
|
+
if (!hunk?.hoisted)
|
|
133
|
+
return decision;
|
|
134
|
+
const chosen = decision.take === "local" ? hunk.local
|
|
135
|
+
: decision.take === "remote" ? hunk.remote
|
|
136
|
+
: decision.take === "custom" ? decision.content.split(/\r?\n/)
|
|
137
|
+
: [];
|
|
138
|
+
if (chosen.length > 0) {
|
|
139
|
+
try {
|
|
140
|
+
const asYaml = ["---", ...chosen, "---", ""].join("\n");
|
|
141
|
+
Object.assign(frontmatterUpdates, matter(asYaml).data);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Not parseable as YAML - leave the frontmatter alone rather than
|
|
145
|
+
// corrupting it; the caller still sees the block disappear.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { index: decision.index, take: "none" };
|
|
149
|
+
});
|
|
150
|
+
const result = applyResolutions(original, operation, effective);
|
|
151
|
+
if (Object.keys(frontmatterUpdates).length > 0) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = matter(result.text);
|
|
154
|
+
result.text = matter.stringify(parsed.content, { ...parsed.data, ...frontmatterUpdates });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Frontmatter still holds markers of its own; it will be rewritten
|
|
158
|
+
// once those hunks are resolved too.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// The invariant is checked against the resulting text rather than the hunk
|
|
162
|
+
// bookkeeping: staging a file that still holds markers tells git the
|
|
163
|
+
// conflict is settled and lets them be committed as documentation content.
|
|
164
|
+
const settled = !hasConflictMarkers(result.text);
|
|
165
|
+
await syncEngine.writeResolvedFile(v.path, result.text, settled);
|
|
166
|
+
const lines = [`Resolved ${result.applied.length} hunk(s) in "${v.path}".`];
|
|
167
|
+
if (result.unknown.length > 0) {
|
|
168
|
+
lines.push(`Ignored — no such hunk: ${result.unknown.join(", ")}.`);
|
|
169
|
+
}
|
|
170
|
+
if (!settled) {
|
|
171
|
+
lines.push(`${result.remaining} hunk(s) still conflicted; the file is left unstaged until every hunk is decided.`, `Run get_conflict_hunks again — the remaining hunks have been renumbered.`);
|
|
172
|
+
return ok(lines.join("\n"));
|
|
173
|
+
}
|
|
174
|
+
lines.push(`File fully resolved and staged.`);
|
|
175
|
+
const finish = await syncEngine.continueOperation();
|
|
176
|
+
if (finish.remaining.length > 0) {
|
|
177
|
+
lines.push(``, `Still conflicted elsewhere (${finish.remaining.length}):`, ` - ${finish.remaining.join("\n - ")}`);
|
|
178
|
+
}
|
|
179
|
+
else if (finish.done && finish.operation !== "none") {
|
|
180
|
+
lines.push(``, `The ${finish.operation} is complete. Run push_docs to publish.`);
|
|
181
|
+
}
|
|
182
|
+
else if (finish.error) {
|
|
183
|
+
lines.push(``, `The ${finish.operation} could not be completed: ${finish.error}`);
|
|
184
|
+
}
|
|
185
|
+
return ok(lines.join("\n"));
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return err(`Error resolving hunks: ${e instanceof Error ? e.message : String(e)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|