@imenam/mcp-documentor 1.0.71 → 1.0.72
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 +50 -0
- package/dist/src/core/conflict-inspector.js +140 -0
- package/dist/src/core/git-sync.d.ts +102 -4
- package/dist/src/core/git-sync.js +265 -25
- 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 -9
- package/dist/src/tools/conflicts.d.ts +12 -0
- package/dist/src/tools/conflicts.js +145 -0
- package/dist/src/tools/sync.js +43 -6
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/{arc-Carsq0k3.js → arc-KWKRz6LO.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-caqcwdTw.js → architectureDiagram-3BPJPVTR-Cjfl98ZS.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-0wUO8k2I.js → blockDiagram-GPEHLZMM-CtPUrnse.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-DS6q7fMy.js → c4Diagram-AAUBKEIU-Dh5_7IZy.js} +1 -1
- package/gui/dist/assets/channel-JejxY436.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-CrJc-G6D.js → chunk-2J33WTMH-44T5JbXy.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-C5vtPFbB.js → chunk-4BX2VUAB-D1fEQ0Ng.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-BchyQUFM.js → chunk-55IACEB6-M2lEB2Vn.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BCIaWtKq.js → chunk-727SXJPM-B3A_Fp4f.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-BEQJDJt5.js → chunk-AQP2D5EJ-CqkJXv3c.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-IfQ3QtrC.js → chunk-FMBD7UC4-Iyj_Ky8e.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-4Du33w7t.js → chunk-ND2GUHAM-DE5R-C9U.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-HR0CP6fa.js → chunk-QZHKN3VN-BekBU-YP.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-CdAqI_QA.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CdAqI_QA.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-C9Re3ENx.js → cose-bilkent-S5V4N54A-Dw2zOlxr.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-DZmcvMMp.js → dagre-BM42HDAG-ebVZNyxI.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-EyJrAGht.js → diagram-2AECGRRQ-Bh5FUKy0.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-CHd3T9_b.js → diagram-5GNKFQAL-YJJTJy-Q.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CIyFrZSR.js → diagram-KO2AKTUF-D161kBvB.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-BlkJALyz.js → diagram-LMA3HP47-Drcmx9J8.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-P8LA7IZE.js → diagram-OG6HWLK6-DRrxnW_H.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BPywgWN1.js → erDiagram-TEJ5UH35-FrH4iEa3.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-ByL0kGiV.js → flowDiagram-I6XJVG4X-BMxSElvH.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-_pnaYCU4.js → ganttDiagram-6RSMTGT7-H09FGGUr.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-DN67CDGs.js → gitGraphDiagram-PVQCEYII-DLhPfXNp.js} +1 -1
- package/gui/dist/assets/{index-Bjc5MOe1.js → index-CdGsMXYw.js} +53 -53
- package/gui/dist/assets/index-DAzSbGj_.css +1 -0
- package/gui/dist/assets/{infoDiagram-5YYISTIA-Bm5LEpzH.js → infoDiagram-5YYISTIA-CV8ctX8t.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DUYSULkT.js → ishikawaDiagram-YF4QCWOH-BeuHUSPO.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-BvJ1_f0L.js → journeyDiagram-JHISSGLW-CfAHwYXQ.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-DDYKg6RY.js → kanban-definition-UN3LZRKU-CFF4KWbn.js} +1 -1
- package/gui/dist/assets/{linear-HlpG9u6P.js → linear-DwZpGeRp.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CPa_wo_s.js → mindmap-definition-RKZ34NQL-BW0yW4ap.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-BWo0c4DP.js → pieDiagram-4H26LBE5-plBCN_c7.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CLahU-6B.js → quadrantDiagram-W4KKPZXB-Dmp9sOq_.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BoYbgzQ7.js → requirementDiagram-4Y6WPE33-CthKC15J.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CGm79aMo.js → sankeyDiagram-5OEKKPKP-BCAzzfPU.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-BkknTD52.js → sequenceDiagram-3UESZ5HK-DgwOe82k.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-DSBurgwC.js → stateDiagram-AJRCARHV-B6k-x1TC.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-bfw3SR0t.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-CBHyrGuz.js → timeline-definition-PNZ67QCA-CqwqJkZw.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-Dn9fr_bR.js → vennDiagram-CIIHVFJN-REFDrI5t.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-nMuySeAb.js → wardley-L42UT6IY-53gLZWfR.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-Byn2STLI.js → wardleyDiagram-YWT4CUSO-OPfKpNx-.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-Bi-Y9wp5.js → xychartDiagram-2RQKCTM6-HWGySgFg.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- 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
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
114
|
-
*
|
|
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.
|
|
@@ -151,19 +220,20 @@ export class SyncEngine {
|
|
|
151
220
|
const after = await this.git.revparse(["HEAD"]).catch(() => "");
|
|
152
221
|
return { status: "updated", changed: await this.diffNames(before, after) };
|
|
153
222
|
}
|
|
154
|
-
// Diverged with a clean tree →
|
|
155
|
-
//
|
|
223
|
+
// Diverged with a clean tree → replay local commits on top of the remote. A
|
|
224
|
+
// conflict stops the rebase with both versions preserved in the index, so it
|
|
225
|
+
// can be resolved and the rebase continued.
|
|
156
226
|
try {
|
|
157
|
-
await this.git.
|
|
227
|
+
await this.git.raw(["rebase", `origin/${cfg.branch}`]);
|
|
158
228
|
}
|
|
159
229
|
catch {
|
|
160
230
|
const after = await this.git.status();
|
|
161
231
|
if (after.conflicted.length > 0)
|
|
162
232
|
return { status: "conflict", conflicted: after.conflicted };
|
|
163
|
-
throw new Error("
|
|
233
|
+
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
234
|
}
|
|
165
|
-
const
|
|
166
|
-
return { status: "updated", changed: await this.diffNames(before,
|
|
235
|
+
const rebased = await this.git.revparse(["HEAD"]).catch(() => "");
|
|
236
|
+
return { status: "updated", changed: await this.diffNames(before, rebased) };
|
|
167
237
|
}
|
|
168
238
|
async diffNames(before, after) {
|
|
169
239
|
if (!before || !after || before === after)
|
|
@@ -174,6 +244,14 @@ export class SyncEngine {
|
|
|
174
244
|
async push(message) {
|
|
175
245
|
const cfg = this.requireConfig();
|
|
176
246
|
await this.ensureRepo();
|
|
247
|
+
// `add -A` would happily stage conflict markers and commit them as content,
|
|
248
|
+
// so an unfinished merge or rebase has to stop the push outright.
|
|
249
|
+
const operation = await this.operationInProgress();
|
|
250
|
+
const pending = await this.git.status();
|
|
251
|
+
if (operation !== "none" || pending.conflicted.length > 0) {
|
|
252
|
+
throw new Error(`A ${operation === "none" ? "conflict" : operation} is still unresolved (${pending.conflicted.length} conflicted file(s)). ` +
|
|
253
|
+
"Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
|
|
254
|
+
}
|
|
177
255
|
// .gitignore (written by ensureRepo) excludes all derived/local state,
|
|
178
256
|
// so -A safely stages only versioned files (projects/, config.json, .gitignore).
|
|
179
257
|
await this.git.add(["-A"]);
|
|
@@ -197,19 +275,181 @@ export class SyncEngine {
|
|
|
197
275
|
}
|
|
198
276
|
return { pushed: true, committed, message: committed ? "Committed and pushed." : "Nothing new to commit; pushed existing commits." };
|
|
199
277
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Detects whether a merge or a rebase is mid-flight. Read through
|
|
280
|
+
* `rev-parse --git-path` rather than assuming `.git` is a directory, so this
|
|
281
|
+
* still works in a worktree or a submodule.
|
|
282
|
+
*/
|
|
283
|
+
async operationInProgress() {
|
|
284
|
+
const marker = async (name) => {
|
|
285
|
+
const rel = await this.git.raw(["rev-parse", "--git-path", name]).catch(() => "");
|
|
286
|
+
if (!rel.trim())
|
|
287
|
+
return false;
|
|
288
|
+
return fs.existsSync(path.resolve(this.dataDir, rel.trim()));
|
|
289
|
+
};
|
|
290
|
+
if ((await marker("rebase-merge")) || (await marker("rebase-apply")))
|
|
291
|
+
return "rebase";
|
|
292
|
+
if (await marker("MERGE_HEAD"))
|
|
293
|
+
return "merge";
|
|
294
|
+
return "none";
|
|
295
|
+
}
|
|
296
|
+
/** Index stages recorded for a conflicted path: 1 = base, 2 = ours, 3 = theirs. */
|
|
297
|
+
async conflictStages(filePath) {
|
|
298
|
+
const out = await this.git.raw(["ls-files", "-u", "--", filePath]).catch(() => "");
|
|
299
|
+
const stages = new Set();
|
|
300
|
+
for (const line of out.split("\n")) {
|
|
301
|
+
const match = line.match(/^\S+ \S+ (\d)\t/);
|
|
302
|
+
if (match)
|
|
303
|
+
stages.add(Number(match[1]));
|
|
304
|
+
}
|
|
305
|
+
return stages;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Finishes the in-flight merge or rebase once every conflict is staged.
|
|
309
|
+
* A rebase can stop again on the next replayed commit, so the caller is told
|
|
310
|
+
* what is still outstanding rather than being promised completion.
|
|
311
|
+
*
|
|
312
|
+
* `git rebase --continue` insists on an editor for the replayed commit
|
|
313
|
+
* message, and simple-git blocks both `-c core.editor` and GIT_EDITOR as
|
|
314
|
+
* injection vectors. This one command therefore goes out directly with a no-op
|
|
315
|
+
* editor - the value is a fixed literal here, never user input.
|
|
316
|
+
*/
|
|
317
|
+
async continueOperation() {
|
|
318
|
+
const operation = await this.operationInProgress();
|
|
319
|
+
if (operation === "none")
|
|
320
|
+
return { operation, done: true, remaining: [] };
|
|
321
|
+
const before = await this.git.status();
|
|
322
|
+
if (before.conflicted.length > 0) {
|
|
323
|
+
return { operation, done: false, remaining: before.conflicted };
|
|
324
|
+
}
|
|
325
|
+
// A rebase legitimately stops again on the next commit's conflicts, so a
|
|
326
|
+
// non-zero exit is not necessarily a failure - the state read back below is
|
|
327
|
+
// the real report.
|
|
328
|
+
const run = await this.gitDirect(operation === "rebase" ? ["rebase", "--continue"] : ["commit", "--no-edit"], {
|
|
329
|
+
GIT_EDITOR: "true",
|
|
330
|
+
});
|
|
331
|
+
const error = run.ok ? undefined : run.stderr;
|
|
332
|
+
const still = await this.operationInProgress();
|
|
333
|
+
const after = await this.git.status();
|
|
334
|
+
const done = still === "none";
|
|
335
|
+
return { operation, done, remaining: after.conflicted, ...(!done && error ? { error } : {}) };
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Runs one git command outside simple-git, which rejects a handful of config
|
|
339
|
+
* keys and environment variables as injection vectors - merge.*.driver and
|
|
340
|
+
* GIT_EDITOR among them. Everything passed here is a fixed literal or a path
|
|
341
|
+
* derived from the install location, never agent or user input.
|
|
342
|
+
*/
|
|
343
|
+
gitDirect(args, env = {}) {
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
execFile("git", args, { cwd: this.dataDir, env: { ...process.env, ...env } }, (error, stdout, stderr) => resolve({ ok: !error, stdout: stdout.trim(), stderr: (stderr || error?.message || "").trim() }));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The refs holding each side during the operation in flight. A rebase replays
|
|
350
|
+
* our commits onto the remote, so HEAD is the *remote* there - the mapping
|
|
351
|
+
* that makes every side label absolute.
|
|
352
|
+
*/
|
|
353
|
+
sideRefs(operation) {
|
|
354
|
+
return operation === "rebase"
|
|
355
|
+
? { local: "REBASE_HEAD", remote: "HEAD" }
|
|
356
|
+
: { local: "HEAD", remote: "MERGE_HEAD" };
|
|
357
|
+
}
|
|
358
|
+
async lastCommitFor(ref, filePath) {
|
|
359
|
+
const out = await this.git
|
|
360
|
+
.raw(["log", "-1", "--format=%an%x00%aI%x00%s", ref, "--", filePath])
|
|
361
|
+
.catch(() => "");
|
|
362
|
+
const [author, date, subject] = out.trim().split("\u0000");
|
|
363
|
+
return author ? { author, date: date ?? "", subject: subject ?? "" } : undefined;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Every conflicted file with its shape and its two authors - enough for the
|
|
367
|
+
* agent to decide what to look at without opening a single document.
|
|
368
|
+
*/
|
|
369
|
+
async listConflicts() {
|
|
202
370
|
this.requireConfig();
|
|
371
|
+
const operation = await this.operationInProgress();
|
|
372
|
+
const status = await this.git.status();
|
|
373
|
+
const refs = this.sideRefs(operation);
|
|
374
|
+
const localStage = operation === "rebase" ? 3 : 2;
|
|
375
|
+
const files = [];
|
|
376
|
+
for (const filePath of status.conflicted) {
|
|
377
|
+
const stages = await this.conflictStages(filePath);
|
|
378
|
+
let kind = "content";
|
|
379
|
+
let deletedOn;
|
|
380
|
+
if (!stages.has(1)) {
|
|
381
|
+
kind = "add-add";
|
|
382
|
+
}
|
|
383
|
+
else if (!stages.has(2) || !stages.has(3)) {
|
|
384
|
+
kind = "delete-modify";
|
|
385
|
+
const missing = stages.has(2) ? 3 : 2;
|
|
386
|
+
deletedOn = missing === localStage ? "local" : "remote";
|
|
387
|
+
}
|
|
388
|
+
files.push({
|
|
389
|
+
path: filePath,
|
|
390
|
+
kind,
|
|
391
|
+
...(deletedOn ? { deletedOn } : {}),
|
|
392
|
+
local: await this.lastCommitFor(refs.local, filePath),
|
|
393
|
+
remote: await this.lastCommitFor(refs.remote, filePath),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return { operation, files };
|
|
397
|
+
}
|
|
398
|
+
/** Reads a conflicted file from the working tree, refusing to escape the data dir. */
|
|
399
|
+
readWorkingFile(relativePath) {
|
|
400
|
+
const absolute = path.resolve(this.dataDir, relativePath);
|
|
401
|
+
if (!absolute.startsWith(path.resolve(this.dataDir))) {
|
|
402
|
+
throw new Error(`Path "${relativePath}" is outside the documentation data directory.`);
|
|
403
|
+
}
|
|
404
|
+
if (!fs.existsSync(absolute))
|
|
405
|
+
throw new Error(`File "${relativePath}" does not exist.`);
|
|
406
|
+
return fs.readFileSync(absolute, "utf-8");
|
|
407
|
+
}
|
|
408
|
+
/** Writes a resolved file back and stages it when nothing is left conflicted in it. */
|
|
409
|
+
async writeResolvedFile(relativePath, content, stage) {
|
|
410
|
+
const absolute = path.resolve(this.dataDir, relativePath);
|
|
411
|
+
if (!absolute.startsWith(path.resolve(this.dataDir))) {
|
|
412
|
+
throw new Error(`Path "${relativePath}" is outside the documentation data directory.`);
|
|
413
|
+
}
|
|
414
|
+
fs.writeFileSync(absolute, content, "utf-8");
|
|
415
|
+
if (stage)
|
|
416
|
+
await this.git.add([relativePath]);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Bulk-resolve conflicts by keeping one side, then stage the result.
|
|
420
|
+
*
|
|
421
|
+
* The git flag depends on the operation in flight: a merge replays the remote
|
|
422
|
+
* onto our branch (--ours = local), while a rebase replays our commits onto
|
|
423
|
+
* the remote (--ours = remote). Picking the flag from the strategy alone would
|
|
424
|
+
* silently keep the wrong side under rebase.
|
|
425
|
+
*/
|
|
426
|
+
async resolveConflict(side, paths) {
|
|
427
|
+
this.requireConfig();
|
|
428
|
+
const operation = await this.operationInProgress();
|
|
203
429
|
const status = await this.git.status();
|
|
204
430
|
const targets = paths && paths.length > 0 ? paths : status.conflicted;
|
|
205
431
|
if (targets.length === 0)
|
|
206
|
-
return [];
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
432
|
+
return { resolved: [], operation };
|
|
433
|
+
const localFlag = operation === "rebase" ? "--theirs" : "--ours";
|
|
434
|
+
const remoteFlag = operation === "rebase" ? "--ours" : "--theirs";
|
|
435
|
+
const sideFlag = side === "local" ? localFlag : remoteFlag;
|
|
436
|
+
const wantedStage = sideFlag === "--ours" ? 2 : 3;
|
|
437
|
+
const resolved = [];
|
|
438
|
+
for (const filePath of targets) {
|
|
439
|
+
const stages = await this.conflictStages(filePath);
|
|
440
|
+
if (stages.size === 0)
|
|
441
|
+
continue;
|
|
442
|
+
if (stages.has(wantedStage)) {
|
|
443
|
+
await this.git.raw(["checkout", sideFlag, "--", filePath]);
|
|
444
|
+
await this.git.add([filePath]);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// delete/modify conflict: the side we are keeping deleted the file, so
|
|
448
|
+
// there is nothing to check out - record the deletion instead.
|
|
449
|
+
await this.git.raw(["rm", "-f", "--", filePath]);
|
|
450
|
+
}
|
|
451
|
+
resolved.push(filePath);
|
|
212
452
|
}
|
|
213
|
-
return
|
|
453
|
+
return { resolved, operation };
|
|
214
454
|
}
|
|
215
455
|
}
|
|
@@ -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;
|
|
@@ -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;
|