@ferrule-io/ok-fine 0.0.0-stage → 0.3.7
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/LICENSE +202 -0
- package/README.md +93 -2
- package/dist/auth/discovery.js +67 -0
- package/dist/auth/http-auth.js +50 -0
- package/dist/auth/verifier.js +176 -0
- package/dist/cli-options.js +99 -0
- package/dist/cli.js +115 -0
- package/dist/config.js +320 -0
- package/dist/data-dir-lock.js +60 -0
- package/dist/errors.js +13 -0
- package/dist/http/mcp-route.js +32 -0
- package/dist/http/rest.js +243 -0
- package/dist/http/ui.js +118 -0
- package/dist/main.js +32 -0
- package/dist/mcp/server.js +203 -0
- package/dist/okf/concept.js +99 -0
- package/dist/okf/frontmatter.js +180 -0
- package/dist/okf/index-file.js +74 -0
- package/dist/okf/lint.js +480 -0
- package/dist/okf/log-file.js +74 -0
- package/dist/okf/markdown.js +106 -0
- package/dist/okf/paths.js +99 -0
- package/dist/okf/repository.js +53 -0
- package/dist/okf/semantics.js +121 -0
- package/dist/server.js +343 -0
- package/dist/service/feedback.js +40 -0
- package/dist/service/knowledge-service.js +819 -0
- package/dist/service/principal.js +22 -0
- package/dist/store/archive.js +108 -0
- package/dist/store/backend.js +4 -0
- package/dist/store/bundle.js +250 -0
- package/dist/store/catalog.js +295 -0
- package/dist/store/fs-util.js +120 -0
- package/dist/store/git-backend.js +713 -0
- package/dist/store/git.js +274 -0
- package/dist/store/mutex.js +19 -0
- package/dist/store/path-index.js +54 -0
- package/dist/ui/assets/bash-BiGZg9Om.js +1 -0
- package/dist/ui/assets/css-BGIxQoYs.js +1 -0
- package/dist/ui/assets/css-DUjzNfgv.js +1 -0
- package/dist/ui/assets/diff-woXpYk--.js +1 -0
- package/dist/ui/assets/dockerfile-IyjqRm3v.js +1 -0
- package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/dist/ui/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/dist/ui/assets/github-light-default-BXViO-2h.js +1 -0
- package/dist/ui/assets/go-rLFTqkRN.js +1 -0
- package/dist/ui/assets/highlight-CW19vWvw.js +153 -0
- package/dist/ui/assets/html-BMn1ogTm.js +1 -0
- package/dist/ui/assets/html-BlpXnbzK.js +1 -0
- package/dist/ui/assets/index-0b2NNRC5.js +127 -0
- package/dist/ui/assets/index-CFuTAZQM.css +1 -0
- package/dist/ui/assets/java-BtIQNnN0.js +1 -0
- package/dist/ui/assets/java-DxlWrXwe.js +1 -0
- package/dist/ui/assets/javascript-BzbQS41l.js +1 -0
- package/dist/ui/assets/javascript-Yelw-5ZN.js +1 -0
- package/dist/ui/assets/json-qhed-kSA.js +1 -0
- package/dist/ui/assets/jsx-D3KKoC5L.js +1 -0
- package/dist/ui/assets/jsx-hEbJ82HF.js +1 -0
- package/dist/ui/assets/markdown-BYOwaDjH.js +1 -0
- package/dist/ui/assets/python-gzcpVVnB.js +1 -0
- package/dist/ui/assets/ruby-Di5fYh6S.js +1 -0
- package/dist/ui/assets/rust-DpiIf5JO.js +1 -0
- package/dist/ui/assets/shellscript-CqHdAlCp.js +1 -0
- package/dist/ui/assets/sql-BOsskAmu.js +1 -0
- package/dist/ui/assets/sql-CNu3Or2W.js +1 -0
- package/dist/ui/assets/toml-CcmNWLt0.js +1 -0
- package/dist/ui/assets/tsx-5f3ulCja.js +1 -0
- package/dist/ui/assets/tsx-BOvsFOI6.js +1 -0
- package/dist/ui/assets/typescript-Utq2Cl8c.js +1 -0
- package/dist/ui/assets/typescript-j_1H8WHN.js +1 -0
- package/dist/ui/assets/yaml-CxbubiML.js +1 -0
- package/dist/ui/assets/yaml-DijMHRDA.js +1 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +16 -0
- package/dist/ui/theme-init.js +10 -0
- package/dist/version.js +2 -0
- package/package.json +77 -4
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, rm, rmdir } from "node:fs/promises";
|
|
2
|
+
import { join, posix } from "node:path";
|
|
3
|
+
import { OkfError } from "../errors.js";
|
|
4
|
+
import { nowIso } from "../okf/frontmatter.js";
|
|
5
|
+
import { PROJECT_RE } from "../okf/paths.js";
|
|
6
|
+
import { parseActor } from "../okf/semantics.js";
|
|
7
|
+
import { assertContainedPath, atomicWrite, listTreeFiles, pathExists } from "./fs-util.js";
|
|
8
|
+
import { Git, isPushRejection, redactRemote } from "./git.js";
|
|
9
|
+
import { Mutex } from "./mutex.js";
|
|
10
|
+
import { PathIndex } from "./path-index.js";
|
|
11
|
+
const REMOTE_BASE_REF = "refs/ok-fine/remote-base";
|
|
12
|
+
const REMOTE_REWRITTEN_MSG = "remote history was rewritten; syncing halted. Stop ok-fine, remove DATA_DIR/repo, and restart to re-clone";
|
|
13
|
+
function extractChangedProjects(diffOutput) {
|
|
14
|
+
const lines = diffOutput
|
|
15
|
+
.split(/\r?\n/)
|
|
16
|
+
.map((l) => l.trim())
|
|
17
|
+
.filter((l) => l.length > 0);
|
|
18
|
+
const projects = new Set();
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const firstSegment = line.split("/")[0];
|
|
21
|
+
if (firstSegment && PROJECT_RE.test(firstSegment)) {
|
|
22
|
+
projects.add(firstSegment);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return Array.from(projects);
|
|
26
|
+
}
|
|
27
|
+
/** Mutations against the working tree; keeps the path index in step with disk. */
|
|
28
|
+
class GitTx {
|
|
29
|
+
repoDir;
|
|
30
|
+
index;
|
|
31
|
+
touched = false;
|
|
32
|
+
constructor(repoDir, index) {
|
|
33
|
+
this.repoDir = repoDir;
|
|
34
|
+
this.index = index;
|
|
35
|
+
}
|
|
36
|
+
async writeFile(project, path, content) {
|
|
37
|
+
await assertContainedPath(this.repoDir, project, path);
|
|
38
|
+
await atomicWrite(join(this.repoDir, project, path), content);
|
|
39
|
+
this.index.add(project, path);
|
|
40
|
+
this.touched = true;
|
|
41
|
+
}
|
|
42
|
+
async deleteFile(project, path) {
|
|
43
|
+
await assertContainedPath(this.repoDir, project, path);
|
|
44
|
+
const projectDir = join(this.repoDir, project);
|
|
45
|
+
await rm(join(projectDir, path), { force: true });
|
|
46
|
+
let dir = posix.dirname(path);
|
|
47
|
+
while (dir !== "." && dir !== "") {
|
|
48
|
+
const abs = join(projectDir, dir);
|
|
49
|
+
try {
|
|
50
|
+
if ((await readdir(abs)).length > 0) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
await rmdir(abs);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
dir = posix.dirname(dir);
|
|
59
|
+
}
|
|
60
|
+
this.index.remove(project, path);
|
|
61
|
+
this.touched = true;
|
|
62
|
+
}
|
|
63
|
+
async deleteProject(project) {
|
|
64
|
+
const projectDir = join(this.repoDir, project);
|
|
65
|
+
try {
|
|
66
|
+
const s = await lstat(projectDir);
|
|
67
|
+
if (s.isSymbolicLink()) {
|
|
68
|
+
await rm(projectDir, { force: true });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err.code !== "ENOENT") {
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.index.setProject(project, []);
|
|
80
|
+
this.touched = true;
|
|
81
|
+
}
|
|
82
|
+
async replaceProject(project, source) {
|
|
83
|
+
const projectDir = join(this.repoDir, project);
|
|
84
|
+
try {
|
|
85
|
+
const s = await lstat(projectDir);
|
|
86
|
+
if (s.isSymbolicLink()) {
|
|
87
|
+
await rm(projectDir, { force: true });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== "ENOENT") {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const written = [];
|
|
99
|
+
for (const path of source.paths) {
|
|
100
|
+
await assertContainedPath(this.repoDir, project, path);
|
|
101
|
+
const content = await source.read(path);
|
|
102
|
+
if (content !== null) {
|
|
103
|
+
await atomicWrite(join(projectDir, path), content);
|
|
104
|
+
written.push(path);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.index.setProject(project, written);
|
|
108
|
+
this.touched = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class GitBackend {
|
|
112
|
+
config;
|
|
113
|
+
log;
|
|
114
|
+
repoDir;
|
|
115
|
+
homeDir;
|
|
116
|
+
git;
|
|
117
|
+
mutex = new Mutex();
|
|
118
|
+
index = new PathIndex();
|
|
119
|
+
resyncHandler = null;
|
|
120
|
+
lastSyncAt = null;
|
|
121
|
+
lastError = null;
|
|
122
|
+
constructor(config, log, repoDir, homeDir) {
|
|
123
|
+
this.config = config;
|
|
124
|
+
this.log = log;
|
|
125
|
+
this.repoDir = repoDir;
|
|
126
|
+
this.homeDir = homeDir;
|
|
127
|
+
this.git = new Git(repoDir, homeDir, config);
|
|
128
|
+
}
|
|
129
|
+
get hasRemote() {
|
|
130
|
+
return Boolean(this.config.gitRemoteUrl && this.config.gitRemoteUrl.trim().length > 0);
|
|
131
|
+
}
|
|
132
|
+
setResyncHandler(handler) {
|
|
133
|
+
this.resyncHandler = handler;
|
|
134
|
+
}
|
|
135
|
+
async close() {
|
|
136
|
+
await this.mutex.idle();
|
|
137
|
+
await this.git.close();
|
|
138
|
+
}
|
|
139
|
+
async projects() {
|
|
140
|
+
return this.index.projects();
|
|
141
|
+
}
|
|
142
|
+
async tree(project) {
|
|
143
|
+
return this.index.tree(project);
|
|
144
|
+
}
|
|
145
|
+
async readFile(project, path) {
|
|
146
|
+
if (!this.index.tree(project).hasFile(path)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await assertContainedPath(this.repoDir, project, path);
|
|
151
|
+
const fullPath = join(this.repoDir, project, path);
|
|
152
|
+
const s = await lstat(fullPath);
|
|
153
|
+
if (s.isSymbolicLink() || !s.isFile()) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return await readFile(fullPath);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async reindexProject(project) {
|
|
163
|
+
const dir = join(this.repoDir, project);
|
|
164
|
+
let paths = [];
|
|
165
|
+
try {
|
|
166
|
+
const s = await lstat(dir);
|
|
167
|
+
if (!s.isSymbolicLink() && s.isDirectory()) {
|
|
168
|
+
paths = await listTreeFiles(dir);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
paths = [];
|
|
173
|
+
}
|
|
174
|
+
this.index.setProject(project, paths);
|
|
175
|
+
}
|
|
176
|
+
async reindexAll() {
|
|
177
|
+
this.index.clear();
|
|
178
|
+
const entries = await readdir(this.repoDir, { withFileTypes: true });
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (entry.name.startsWith(".") || !entry.isDirectory()) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (PROJECT_RE.test(entry.name)) {
|
|
184
|
+
await this.reindexProject(entry.name);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.log.warn(`Ignoring non-matching directory "${entry.name}" in repository root`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Reindexes projects whose stored content changed underneath the service and lets the handler catch up. */
|
|
192
|
+
async resync(projects) {
|
|
193
|
+
for (const project of new Set(projects)) {
|
|
194
|
+
await this.reindexProject(project);
|
|
195
|
+
if (!this.resyncHandler) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const tx = new GitTx(this.repoDir, this.index);
|
|
199
|
+
await this.resyncHandler(project, tx);
|
|
200
|
+
if (tx.touched) {
|
|
201
|
+
await this.commitInternal(`okf(${project}): regenerate indexes`, "process:ok-fine", { subject: "system", clientId: "ok-fine" }, [project]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
static async open(config, log) {
|
|
206
|
+
const repoDir = join(config.dataDir, "repo");
|
|
207
|
+
const homeDir = join(config.dataDir, "home");
|
|
208
|
+
await mkdir(repoDir, { recursive: true });
|
|
209
|
+
await mkdir(homeDir, { recursive: true });
|
|
210
|
+
const repo = new GitBackend(config, log, repoDir, homeDir);
|
|
211
|
+
await repo.git.prepareSsh();
|
|
212
|
+
// Check if git initialized
|
|
213
|
+
const gitDirExists = await pathExists(join(repoDir, ".git"));
|
|
214
|
+
if (!gitDirExists) {
|
|
215
|
+
await repo.git.run(["init", "-b", config.gitBranch]);
|
|
216
|
+
}
|
|
217
|
+
const headRev = await repo.git.run(["rev-parse", "--verify", "HEAD"], { allowFail: true });
|
|
218
|
+
let headHasCommits = headRev.code === 0;
|
|
219
|
+
if (repo.hasRemote && config.gitRemoteUrl) {
|
|
220
|
+
const remotes = await repo.git.run(["remote"], { allowFail: true });
|
|
221
|
+
const remoteList = remotes.stdout.split(/\r?\n/).map((r) => r.trim());
|
|
222
|
+
if (remoteList.includes("origin")) {
|
|
223
|
+
await repo.git.run(["remote", "set-url", "origin", config.gitRemoteUrl]);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
await repo.git.run(["remote", "add", "origin", config.gitRemoteUrl]);
|
|
227
|
+
}
|
|
228
|
+
const fetchRes = await repo.git.run(["fetch", "origin", config.gitBranch], { allowFail: true });
|
|
229
|
+
if (fetchRes.code !== 0) {
|
|
230
|
+
const isRefNotFound = fetchRes.stderr.includes("couldn't find remote ref");
|
|
231
|
+
if (!isRefNotFound) {
|
|
232
|
+
if (!headHasCommits) {
|
|
233
|
+
throw new Error(`Failed to fetch from remote on empty local repository:\n${fetchRes.stderr}`);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
log.warn({ stderr: fetchRes.stderr }, "Fetch failed on startup; continuing with local commits");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const remoteBranchRev = await repo.git.run(["rev-parse", "--verify", `origin/${config.gitBranch}`], {
|
|
241
|
+
allowFail: true,
|
|
242
|
+
});
|
|
243
|
+
const remoteBranchExists = remoteBranchRev.code === 0;
|
|
244
|
+
if (!headHasCommits) {
|
|
245
|
+
if (remoteBranchExists) {
|
|
246
|
+
await repo.git.run(["checkout", "-B", config.gitBranch, `origin/${config.gitBranch}`]);
|
|
247
|
+
headHasCommits = true;
|
|
248
|
+
await repo.setRememberedRemoteTip(remoteBranchRev.stdout.trim());
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Initialize empty bundle root files
|
|
252
|
+
await repo.ensureAttributesAndIgnore();
|
|
253
|
+
await repo.git.run([
|
|
254
|
+
"commit",
|
|
255
|
+
"--author=process:ok-fine <ok-fine@localhost>",
|
|
256
|
+
"-m",
|
|
257
|
+
"okf: initialize knowledge repository",
|
|
258
|
+
]);
|
|
259
|
+
await repo.git.run(["push", "-u", "origin", `${config.gitBranch}:${config.gitBranch}`], {
|
|
260
|
+
allowFail: true,
|
|
261
|
+
});
|
|
262
|
+
headHasCommits = true;
|
|
263
|
+
const initialHead = (await repo.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
264
|
+
await repo.setRememberedRemoteTip(initialHead);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
await repo.sync();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
if (!headHasCommits) {
|
|
273
|
+
await repo.ensureAttributesAndIgnore();
|
|
274
|
+
await repo.git.run([
|
|
275
|
+
"commit",
|
|
276
|
+
"--author=process:ok-fine <ok-fine@localhost>",
|
|
277
|
+
"-m",
|
|
278
|
+
"okf: initialize knowledge repository",
|
|
279
|
+
]);
|
|
280
|
+
headHasCommits = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Ensure .gitattributes and .gitignore exist with required lines
|
|
284
|
+
const attrChanged = await repo.ensureAttributesAndIgnore();
|
|
285
|
+
if (attrChanged) {
|
|
286
|
+
await repo.git.run(["add", ".gitattributes", ".gitignore"]);
|
|
287
|
+
const diffCheck = await repo.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
|
|
288
|
+
if (diffCheck.code !== 0) {
|
|
289
|
+
await repo.git.run([
|
|
290
|
+
"commit",
|
|
291
|
+
"--author=process:ok-fine <ok-fine@localhost>",
|
|
292
|
+
"-m",
|
|
293
|
+
"okf: configure merge and ignore attributes",
|
|
294
|
+
]);
|
|
295
|
+
if (repo.hasRemote) {
|
|
296
|
+
const pushRes = await repo.git.run(["push", "origin", `HEAD:refs/heads/${config.gitBranch}`], {
|
|
297
|
+
allowFail: true,
|
|
298
|
+
});
|
|
299
|
+
if (pushRes.code === 0) {
|
|
300
|
+
const headSha = (await repo.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
301
|
+
await repo.setRememberedRemoteTip(headSha);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
await repo.reindexAll();
|
|
307
|
+
return repo;
|
|
308
|
+
}
|
|
309
|
+
async getRememberedRemoteTip() {
|
|
310
|
+
const res = await this.git.run(["rev-parse", "--verify", REMOTE_BASE_REF], {
|
|
311
|
+
allowFail: true,
|
|
312
|
+
});
|
|
313
|
+
if (res.code === 0 && res.stdout.trim().length > 0) {
|
|
314
|
+
return res.stdout.trim();
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
async setRememberedRemoteTip(sha) {
|
|
319
|
+
await this.git.run(["update-ref", REMOTE_BASE_REF, sha], { allowFail: true });
|
|
320
|
+
}
|
|
321
|
+
async isRemoteAncestorHalted(remoteTip) {
|
|
322
|
+
const rememberedTip = await this.getRememberedRemoteTip();
|
|
323
|
+
if (!rememberedTip) {
|
|
324
|
+
await this.setRememberedRemoteTip(remoteTip);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
if (rememberedTip === remoteTip) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const ancestorCheck = await this.git.run(["merge-base", "--is-ancestor", rememberedTip, remoteTip], {
|
|
331
|
+
allowFail: true,
|
|
332
|
+
});
|
|
333
|
+
if (ancestorCheck.code !== 0) {
|
|
334
|
+
this.lastError = REMOTE_REWRITTEN_MSG;
|
|
335
|
+
this.log.error({ rememberedTip, remoteTip }, REMOTE_REWRITTEN_MSG);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
async ensureAttributesAndIgnore() {
|
|
341
|
+
let changed = false;
|
|
342
|
+
const gitattributesPath = join(this.repoDir, ".gitattributes");
|
|
343
|
+
let attrContent = "";
|
|
344
|
+
try {
|
|
345
|
+
const s = await lstat(gitattributesPath);
|
|
346
|
+
if (s.isSymbolicLink()) {
|
|
347
|
+
await rm(gitattributesPath, { force: true });
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
attrContent = await readFile(gitattributesPath, "utf8");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err.code !== "ENOENT") {
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const requiredAttrLines = ["**/index.md merge=union", "**/log.md merge=union"];
|
|
359
|
+
let newAttrContent = attrContent;
|
|
360
|
+
for (const line of requiredAttrLines) {
|
|
361
|
+
if (!newAttrContent.includes(line)) {
|
|
362
|
+
if (newAttrContent.length > 0 && !newAttrContent.endsWith("\n")) {
|
|
363
|
+
newAttrContent += "\n";
|
|
364
|
+
}
|
|
365
|
+
newAttrContent += `${line}\n`;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (newAttrContent !== attrContent) {
|
|
369
|
+
await atomicWrite(gitattributesPath, newAttrContent);
|
|
370
|
+
await this.git.run(["add", ".gitattributes"]);
|
|
371
|
+
changed = true;
|
|
372
|
+
}
|
|
373
|
+
const gitignorePath = join(this.repoDir, ".gitignore");
|
|
374
|
+
let ignoreContent = "";
|
|
375
|
+
try {
|
|
376
|
+
const s = await lstat(gitignorePath);
|
|
377
|
+
if (s.isSymbolicLink()) {
|
|
378
|
+
await rm(gitignorePath, { force: true });
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
ignoreContent = await readFile(gitignorePath, "utf8");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
if (err.code !== "ENOENT") {
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const requiredIgnoreLine = ".*.tmp";
|
|
390
|
+
let newIgnoreContent = ignoreContent;
|
|
391
|
+
if (!newIgnoreContent.includes(requiredIgnoreLine)) {
|
|
392
|
+
if (newIgnoreContent.length > 0 && !newIgnoreContent.endsWith("\n")) {
|
|
393
|
+
newIgnoreContent += "\n";
|
|
394
|
+
}
|
|
395
|
+
newIgnoreContent += `${requiredIgnoreLine}\n`;
|
|
396
|
+
}
|
|
397
|
+
if (newIgnoreContent !== ignoreContent) {
|
|
398
|
+
await atomicWrite(gitignorePath, newIgnoreContent);
|
|
399
|
+
await this.git.run(["add", ".gitignore"]);
|
|
400
|
+
changed = true;
|
|
401
|
+
}
|
|
402
|
+
return changed;
|
|
403
|
+
}
|
|
404
|
+
async commitInternal(subject, author, principal, projects) {
|
|
405
|
+
const addArgs = projects.length > 0 ? ["add", "-A", "--", ...projects] : ["add", "-A"];
|
|
406
|
+
await this.git.run(addArgs);
|
|
407
|
+
const diffCheck = await this.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
|
|
408
|
+
if (diffCheck.code === 0) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
await this.git.run([
|
|
412
|
+
"commit",
|
|
413
|
+
`--author=${author} <ok-fine@localhost>`,
|
|
414
|
+
"-m",
|
|
415
|
+
subject,
|
|
416
|
+
"-m",
|
|
417
|
+
`Okf-Principal: sub=${principal.subject} client=${principal.clientId}`,
|
|
418
|
+
]);
|
|
419
|
+
const rev = await this.git.run(["rev-parse", "HEAD"]);
|
|
420
|
+
return rev.stdout.trim();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Aborts a failed rebase, keeps the original local commits on `ok-fine/conflict-<stamp>` (always as a
|
|
424
|
+
* local branch, plus on the remote when the push succeeds), then resets to the remote branch.
|
|
425
|
+
* Returns a human-readable description of where the commits were preserved.
|
|
426
|
+
*/
|
|
427
|
+
async preserveConflict() {
|
|
428
|
+
await this.git.run(["rebase", "--abort"], { allowFail: true });
|
|
429
|
+
const stamp = new Date()
|
|
430
|
+
.toISOString()
|
|
431
|
+
.replace(/[-:]/g, "")
|
|
432
|
+
.replace(/\.\d{3}/, "")
|
|
433
|
+
.replace(/Z$/, "Z");
|
|
434
|
+
const conflictBranch = `ok-fine/conflict-${stamp}`;
|
|
435
|
+
// The local branch keeps the commits reachable on the PVC even if the remote push fails.
|
|
436
|
+
await this.git.run(["branch", "-f", conflictBranch, "HEAD"]);
|
|
437
|
+
const pushRes = await this.git.run(["push", "origin", `refs/heads/${conflictBranch}:refs/heads/${conflictBranch}`], { allowFail: true });
|
|
438
|
+
await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
|
|
439
|
+
let message = `local commits preserved on ${conflictBranch}`;
|
|
440
|
+
if (pushRes.code !== 0) {
|
|
441
|
+
const reason = pushRes.stderr.trim().split("\n")[0] ?? "";
|
|
442
|
+
message += ` (local branch only; pushing it failed: ${reason})`;
|
|
443
|
+
}
|
|
444
|
+
this.log.error({ stderr: pushRes.code !== 0 ? pushRes.stderr : undefined }, `rebase conflict; ${message}`);
|
|
445
|
+
return message;
|
|
446
|
+
}
|
|
447
|
+
async transaction(spec, work) {
|
|
448
|
+
return this.mutex.run(async () => {
|
|
449
|
+
const warnings = [];
|
|
450
|
+
let syncHalted = false;
|
|
451
|
+
// Step 1: Remote sync check before work
|
|
452
|
+
if (this.hasRemote) {
|
|
453
|
+
const fetchRes = await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
|
|
454
|
+
if (fetchRes.code !== 0) {
|
|
455
|
+
const firstLine = fetchRes.stderr.split(/\r?\n/)[0] ?? "unknown error";
|
|
456
|
+
warnings.push(`remote unreachable: ${firstLine}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const remoteRevRes = await this.git.run(["rev-parse", "--verify", `origin/${this.config.gitBranch}`], {
|
|
460
|
+
allowFail: true,
|
|
461
|
+
});
|
|
462
|
+
if (remoteRevRes.code === 0) {
|
|
463
|
+
const remoteTip = remoteRevRes.stdout.trim();
|
|
464
|
+
if (await this.isRemoteAncestorHalted(remoteTip)) {
|
|
465
|
+
syncHalted = true;
|
|
466
|
+
warnings.push(this.lastError ?? REMOTE_REWRITTEN_MSG);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!syncHalted) {
|
|
470
|
+
const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
|
|
471
|
+
allowFail: true,
|
|
472
|
+
});
|
|
473
|
+
const behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
|
|
474
|
+
if (behind > 0) {
|
|
475
|
+
const before = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
476
|
+
const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
|
|
477
|
+
if (rebaseRes.code !== 0) {
|
|
478
|
+
warnings.push(await this.preserveConflict());
|
|
479
|
+
}
|
|
480
|
+
const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
481
|
+
if (before !== after) {
|
|
482
|
+
const diffRes = await this.git.run(["diff", "--name-only", before, after]);
|
|
483
|
+
await this.resync(extractChangedProjects(diffRes.stdout));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Step 2 & 3: Run work and commit
|
|
490
|
+
try {
|
|
491
|
+
const workRes = await work(new GitTx(this.repoDir, this.index));
|
|
492
|
+
let commitSha = null;
|
|
493
|
+
if (workRes.commit) {
|
|
494
|
+
const addArgs = spec.projects.length > 0 ? ["add", "-A", "--", ...spec.projects] : ["add", "-A"];
|
|
495
|
+
await this.git.run(addArgs);
|
|
496
|
+
const diffCheck = await this.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
|
|
497
|
+
if (diffCheck.code !== 0) {
|
|
498
|
+
const c = workRes.commit;
|
|
499
|
+
// human:<email> writes carry the person's own email so bundle history matches their code commits.
|
|
500
|
+
const actor = parseActor(c.author);
|
|
501
|
+
const authorEmail = actor?.kind === "human" && actor.id.includes("@") ? actor.id : "ok-fine@localhost";
|
|
502
|
+
const commitArgs = ["commit", `--author=${c.author} <${authorEmail}>`, "-m", c.subject];
|
|
503
|
+
if (c.body && c.body.trim().length > 0) {
|
|
504
|
+
commitArgs.push("-m", c.body);
|
|
505
|
+
}
|
|
506
|
+
commitArgs.push("-m", `Okf-Principal: sub=${c.principal.subject} client=${c.principal.clientId}`);
|
|
507
|
+
await this.git.run(commitArgs);
|
|
508
|
+
commitSha = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Step 4: Push commit if made
|
|
512
|
+
let pushed = null;
|
|
513
|
+
if (commitSha !== null && this.hasRemote) {
|
|
514
|
+
if (syncHalted) {
|
|
515
|
+
pushed = false;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
let pushSuccess = false;
|
|
519
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
520
|
+
const pushRes = await this.git.run(["push", "origin", `HEAD:refs/heads/${this.config.gitBranch}`], {
|
|
521
|
+
allowFail: true,
|
|
522
|
+
});
|
|
523
|
+
if (pushRes.code === 0) {
|
|
524
|
+
pushSuccess = true;
|
|
525
|
+
pushed = true;
|
|
526
|
+
await this.setRememberedRemoteTip(commitSha);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
if (isPushRejection(pushRes.stderr)) {
|
|
530
|
+
const pre = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
531
|
+
await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
|
|
532
|
+
const remoteRevRes = await this.git.run(["rev-parse", "--verify", `origin/${this.config.gitBranch}`], {
|
|
533
|
+
allowFail: true,
|
|
534
|
+
});
|
|
535
|
+
if (remoteRevRes.code === 0) {
|
|
536
|
+
const remoteTip = remoteRevRes.stdout.trim();
|
|
537
|
+
if (await this.isRemoteAncestorHalted(remoteTip)) {
|
|
538
|
+
pushed = false;
|
|
539
|
+
warnings.push(this.lastError ?? REMOTE_REWRITTEN_MSG);
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], {
|
|
544
|
+
allowFail: true,
|
|
545
|
+
});
|
|
546
|
+
if (rebaseRes.code === 0) {
|
|
547
|
+
const post = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
548
|
+
const diffRes = await this.git.run(["diff", "--name-only", pre, post]);
|
|
549
|
+
await this.resync(extractChangedProjects(diffRes.stdout));
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
await this.git.run(["rebase", "--abort"], { allowFail: true });
|
|
553
|
+
await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
|
|
554
|
+
await this.resync(spec.projects);
|
|
555
|
+
throw new OkfError("upstream_conflict", 409, "change conflicts with a concurrent upstream edit; re-read and retry");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
pushed = false;
|
|
560
|
+
const firstLine = pushRes.stderr.split(/\r?\n/)[0] ?? "unknown error";
|
|
561
|
+
warnings.push(`push failed; will retry on next sync: ${firstLine}`);
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!pushSuccess && pushed === null) {
|
|
566
|
+
pushed = false;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
value: workRes.value,
|
|
572
|
+
commit: commitSha,
|
|
573
|
+
pushed,
|
|
574
|
+
warnings,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
await this.git.run(["reset", "--hard", "HEAD"], { allowFail: true });
|
|
579
|
+
const cleanArgs = spec.projects.length > 0 ? ["clean", "-fd", "--", ...spec.projects] : ["clean", "-fd"];
|
|
580
|
+
await this.git.run(cleanArgs, { allowFail: true });
|
|
581
|
+
await this.resync(spec.projects);
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
async sync() {
|
|
587
|
+
return this.mutex.run(async () => {
|
|
588
|
+
if (!this.hasRemote) {
|
|
589
|
+
return this.syncStatus();
|
|
590
|
+
}
|
|
591
|
+
const before = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
592
|
+
const fetchRes = await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
|
|
593
|
+
if (fetchRes.code !== 0) {
|
|
594
|
+
this.lastError = fetchRes.stderr.split(/\r?\n/)[0] ?? "fetch failed";
|
|
595
|
+
return this.syncStatus();
|
|
596
|
+
}
|
|
597
|
+
const remoteRevRes = await this.git.run(["rev-parse", "--verify", `origin/${this.config.gitBranch}`], {
|
|
598
|
+
allowFail: true,
|
|
599
|
+
});
|
|
600
|
+
if (remoteRevRes.code !== 0) {
|
|
601
|
+
this.lastError = "remote ref not found";
|
|
602
|
+
return this.syncStatus();
|
|
603
|
+
}
|
|
604
|
+
const remoteTip = remoteRevRes.stdout.trim();
|
|
605
|
+
if (await this.isRemoteAncestorHalted(remoteTip)) {
|
|
606
|
+
return this.syncStatus();
|
|
607
|
+
}
|
|
608
|
+
const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
|
|
609
|
+
allowFail: true,
|
|
610
|
+
});
|
|
611
|
+
const behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
|
|
612
|
+
let runError = null;
|
|
613
|
+
if (behind > 0) {
|
|
614
|
+
const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
|
|
615
|
+
if (rebaseRes.code !== 0) {
|
|
616
|
+
runError = `rebase conflict; ${await this.preserveConflict()}`;
|
|
617
|
+
}
|
|
618
|
+
const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
619
|
+
if (before !== after) {
|
|
620
|
+
const diffRes = await this.git.run(["diff", "--name-only", before, after]);
|
|
621
|
+
await this.resync(extractChangedProjects(diffRes.stdout));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const aheadRes = await this.git.run(["rev-list", "--count", `origin/${this.config.gitBranch}..HEAD`], {
|
|
625
|
+
allowFail: true,
|
|
626
|
+
});
|
|
627
|
+
const ahead = Number.parseInt(aheadRes.stdout.trim(), 10) || 0;
|
|
628
|
+
if (ahead > 0) {
|
|
629
|
+
const pushRes = await this.git.run(["push", "origin", `HEAD:refs/heads/${this.config.gitBranch}`], {
|
|
630
|
+
allowFail: true,
|
|
631
|
+
});
|
|
632
|
+
if (pushRes.code !== 0) {
|
|
633
|
+
const pushError = `push failed: ${pushRes.stderr.split(/\r?\n/)[0] ?? "unknown error"}`;
|
|
634
|
+
runError = runError ? `${runError}; ${pushError}` : pushError;
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const headSha = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
|
|
638
|
+
await this.setRememberedRemoteTip(headSha);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
await this.setRememberedRemoteTip(remoteTip);
|
|
643
|
+
}
|
|
644
|
+
// lastError describes the most recent sync run only.
|
|
645
|
+
this.lastError = runError;
|
|
646
|
+
this.lastSyncAt = nowIso();
|
|
647
|
+
return this.syncStatus();
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async syncStatus() {
|
|
651
|
+
const remote = this.config.gitRemoteUrl ? redactRemote(this.config.gitRemoteUrl) : null;
|
|
652
|
+
let ahead = 0;
|
|
653
|
+
let behind = 0;
|
|
654
|
+
if (this.hasRemote) {
|
|
655
|
+
const aheadRes = await this.git.run(["rev-list", "--count", `origin/${this.config.gitBranch}..HEAD`], {
|
|
656
|
+
allowFail: true,
|
|
657
|
+
});
|
|
658
|
+
if (aheadRes.code === 0) {
|
|
659
|
+
ahead = Number.parseInt(aheadRes.stdout.trim(), 10) || 0;
|
|
660
|
+
}
|
|
661
|
+
const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
|
|
662
|
+
allowFail: true,
|
|
663
|
+
});
|
|
664
|
+
if (behindRes.code === 0) {
|
|
665
|
+
behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
remote,
|
|
670
|
+
branch: this.config.gitBranch,
|
|
671
|
+
lastSyncAt: this.lastSyncAt,
|
|
672
|
+
lastError: this.lastError,
|
|
673
|
+
ahead,
|
|
674
|
+
behind,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
async history(project, path, limit) {
|
|
678
|
+
const clampedLimit = Math.max(1, Math.min(100, limit));
|
|
679
|
+
const args = [
|
|
680
|
+
"log",
|
|
681
|
+
"-n",
|
|
682
|
+
String(clampedLimit),
|
|
683
|
+
"--format=%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Okf-Principal,valueonly)%x1e",
|
|
684
|
+
];
|
|
685
|
+
args.push("--", path === null ? `${project}/` : `${project}/${path}`);
|
|
686
|
+
const res = await this.git.run(args, { allowFail: true });
|
|
687
|
+
if (res.code !== 0) {
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
const entries = res.stdout.split("\x1e").filter((e) => e.trim().length > 0);
|
|
691
|
+
const commits = [];
|
|
692
|
+
for (const entry of entries) {
|
|
693
|
+
const parts = entry.trim().split("\x1f");
|
|
694
|
+
const sha = parts[0] ?? "";
|
|
695
|
+
const at = parts[1] ?? "";
|
|
696
|
+
const actor = parts[2] ?? "";
|
|
697
|
+
const subject = parts[3] ?? "";
|
|
698
|
+
const principalRaw = parts[4]?.trim();
|
|
699
|
+
commits.push({
|
|
700
|
+
sha,
|
|
701
|
+
at,
|
|
702
|
+
actor,
|
|
703
|
+
subject,
|
|
704
|
+
principal: principalRaw && principalRaw.length > 0 ? principalRaw : null,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
return commits;
|
|
708
|
+
}
|
|
709
|
+
archive(project) {
|
|
710
|
+
return this.git.spawnStdout(["archive", "--format=tar.gz", "HEAD", "--", project]);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=git-backend.js.map
|