@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
|
@@ -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.
|
|
@@ -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
|
-
|
|
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
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
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 →
|
|
155
|
-
//
|
|
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.
|
|
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("
|
|
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
|
|
166
|
-
return { status: "updated", changed: await this.diffNames(before,
|
|
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
|
-
//
|
|
178
|
-
// so
|
|
179
|
-
await this.
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
/**
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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;
|