@ferrule-io/ok-fine 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,65 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { access, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ export async function atomicWrite(absPath, data) {
5
+ const dir = dirname(absPath);
6
+ await mkdir(dir, { recursive: true });
7
+ const tmpName = join(dir, `.${basename(absPath)}.${randomBytes(4).toString("hex")}.tmp`);
8
+ await writeFile(tmpName, data);
9
+ await rename(tmpName, absPath);
10
+ }
11
+ export async function pathExists(absPath) {
12
+ try {
13
+ await access(absPath);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export async function isDirectory(absPath) {
21
+ try {
22
+ const s = await stat(absPath);
23
+ return s.isDirectory();
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * Lists regular files below `rootDir` as sorted POSIX relative paths. Skips dot-entries; symlinks and other
31
+ * non-regular entries are excluded and never followed. A missing root yields [].
32
+ */
33
+ export async function listTreeFiles(rootDir) {
34
+ const files = [];
35
+ async function walk(rel) {
36
+ let entries;
37
+ try {
38
+ entries = await readdir(rel === "" ? rootDir : join(rootDir, rel), { withFileTypes: true });
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ if (entry.name.startsWith(".")) {
45
+ continue;
46
+ }
47
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
48
+ if (entry.isDirectory()) {
49
+ await walk(childRel);
50
+ }
51
+ else if (entry.isFile()) {
52
+ files.push(childRel);
53
+ }
54
+ }
55
+ }
56
+ await walk("");
57
+ return files.sort();
58
+ }
59
+ export async function fsBundleSource(rootDir) {
60
+ return {
61
+ paths: await listTreeFiles(rootDir),
62
+ read: (path) => readFile(join(rootDir, path)).catch(() => null),
63
+ };
64
+ }
65
+ //# sourceMappingURL=fs-util.js.map
@@ -0,0 +1,564 @@
1
+ import { mkdir, readdir, readFile, rm, rmdir, writeFile } 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 { atomicWrite, isDirectory, 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
+ function extractChangedProjects(diffOutput) {
12
+ const lines = diffOutput
13
+ .split(/\r?\n/)
14
+ .map((l) => l.trim())
15
+ .filter((l) => l.length > 0);
16
+ const projects = new Set();
17
+ for (const line of lines) {
18
+ const firstSegment = line.split("/")[0];
19
+ if (firstSegment && PROJECT_RE.test(firstSegment)) {
20
+ projects.add(firstSegment);
21
+ }
22
+ }
23
+ return Array.from(projects);
24
+ }
25
+ /** Mutations against the working tree; keeps the path index in step with disk. */
26
+ class GitTx {
27
+ repoDir;
28
+ index;
29
+ touched = false;
30
+ constructor(repoDir, index) {
31
+ this.repoDir = repoDir;
32
+ this.index = index;
33
+ }
34
+ async writeFile(project, path, content) {
35
+ await atomicWrite(join(this.repoDir, project, path), content);
36
+ this.index.add(project, path);
37
+ this.touched = true;
38
+ }
39
+ async deleteFile(project, path) {
40
+ const projectDir = join(this.repoDir, project);
41
+ await rm(join(projectDir, path), { force: true });
42
+ let dir = posix.dirname(path);
43
+ while (dir !== "." && dir !== "") {
44
+ const abs = join(projectDir, dir);
45
+ try {
46
+ if ((await readdir(abs)).length > 0) {
47
+ break;
48
+ }
49
+ await rmdir(abs);
50
+ }
51
+ catch {
52
+ break;
53
+ }
54
+ dir = posix.dirname(dir);
55
+ }
56
+ this.index.remove(project, path);
57
+ this.touched = true;
58
+ }
59
+ async deleteProject(project) {
60
+ await rm(join(this.repoDir, project), { recursive: true, force: true });
61
+ this.index.setProject(project, []);
62
+ this.touched = true;
63
+ }
64
+ async replaceProject(project, source) {
65
+ const projectDir = join(this.repoDir, project);
66
+ await rm(projectDir, { recursive: true, force: true });
67
+ const written = [];
68
+ for (const path of source.paths) {
69
+ const content = await source.read(path);
70
+ if (content !== null) {
71
+ await atomicWrite(join(projectDir, path), content);
72
+ written.push(path);
73
+ }
74
+ }
75
+ this.index.setProject(project, written);
76
+ this.touched = true;
77
+ }
78
+ }
79
+ export class GitBackend {
80
+ config;
81
+ log;
82
+ repoDir;
83
+ homeDir;
84
+ git;
85
+ mutex = new Mutex();
86
+ index = new PathIndex();
87
+ resyncHandler = null;
88
+ lastSyncAt = null;
89
+ lastError = null;
90
+ constructor(config, log, repoDir, homeDir) {
91
+ this.config = config;
92
+ this.log = log;
93
+ this.repoDir = repoDir;
94
+ this.homeDir = homeDir;
95
+ this.git = new Git(repoDir, homeDir, config);
96
+ }
97
+ get hasRemote() {
98
+ return Boolean(this.config.gitRemoteUrl && this.config.gitRemoteUrl.trim().length > 0);
99
+ }
100
+ setResyncHandler(handler) {
101
+ this.resyncHandler = handler;
102
+ }
103
+ async close() {
104
+ await this.mutex.idle();
105
+ }
106
+ async projects() {
107
+ return this.index.projects();
108
+ }
109
+ async tree(project) {
110
+ return this.index.tree(project);
111
+ }
112
+ async readFile(project, path) {
113
+ if (!this.index.tree(project).hasFile(path)) {
114
+ return null;
115
+ }
116
+ try {
117
+ return await readFile(join(this.repoDir, project, path));
118
+ }
119
+ catch (err) {
120
+ if (err.code === "ENOENT") {
121
+ return null;
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+ async reindexProject(project) {
127
+ const dir = join(this.repoDir, project);
128
+ const paths = (await isDirectory(dir)) ? await listTreeFiles(dir) : [];
129
+ this.index.setProject(project, paths);
130
+ }
131
+ async reindexAll() {
132
+ this.index.clear();
133
+ const entries = await readdir(this.repoDir, { withFileTypes: true });
134
+ for (const entry of entries) {
135
+ if (entry.name.startsWith(".") || !entry.isDirectory()) {
136
+ continue;
137
+ }
138
+ if (PROJECT_RE.test(entry.name)) {
139
+ await this.reindexProject(entry.name);
140
+ }
141
+ else {
142
+ this.log.warn(`Ignoring non-matching directory "${entry.name}" in repository root`);
143
+ }
144
+ }
145
+ }
146
+ /** Reindexes projects whose stored content changed underneath the service and lets the handler catch up. */
147
+ async resync(projects) {
148
+ for (const project of new Set(projects)) {
149
+ await this.reindexProject(project);
150
+ if (!this.resyncHandler) {
151
+ continue;
152
+ }
153
+ const tx = new GitTx(this.repoDir, this.index);
154
+ await this.resyncHandler(project, tx);
155
+ if (tx.touched) {
156
+ await this.commitInternal(`okf(${project}): regenerate indexes`, "process:ok-fine", { subject: "system", clientId: "ok-fine" }, [project]);
157
+ }
158
+ }
159
+ }
160
+ static async open(config, log) {
161
+ const repoDir = join(config.dataDir, "repo");
162
+ const homeDir = join(config.dataDir, "home");
163
+ await mkdir(repoDir, { recursive: true });
164
+ await mkdir(homeDir, { recursive: true });
165
+ const repo = new GitBackend(config, log, repoDir, homeDir);
166
+ await repo.git.prepareSsh();
167
+ // Check if git initialized
168
+ const gitDirExists = await pathExists(join(repoDir, ".git"));
169
+ if (!gitDirExists) {
170
+ await repo.git.run(["init", "-b", config.gitBranch]);
171
+ }
172
+ const headRev = await repo.git.run(["rev-parse", "--verify", "HEAD"], { allowFail: true });
173
+ let headHasCommits = headRev.code === 0;
174
+ if (repo.hasRemote && config.gitRemoteUrl) {
175
+ const remotes = await repo.git.run(["remote"], { allowFail: true });
176
+ const remoteList = remotes.stdout.split(/\r?\n/).map((r) => r.trim());
177
+ if (remoteList.includes("origin")) {
178
+ await repo.git.run(["remote", "set-url", "origin", config.gitRemoteUrl]);
179
+ }
180
+ else {
181
+ await repo.git.run(["remote", "add", "origin", config.gitRemoteUrl]);
182
+ }
183
+ const fetchRes = await repo.git.run(["fetch", "origin", config.gitBranch], { allowFail: true });
184
+ if (fetchRes.code !== 0) {
185
+ const isRefNotFound = fetchRes.stderr.includes("couldn't find remote ref");
186
+ if (!isRefNotFound) {
187
+ if (!headHasCommits) {
188
+ throw new Error(`Failed to fetch from remote on empty local repository:\n${fetchRes.stderr}`);
189
+ }
190
+ else {
191
+ log.warn({ stderr: fetchRes.stderr }, "Fetch failed on startup; continuing with local commits");
192
+ }
193
+ }
194
+ }
195
+ const remoteBranchRev = await repo.git.run(["rev-parse", "--verify", `origin/${config.gitBranch}`], {
196
+ allowFail: true,
197
+ });
198
+ const remoteBranchExists = remoteBranchRev.code === 0;
199
+ if (!headHasCommits) {
200
+ if (remoteBranchExists) {
201
+ await repo.git.run(["checkout", "-B", config.gitBranch, `origin/${config.gitBranch}`]);
202
+ headHasCommits = true;
203
+ }
204
+ else {
205
+ // Initialize empty bundle root files
206
+ await repo.ensureAttributesAndIgnore();
207
+ await repo.git.run([
208
+ "commit",
209
+ "--author=process:ok-fine <ok-fine@localhost>",
210
+ "-m",
211
+ "okf: initialize knowledge repository",
212
+ ]);
213
+ await repo.git.run(["push", "-u", "origin", `${config.gitBranch}:${config.gitBranch}`], {
214
+ allowFail: true,
215
+ });
216
+ headHasCommits = true;
217
+ }
218
+ }
219
+ else {
220
+ await repo.sync();
221
+ }
222
+ }
223
+ else {
224
+ if (!headHasCommits) {
225
+ await repo.ensureAttributesAndIgnore();
226
+ await repo.git.run([
227
+ "commit",
228
+ "--author=process:ok-fine <ok-fine@localhost>",
229
+ "-m",
230
+ "okf: initialize knowledge repository",
231
+ ]);
232
+ headHasCommits = true;
233
+ }
234
+ }
235
+ // Ensure .gitattributes and .gitignore exist with required lines
236
+ const attrChanged = await repo.ensureAttributesAndIgnore();
237
+ if (attrChanged) {
238
+ await repo.git.run(["add", ".gitattributes", ".gitignore"]);
239
+ const diffCheck = await repo.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
240
+ if (diffCheck.code !== 0) {
241
+ await repo.git.run([
242
+ "commit",
243
+ "--author=process:ok-fine <ok-fine@localhost>",
244
+ "-m",
245
+ "okf: configure merge and ignore attributes",
246
+ ]);
247
+ if (repo.hasRemote) {
248
+ await repo.git.run(["push", "origin", `HEAD:refs/heads/${config.gitBranch}`], {
249
+ allowFail: true,
250
+ });
251
+ }
252
+ }
253
+ }
254
+ await repo.reindexAll();
255
+ return repo;
256
+ }
257
+ async ensureAttributesAndIgnore() {
258
+ let changed = false;
259
+ const gitattributesPath = join(this.repoDir, ".gitattributes");
260
+ let attrContent = "";
261
+ try {
262
+ attrContent = await readFile(gitattributesPath, "utf8");
263
+ }
264
+ catch {
265
+ // not exists
266
+ }
267
+ const requiredAttrLines = ["**/index.md merge=union", "**/log.md merge=union"];
268
+ let newAttrContent = attrContent;
269
+ for (const line of requiredAttrLines) {
270
+ if (!newAttrContent.includes(line)) {
271
+ if (newAttrContent.length > 0 && !newAttrContent.endsWith("\n")) {
272
+ newAttrContent += "\n";
273
+ }
274
+ newAttrContent += `${line}\n`;
275
+ }
276
+ }
277
+ if (newAttrContent !== attrContent) {
278
+ await writeFile(gitattributesPath, newAttrContent, "utf8");
279
+ await this.git.run(["add", ".gitattributes"]);
280
+ changed = true;
281
+ }
282
+ const gitignorePath = join(this.repoDir, ".gitignore");
283
+ let ignoreContent = "";
284
+ try {
285
+ ignoreContent = await readFile(gitignorePath, "utf8");
286
+ }
287
+ catch {
288
+ // not exists
289
+ }
290
+ const requiredIgnoreLine = ".*.tmp";
291
+ let newIgnoreContent = ignoreContent;
292
+ if (!newIgnoreContent.includes(requiredIgnoreLine)) {
293
+ if (newIgnoreContent.length > 0 && !newIgnoreContent.endsWith("\n")) {
294
+ newIgnoreContent += "\n";
295
+ }
296
+ newIgnoreContent += `${requiredIgnoreLine}\n`;
297
+ }
298
+ if (newIgnoreContent !== ignoreContent) {
299
+ await writeFile(gitignorePath, newIgnoreContent, "utf8");
300
+ await this.git.run(["add", ".gitignore"]);
301
+ changed = true;
302
+ }
303
+ return changed;
304
+ }
305
+ async commitInternal(subject, author, principal, projects) {
306
+ const addArgs = projects.length > 0 ? ["add", "-A", "--", ...projects] : ["add", "-A"];
307
+ await this.git.run(addArgs);
308
+ const diffCheck = await this.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
309
+ if (diffCheck.code === 0) {
310
+ return null;
311
+ }
312
+ await this.git.run([
313
+ "commit",
314
+ `--author=${author} <ok-fine@localhost>`,
315
+ "-m",
316
+ subject,
317
+ "-m",
318
+ `Okf-Principal: sub=${principal.subject} client=${principal.clientId}`,
319
+ ]);
320
+ const rev = await this.git.run(["rev-parse", "HEAD"]);
321
+ return rev.stdout.trim();
322
+ }
323
+ /**
324
+ * Aborts a failed rebase, keeps the original local commits on `ok-fine/conflict-<stamp>` (always as a
325
+ * local branch, plus on the remote when the push succeeds), then resets to the remote branch.
326
+ * Returns a human-readable description of where the commits were preserved.
327
+ */
328
+ async preserveConflict() {
329
+ await this.git.run(["rebase", "--abort"], { allowFail: true });
330
+ const stamp = new Date()
331
+ .toISOString()
332
+ .replace(/[-:]/g, "")
333
+ .replace(/\.\d{3}/, "")
334
+ .replace(/Z$/, "Z");
335
+ const conflictBranch = `ok-fine/conflict-${stamp}`;
336
+ // The local branch keeps the commits reachable on the PVC even if the remote push fails.
337
+ await this.git.run(["branch", "-f", conflictBranch, "HEAD"]);
338
+ const pushRes = await this.git.run(["push", "origin", `refs/heads/${conflictBranch}:refs/heads/${conflictBranch}`], { allowFail: true });
339
+ await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
340
+ let message = `local commits preserved on ${conflictBranch}`;
341
+ if (pushRes.code !== 0) {
342
+ const reason = pushRes.stderr.trim().split("\n")[0] ?? "";
343
+ message += ` (local branch only; pushing it failed: ${reason})`;
344
+ }
345
+ this.log.error({ stderr: pushRes.code !== 0 ? pushRes.stderr : undefined }, `rebase conflict; ${message}`);
346
+ return message;
347
+ }
348
+ async transaction(spec, work) {
349
+ return this.mutex.run(async () => {
350
+ const warnings = [];
351
+ // Step 1: Remote sync check before work
352
+ if (this.hasRemote) {
353
+ const fetchRes = await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
354
+ if (fetchRes.code !== 0) {
355
+ const firstLine = fetchRes.stderr.split(/\r?\n/)[0] ?? "unknown error";
356
+ warnings.push(`remote unreachable: ${firstLine}`);
357
+ }
358
+ else {
359
+ const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
360
+ allowFail: true,
361
+ });
362
+ const behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
363
+ if (behind > 0) {
364
+ const before = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
365
+ const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
366
+ if (rebaseRes.code !== 0) {
367
+ warnings.push(await this.preserveConflict());
368
+ }
369
+ const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
370
+ if (before !== after) {
371
+ const diffRes = await this.git.run(["diff", "--name-only", before, after]);
372
+ await this.resync(extractChangedProjects(diffRes.stdout));
373
+ }
374
+ }
375
+ }
376
+ }
377
+ // Step 2 & 3: Run work and commit
378
+ try {
379
+ const workRes = await work(new GitTx(this.repoDir, this.index));
380
+ let commitSha = null;
381
+ if (workRes.commit) {
382
+ const addArgs = spec.projects.length > 0 ? ["add", "-A", "--", ...spec.projects] : ["add", "-A"];
383
+ await this.git.run(addArgs);
384
+ const diffCheck = await this.git.run(["diff", "--cached", "--quiet"], { allowFail: true });
385
+ if (diffCheck.code !== 0) {
386
+ const c = workRes.commit;
387
+ // human:<email> writes carry the person's own email so bundle history matches their code commits.
388
+ const actor = parseActor(c.author);
389
+ const authorEmail = actor?.kind === "human" && actor.id.includes("@") ? actor.id : "ok-fine@localhost";
390
+ const commitArgs = ["commit", `--author=${c.author} <${authorEmail}>`, "-m", c.subject];
391
+ if (c.body && c.body.trim().length > 0) {
392
+ commitArgs.push("-m", c.body);
393
+ }
394
+ commitArgs.push("-m", `Okf-Principal: sub=${c.principal.subject} client=${c.principal.clientId}`);
395
+ await this.git.run(commitArgs);
396
+ commitSha = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
397
+ }
398
+ }
399
+ // Step 4: Push commit if made
400
+ let pushed = null;
401
+ if (commitSha !== null && this.hasRemote) {
402
+ let pushSuccess = false;
403
+ for (let attempt = 0; attempt < 3; attempt++) {
404
+ const pushRes = await this.git.run(["push", "origin", `HEAD:refs/heads/${this.config.gitBranch}`], {
405
+ allowFail: true,
406
+ });
407
+ if (pushRes.code === 0) {
408
+ pushSuccess = true;
409
+ pushed = true;
410
+ break;
411
+ }
412
+ if (isPushRejection(pushRes.stderr)) {
413
+ const pre = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
414
+ await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
415
+ const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
416
+ if (rebaseRes.code === 0) {
417
+ const post = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
418
+ const diffRes = await this.git.run(["diff", "--name-only", pre, post]);
419
+ await this.resync(extractChangedProjects(diffRes.stdout));
420
+ }
421
+ else {
422
+ await this.git.run(["rebase", "--abort"], { allowFail: true });
423
+ await this.git.run(["reset", "--hard", `origin/${this.config.gitBranch}`]);
424
+ await this.resync(spec.projects);
425
+ throw new OkfError("upstream_conflict", 409, "change conflicts with a concurrent upstream edit; re-read and retry");
426
+ }
427
+ }
428
+ else {
429
+ pushed = false;
430
+ const firstLine = pushRes.stderr.split(/\r?\n/)[0] ?? "unknown error";
431
+ warnings.push(`push failed; will retry on next sync: ${firstLine}`);
432
+ break;
433
+ }
434
+ }
435
+ if (!pushSuccess && pushed === null) {
436
+ pushed = false;
437
+ }
438
+ }
439
+ return {
440
+ value: workRes.value,
441
+ commit: commitSha,
442
+ pushed,
443
+ warnings,
444
+ };
445
+ }
446
+ catch (err) {
447
+ await this.git.run(["reset", "--hard", "HEAD"], { allowFail: true });
448
+ const cleanArgs = spec.projects.length > 0 ? ["clean", "-fd", "--", ...spec.projects] : ["clean", "-fd"];
449
+ await this.git.run(cleanArgs, { allowFail: true });
450
+ await this.resync(spec.projects);
451
+ throw err;
452
+ }
453
+ });
454
+ }
455
+ async sync() {
456
+ return this.mutex.run(async () => {
457
+ if (!this.hasRemote) {
458
+ return this.syncStatus();
459
+ }
460
+ const before = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
461
+ const fetchRes = await this.git.run(["fetch", "origin", this.config.gitBranch], { allowFail: true });
462
+ if (fetchRes.code !== 0) {
463
+ this.lastError = fetchRes.stderr.split(/\r?\n/)[0] ?? "fetch failed";
464
+ return this.syncStatus();
465
+ }
466
+ const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
467
+ allowFail: true,
468
+ });
469
+ const behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
470
+ let runError = null;
471
+ if (behind > 0) {
472
+ const rebaseRes = await this.git.run(["rebase", `origin/${this.config.gitBranch}`], { allowFail: true });
473
+ if (rebaseRes.code !== 0) {
474
+ runError = `rebase conflict; ${await this.preserveConflict()}`;
475
+ }
476
+ const after = (await this.git.run(["rev-parse", "HEAD"])).stdout.trim();
477
+ if (before !== after) {
478
+ const diffRes = await this.git.run(["diff", "--name-only", before, after]);
479
+ await this.resync(extractChangedProjects(diffRes.stdout));
480
+ }
481
+ }
482
+ const aheadRes = await this.git.run(["rev-list", "--count", `origin/${this.config.gitBranch}..HEAD`], {
483
+ allowFail: true,
484
+ });
485
+ const ahead = Number.parseInt(aheadRes.stdout.trim(), 10) || 0;
486
+ if (ahead > 0) {
487
+ const pushRes = await this.git.run(["push", "origin", `HEAD:refs/heads/${this.config.gitBranch}`], {
488
+ allowFail: true,
489
+ });
490
+ if (pushRes.code !== 0) {
491
+ const pushError = `push failed: ${pushRes.stderr.split(/\r?\n/)[0] ?? "unknown error"}`;
492
+ runError = runError ? `${runError}; ${pushError}` : pushError;
493
+ }
494
+ }
495
+ // lastError describes the most recent sync run only.
496
+ this.lastError = runError;
497
+ this.lastSyncAt = nowIso();
498
+ return this.syncStatus();
499
+ });
500
+ }
501
+ async syncStatus() {
502
+ const remote = this.config.gitRemoteUrl ? redactRemote(this.config.gitRemoteUrl) : null;
503
+ let ahead = 0;
504
+ let behind = 0;
505
+ if (this.hasRemote) {
506
+ const aheadRes = await this.git.run(["rev-list", "--count", `origin/${this.config.gitBranch}..HEAD`], {
507
+ allowFail: true,
508
+ });
509
+ if (aheadRes.code === 0) {
510
+ ahead = Number.parseInt(aheadRes.stdout.trim(), 10) || 0;
511
+ }
512
+ const behindRes = await this.git.run(["rev-list", "--count", `HEAD..origin/${this.config.gitBranch}`], {
513
+ allowFail: true,
514
+ });
515
+ if (behindRes.code === 0) {
516
+ behind = Number.parseInt(behindRes.stdout.trim(), 10) || 0;
517
+ }
518
+ }
519
+ return {
520
+ remote,
521
+ branch: this.config.gitBranch,
522
+ lastSyncAt: this.lastSyncAt,
523
+ lastError: this.lastError,
524
+ ahead,
525
+ behind,
526
+ };
527
+ }
528
+ async history(project, path, limit) {
529
+ const clampedLimit = Math.max(1, Math.min(100, limit));
530
+ const args = [
531
+ "log",
532
+ "-n",
533
+ String(clampedLimit),
534
+ "--format=%H%x1f%aI%x1f%an%x1f%s%x1f%(trailers:key=Okf-Principal,valueonly)%x1e",
535
+ ];
536
+ args.push("--", path === null ? `${project}/` : `${project}/${path}`);
537
+ const res = await this.git.run(args, { allowFail: true });
538
+ if (res.code !== 0) {
539
+ return [];
540
+ }
541
+ const entries = res.stdout.split("\x1e").filter((e) => e.trim().length > 0);
542
+ const commits = [];
543
+ for (const entry of entries) {
544
+ const parts = entry.trim().split("\x1f");
545
+ const sha = parts[0] ?? "";
546
+ const at = parts[1] ?? "";
547
+ const actor = parts[2] ?? "";
548
+ const subject = parts[3] ?? "";
549
+ const principalRaw = parts[4]?.trim();
550
+ commits.push({
551
+ sha,
552
+ at,
553
+ actor,
554
+ subject,
555
+ principal: principalRaw && principalRaw.length > 0 ? principalRaw : null,
556
+ });
557
+ }
558
+ return commits;
559
+ }
560
+ archive(project) {
561
+ return this.git.spawnStdout(["archive", "--format=tar.gz", "HEAD", "--", project]);
562
+ }
563
+ }
564
+ //# sourceMappingURL=git-backend.js.map