@klarkxy/dsh-git-commit 0.2.0 → 0.2.2

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/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { DEFAULT_MAX_OUTPUT_TOKENS, MAX_MANIFEST_CHARS, MAX_UNTRACKED_PREVIEW_CHARS, PLUGIN_NAME, RPC_CHANNEL, SETTINGS_KEY, defaultSettings, normalizeFallback, normalizeModelRoute } from "./contracts.js";
2
2
  import { registerHostRpc } from "@klarkxy/dsh-plugin-kit/host-rpc";
3
- import { callLlmText, resolveFeatureModel } from "@klarkxy/dsh-plugin-kit";
3
+ import { notifyModelSettingsChanged, registerModelSettings } from "@klarkxy/dsh-plugin-kit/model-settings";
4
4
  import { modelRouteOverride } from "@klarkxy/dsh-model-route";
5
+ import { callLlmText, resolveFeatureModel } from "@klarkxy/dsh-plugin-kit";
5
6
  import { execFile } from "node:child_process";
6
7
  import { createHash } from "node:crypto";
7
8
  import { constants } from "node:fs";
@@ -343,6 +344,19 @@ async function untrackedPreview(root, path) {
343
344
  };
344
345
  }
345
346
  }
347
+ /** Known commits or an uncertain current group must survive a rejected run. */
348
+ var PartialCommitError = class extends Error {
349
+ commits;
350
+ cause;
351
+ uncertain;
352
+ constructor(commits, cause, uncertain) {
353
+ super(cause instanceof Error ? cause.message : String(cause));
354
+ this.name = "PartialCommitError";
355
+ this.commits = commits;
356
+ this.cause = cause;
357
+ this.uncertain = uncertain;
358
+ }
359
+ };
346
360
  async function indexBytes(path) {
347
361
  try {
348
362
  return await readFile(path);
@@ -369,26 +383,38 @@ async function headOid(run, root) {
369
383
  */
370
384
  async function commitGroups(run, root, groups, selected, signal, verify) {
371
385
  const commits = [];
386
+ let partialFailure;
387
+ const fail = (error) => {
388
+ if (error instanceof PartialCommitError) {
389
+ partialFailure = error;
390
+ throw error;
391
+ }
392
+ if (commits.length === 0) throw error;
393
+ partialFailure = new PartialCommitError(commits, error);
394
+ throw partialFailure;
395
+ };
372
396
  for (const group of groups) {
373
397
  if (group.files.length === 0) continue;
374
398
  const paths = group.files.flatMap((path) => {
375
399
  const file = selected.find((file) => file.path === path);
376
400
  return file?.status === "renamed" && file.origin ? [path, file.origin] : [path];
377
401
  });
378
- await verify?.(paths);
379
- const gitPath = (await run([
380
- "rev-parse",
381
- "--git-path",
382
- "index"
383
- ], {
384
- cwd: root,
385
- signal
386
- })).trim();
387
- const indexPath = isAbsolute(gitPath) ? gitPath : resolve(root, gitPath);
388
- const backupDir = await mkdtemp(join(tmpdir(), "dsh-git-commit-"));
389
- const backupPath = join(backupDir, "index");
390
- let hadIndex = true;
402
+ let backupDir;
391
403
  try {
404
+ signal?.throwIfAborted();
405
+ await verify?.(paths);
406
+ const gitPath = (await run([
407
+ "rev-parse",
408
+ "--git-path",
409
+ "index"
410
+ ], {
411
+ cwd: root,
412
+ signal
413
+ })).trim();
414
+ const indexPath = isAbsolute(gitPath) ? gitPath : resolve(root, gitPath);
415
+ backupDir = await mkdtemp(join(tmpdir(), "dsh-git-commit-"));
416
+ const backupPath = join(backupDir, "index");
417
+ let hadIndex = true;
392
418
  try {
393
419
  await copyFile(indexPath, backupPath);
394
420
  } catch (error) {
@@ -399,7 +425,10 @@ async function commitGroups(run, root, groups, selected, signal, verify) {
399
425
  const originalIndex = await indexBytes(indexPath);
400
426
  if (hadIndex ? !originalIndex?.equals(await readFile(backupPath)) : originalIndex !== void 0) throw new GitError("dsh-git-commit: index changed before committing; retry");
401
427
  let stagedIndex = originalIndex;
428
+ let committed = false;
429
+ let commitInvoked = false;
402
430
  try {
431
+ signal?.throwIfAborted();
403
432
  await run([
404
433
  "add",
405
434
  "-A",
@@ -410,6 +439,8 @@ async function commitGroups(run, root, groups, selected, signal, verify) {
410
439
  signal
411
440
  });
412
441
  stagedIndex = await indexBytes(indexPath);
442
+ signal?.throwIfAborted();
443
+ commitInvoked = true;
413
444
  await run([
414
445
  "commit",
415
446
  "--quiet",
@@ -422,33 +453,71 @@ async function commitGroups(run, root, groups, selected, signal, verify) {
422
453
  cwd: root,
423
454
  signal
424
455
  });
425
- const hash = (await run([
426
- "rev-parse",
427
- "--short",
428
- "HEAD"
429
- ], {
430
- cwd: root,
431
- signal
432
- })).trim();
456
+ committed = true;
457
+ let hash = "";
458
+ try {
459
+ hash = (await run([
460
+ "rev-parse",
461
+ "--short",
462
+ "HEAD"
463
+ ], { cwd: root })).trim();
464
+ } catch (error) {
465
+ commits.push({
466
+ hash: "",
467
+ skipped: false
468
+ });
469
+ throw error;
470
+ }
471
+ if (!hash) {
472
+ commits.push({
473
+ hash: "",
474
+ skipped: false
475
+ });
476
+ throw new GitError("dsh-git-commit: commit was created but its hash could not be read");
477
+ }
433
478
  commits.push({
434
479
  hash,
435
480
  skipped: false
436
481
  });
437
482
  } catch (error) {
438
- const currentHead = await headOid(run, root);
439
- const currentIndex = await indexBytes(indexPath);
440
- const indexUntouched = currentIndex === void 0 ? stagedIndex === void 0 : currentIndex.equals(stagedIndex ?? Buffer.alloc(0));
441
- if (currentHead === head && indexUntouched) {
442
- if (hadIndex) await copyFile(backupPath, indexPath);
443
- else await rm(indexPath, { force: true });
483
+ if (!committed) {
484
+ let currentHead;
485
+ try {
486
+ currentHead = await headOid(run, root);
487
+ } catch {
488
+ if (commitInvoked) throw new PartialCommitError(commits, error, {
489
+ message: group.message,
490
+ files: [...group.files]
491
+ });
492
+ throw error;
493
+ }
494
+ if (currentHead !== head) {
495
+ if (commitInvoked) throw new PartialCommitError(commits, error, {
496
+ message: group.message,
497
+ files: [...group.files]
498
+ });
499
+ throw error;
500
+ }
501
+ const currentIndex = await indexBytes(indexPath);
502
+ const indexUntouched = currentIndex === void 0 ? stagedIndex === void 0 : currentIndex.equals(stagedIndex ?? Buffer.alloc(0));
503
+ if (currentHead === head && indexUntouched) {
504
+ if (hadIndex) await copyFile(backupPath, indexPath);
505
+ else await rm(indexPath, { force: true });
506
+ }
444
507
  }
445
508
  throw error;
446
509
  }
510
+ } catch (error) {
511
+ fail(error);
447
512
  } finally {
448
- await rm(backupDir, {
449
- recursive: true,
450
- force: true
451
- });
513
+ if (backupDir) try {
514
+ await rm(backupDir, {
515
+ recursive: true,
516
+ force: true
517
+ });
518
+ } catch (error) {
519
+ fail(partialFailure ?? error);
520
+ }
452
521
  }
453
522
  }
454
523
  return commits;
@@ -563,10 +632,13 @@ function parsePlan(text, files) {
563
632
  //#region src/service.ts
564
633
  var CommitRunError = class extends Error {
565
634
  code;
566
- constructor(code, message) {
635
+ /** Commits already created when this run stopped. Absent when nothing was written. */
636
+ partial;
637
+ constructor(code, message, partial) {
567
638
  super(message);
568
- this.name = "CommitRunError";
639
+ this.name = code === "cancelled" ? "AbortError" : "CommitRunError";
569
640
  this.code = code;
641
+ this.partial = partial;
570
642
  }
571
643
  };
572
644
  function modelInfo(route, source) {
@@ -597,6 +669,8 @@ var GitCommitService = class {
597
669
  settingsFailure;
598
670
  settingsAdopted = false;
599
671
  disposed = false;
672
+ /** Aborted on unload so an in-flight model plan receives the same signal as a cancelled request. */
673
+ lifetime = new AbortController();
600
674
  constructor(deps) {
601
675
  this.deps = deps;
602
676
  this.run = deps.run ?? execGit;
@@ -617,13 +691,21 @@ var GitCommitService = class {
617
691
  }
618
692
  /** Existing synchronous snapshot API; host operations wait for initialization first. */
619
693
  getSettings() {
694
+ this.workSignal();
620
695
  if (this.settingsFailure) throw this.settingsFailure;
621
696
  return structuredClone(this.settings);
622
697
  }
623
698
  async ready() {
699
+ this.workSignal();
624
700
  await this.settingsReady;
701
+ this.workSignal();
625
702
  if (this.settingsFailure) throw this.settingsFailure;
626
703
  }
704
+ /** Request cancellation and plugin unload share one signal. A disposed service rejects before any new work. */
705
+ workSignal(signal) {
706
+ if (this.disposed || this.lifetime.signal.aborted) throw new CommitRunError("disposed", "dsh-git-commit: plugin is no longer active");
707
+ return signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal;
708
+ }
627
709
  async waitForSettings(signal) {
628
710
  signal?.throwIfAborted();
629
711
  if (!signal) {
@@ -670,7 +752,10 @@ var GitCommitService = class {
670
752
  };
671
753
  await this.deps.settings?.save(next);
672
754
  this.settings = next;
673
- return this.getSettings();
755
+ try {
756
+ this.deps.onSettingsChanged?.(next.revision);
757
+ } catch {}
758
+ return structuredClone(next);
674
759
  });
675
760
  this.settingsPending = result.then(() => {}, () => {});
676
761
  return result;
@@ -678,27 +763,30 @@ var GitCommitService = class {
678
763
  start() {}
679
764
  async dispose() {
680
765
  this.disposed = true;
766
+ this.lifetime.abort();
681
767
  await this.settingsReady;
682
768
  await this.settingsPending;
683
769
  await this.running?.catch(() => {});
684
770
  }
685
- async resolveRepo(sessionId) {
771
+ async resolveRepo(sessionId, signal) {
686
772
  const session = this.deps.sessions.get(sessionId);
687
773
  if (!session) throw new CommitRunError("no-session", "dsh-git-commit: session is not live on this host");
688
774
  const cwd = normalizeCwd(session.header?.cwd);
689
775
  if (!cwd) throw new CommitRunError("no-cwd", "dsh-git-commit: session has no working directory");
690
776
  try {
691
- return await repoInfo(this.run, cwd);
777
+ return await repoInfo(this.run, cwd, signal);
692
778
  } catch (error) {
779
+ signal?.throwIfAborted();
693
780
  if (isGitMissing(error)) throw new CommitRunError("no-git", "dsh-git-commit: git executable not found");
694
781
  throw new CommitRunError("not-a-repo", "dsh-git-commit: session directory is not inside a git repository");
695
782
  }
696
783
  }
697
- async status(sessionId) {
698
- await this.ready();
784
+ async status(sessionId, signal) {
785
+ const work = this.workSignal(signal);
786
+ await this.waitForSettings(work);
699
787
  let resolved;
700
788
  try {
701
- resolved = await this.resolveRepo(sessionId);
789
+ resolved = await this.resolveRepo(sessionId, work);
702
790
  } catch (error) {
703
791
  if (error instanceof CommitRunError) return {
704
792
  available: false,
@@ -708,7 +796,8 @@ var GitCommitService = class {
708
796
  };
709
797
  throw error;
710
798
  }
711
- const changes = await workingTreeChanges(this.run, resolved.root);
799
+ const changes = await workingTreeChanges(this.run, resolved.root, work);
800
+ work.throwIfAborted();
712
801
  const selected = this.selectedModel(sessionId);
713
802
  const model = modelInfo(selected.route, selected.source);
714
803
  return {
@@ -720,21 +809,27 @@ var GitCommitService = class {
720
809
  };
721
810
  }
722
811
  async commit(sessionId, signal) {
812
+ if (this.disposed || this.lifetime.signal.aborted) throw new CommitRunError("disposed", "dsh-git-commit: plugin is no longer active");
723
813
  if (this.running) throw new CommitRunError("busy", "dsh-git-commit: a commit run is already in progress");
724
- const run = this.runCommit(sessionId, signal);
814
+ const work = this.workSignal(signal);
815
+ const run = this.runCommit(sessionId, work);
725
816
  this.running = run;
726
817
  try {
727
818
  return await run;
819
+ } catch (error) {
820
+ if (error instanceof CommitRunError && error.partial) throw error;
821
+ if (work.aborted) throw new CommitRunError("cancelled", "dsh-git-commit: commit run cancelled");
822
+ throw error;
728
823
  } finally {
729
824
  this.running = void 0;
730
825
  }
731
826
  }
732
827
  async runCommit(sessionId, signal) {
733
828
  await this.waitForSettings(signal);
734
- if (this.disposed) throw new CommitRunError("cancelled", "dsh-git-commit: plugin is no longer active");
829
+ if (this.disposed || signal?.aborted) throw new CommitRunError("cancelled", "dsh-git-commit: plugin is no longer active");
735
830
  const session = this.deps.sessions.get(sessionId);
736
831
  const sessionCwd = normalizeCwd(session?.header?.cwd);
737
- const resolved = await this.resolveRepo(sessionId);
832
+ const resolved = await this.resolveRepo(sessionId, signal);
738
833
  const assertReady = () => {
739
834
  signal?.throwIfAborted();
740
835
  if (this.disposed || !this.deps.llm) throw new CommitRunError("cancelled", "dsh-git-commit: plugin is no longer active");
@@ -758,7 +853,10 @@ var GitCommitService = class {
758
853
  const originalVersions = await pathVersions(this.run, resolved.root, paths, signal);
759
854
  const [{ stat, diff }, subjects] = await Promise.all([diffAgainstHead(this.run, resolved.root, paths, signal), recentSubjects(this.run, resolved.root, 10, signal)]);
760
855
  const untracked = [];
761
- for (const file of selected) if (file.status === "untracked") untracked.push(await this.preview(resolved.root, file.path));
856
+ for (const file of selected) {
857
+ assertReady();
858
+ if (file.status === "untracked") untracked.push(await this.preview(resolved.root, file.path));
859
+ }
762
860
  let groups;
763
861
  let planFailure = "no model route was available";
764
862
  let model;
@@ -776,13 +874,14 @@ var GitCommitService = class {
776
874
  untracked
777
875
  }),
778
876
  maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
779
- signal: signal ?? new AbortController().signal,
877
+ signal,
780
878
  sessionId
781
879
  });
782
880
  model = modelInfo(result, planned.source);
783
881
  groups = parsePlan(result.text, selected);
784
882
  if (!groups) planFailure = "the model returned a plan that does not cover the change set";
785
883
  } catch (error) {
884
+ assertReady();
786
885
  if (error instanceof CommitRunError) throw error;
787
886
  planFailure = error instanceof Error ? error.message : String(error);
788
887
  }
@@ -790,30 +889,59 @@ var GitCommitService = class {
790
889
  assertReady();
791
890
  if ((await workspaceSnapshot(this.run, resolved.root, signal)).fingerprint !== initial.fingerprint) throw new CommitRunError("workspace-changed", "dsh-git-commit: workspace changed during planning; retry the commit");
792
891
  const effective = groups ?? [fallbackGroup(selected, looksChinese(subjects) ? "zh" : "en")];
793
- const executed = await commitGroups(this.run, resolved.root, effective, selected, signal, async () => {
794
- assertReady();
795
- if (await this.run([
796
- "symbolic-ref",
797
- "--quiet",
798
- "HEAD"
799
- ], {
800
- cwd: resolved.root,
801
- signal
802
- }).catch(() => "DETACHED") !== branchRef) throw new CommitRunError("workspace-changed", "dsh-git-commit: branch changed during the commit run");
803
- if (await pathVersions(this.run, resolved.root, paths, signal) !== originalVersions) throw new CommitRunError("workspace-changed", "dsh-git-commit: selected file changed during the commit run");
804
- await workingTreeChanges(this.run, resolved.root, signal);
805
- });
806
- const commits = [];
807
- for (let index = 0; index < effective.length; index += 1) {
808
- const result = executed[index];
809
- const group = effective[index];
810
- if (!result || !group || result.skipped) continue;
811
- commits.push({
812
- hash: result.hash,
813
- message: group.message,
814
- files: group.files
892
+ const describe = (executed) => {
893
+ const commits = [];
894
+ for (let index = 0; index < executed.length; index += 1) {
895
+ const result = executed[index];
896
+ const group = effective[index];
897
+ if (!result || !group || result.skipped) continue;
898
+ commits.push({
899
+ hash: result.hash,
900
+ message: group.message,
901
+ files: [...group.files]
902
+ });
903
+ }
904
+ return commits;
905
+ };
906
+ let executed;
907
+ try {
908
+ executed = await commitGroups(this.run, resolved.root, effective, selected, signal, async () => {
909
+ assertReady();
910
+ if (await this.run([
911
+ "symbolic-ref",
912
+ "--quiet",
913
+ "HEAD"
914
+ ], {
915
+ cwd: resolved.root,
916
+ signal
917
+ }).catch(() => "DETACHED") !== branchRef) throw new CommitRunError("workspace-changed", "dsh-git-commit: branch changed during the commit run");
918
+ if (await pathVersions(this.run, resolved.root, paths, signal) !== originalVersions) throw new CommitRunError("workspace-changed", "dsh-git-commit: selected file changed during the commit run");
919
+ await workingTreeChanges(this.run, resolved.root, signal);
920
+ });
921
+ } catch (error) {
922
+ if (!(error instanceof PartialCommitError)) throw error;
923
+ const commits = describe(error.commits);
924
+ const cause = error.cause;
925
+ const cancelled = signal?.aborted || typeof cause === "object" && cause !== null && cause.code === "cancelled" || cause instanceof Error && cause.name === "AbortError";
926
+ const code = cause instanceof CommitRunError ? cause.code : cancelled ? "cancelled" : "commit-failed";
927
+ const message = cause instanceof Error ? cause.message : String(cause);
928
+ throw new CommitRunError(code, message, {
929
+ branch: resolved.branch,
930
+ commits,
931
+ fallback: groups === void 0,
932
+ failure: {
933
+ code,
934
+ message
935
+ },
936
+ remaining: effective.slice(error.commits.length + (error.uncertain ? 1 : 0)).map((group) => ({
937
+ message: group.message,
938
+ files: [...group.files]
939
+ })),
940
+ ...error.uncertain ? { uncertain: [error.uncertain] } : {},
941
+ ...model ? { model } : {}
815
942
  });
816
943
  }
944
+ const commits = describe(executed);
817
945
  if (commits.length === 0) throw new CommitRunError("no-changes", "dsh-git-commit: nothing was committed");
818
946
  return {
819
947
  branch: resolved.branch,
@@ -880,7 +1008,8 @@ function fail(error) {
880
1008
  ok: false,
881
1009
  error: {
882
1010
  code: error.code,
883
- message: error.message
1011
+ message: error.message,
1012
+ ...error.partial ? { partial: error.partial } : {}
884
1013
  }
885
1014
  };
886
1015
  return {
@@ -905,7 +1034,7 @@ async function handleRpc(service, endpoint, payload, signal) {
905
1034
  };
906
1035
  return {
907
1036
  ok: true,
908
- value: await service.status(sessionId)
1037
+ value: await service.status(sessionId, signal)
909
1038
  };
910
1039
  }
911
1040
  case "settings":
@@ -963,6 +1092,7 @@ function apply(ctx) {
963
1092
  const tablePromise = domainPromise.then((domain) => domain.table("settings"));
964
1093
  const service = new GitCommitService({
965
1094
  plugin: PLUGIN_NAME,
1095
+ onSettingsChanged: (revision) => notifyModelSettingsChanged(ctx, PLUGIN_NAME, revision),
966
1096
  llm: host.llm,
967
1097
  host,
968
1098
  sessions: host.sessions,
@@ -981,6 +1111,43 @@ function apply(ctx) {
981
1111
  await (await domainPromise).close();
982
1112
  }, "git-commit.lifecycle");
983
1113
  ctx.effect(() => registerHostRpc(host, RPC_CHANNEL, (endpoint, payload, signal) => handleRpc(service, endpoint, payload, signal)), "git-commit.rpc");
1114
+ ctx.effect(() => registerModelSettings(ctx, {
1115
+ ns: PLUGIN_NAME,
1116
+ title: {
1117
+ zh: "Git 提交",
1118
+ en: "Git Commit"
1119
+ },
1120
+ async read(signal) {
1121
+ await service.ready();
1122
+ signal.throwIfAborted();
1123
+ const settings = service.getSettings();
1124
+ return {
1125
+ revision: settings.revision,
1126
+ fields: [{
1127
+ path: ["model"],
1128
+ label: {
1129
+ zh: "提交模型",
1130
+ en: "Commit model"
1131
+ },
1132
+ current: settings.model,
1133
+ followLabel: {
1134
+ zh: "当前会话 / 宿主默认模型",
1135
+ en: "Current conversation / host default"
1136
+ },
1137
+ allowFollow: true
1138
+ }]
1139
+ };
1140
+ },
1141
+ async write(path, route, expectedRevision, signal) {
1142
+ if (path.length !== 1 || path[0] !== "model") throw new Error("Unknown Git Commit model setting");
1143
+ await service.ready();
1144
+ signal.throwIfAborted();
1145
+ return service.updateSettings({
1146
+ ...service.getSettings(),
1147
+ model: route
1148
+ }, expectedRevision);
1149
+ }
1150
+ }), "git-commit.model-settings");
984
1151
  }
985
1152
  //#endregion
986
1153
  export { GitCommitService, apply, buildPlanInput, defaultSettings, handleRpc, inject, name, normalizeFallback, normalizeModelRoute, parsePlan, parsePorcelain };