dsh-taskboard 0.6.2 → 0.6.3

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.
@@ -1,5 +1,7 @@
1
- import { asBoardSettings, asIsolation, asPermission, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, defaultPermissionOf, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
1
+ import { asBoardSettings, asIsolation, asPermission, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, defaultPermissionOf, isValidRelRepoPath, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
2
2
  import { WORKTREE_DIR, worktreePathOf } from "./git.js";
3
+ import { removeMirror, repoMainPath } from "./isolation.js";
4
+ import { createRepoScanner } from "./repos.js";
3
5
  import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
4
6
  import { ERR, ToolError } from "./tools.js";
5
7
  import { join, resolve, sep } from "node:path";
@@ -189,23 +191,58 @@ function registerTaskboardRoutes(ctx, options) {
189
191
  return true;
190
192
  }
191
193
  };
192
- const gitAvailable = async (path) => {
193
- if (options.git === void 0) return false;
194
+ const sharedScanner = options.scanner ?? createRepoScanner();
195
+ /**
196
+ * Whether the workspace root itself is a git repo (the .gitignore-suggestion
197
+ * gate — a plain container has no repo that could ignore anything).
198
+ */
199
+ const rootIsRepo = async (path) => {
200
+ try {
201
+ return await options.git?.detect(path) ?? false;
202
+ } catch {}
203
+ };
204
+ /**
205
+ * Workspace repo facts for the form (0.6.3): `gitAvailable` gates the
206
+ * worktree option, `repoCount` feeds the mirror badge. Availability now
207
+ * covers PARALLEL MULTI-REPO workspaces too: a root repo qualifies as
208
+ * before, and a workspace whose root is NOT a repo still qualifies when
209
+ * the scanner finds nested repos — prepareMirror isolates exactly that
210
+ * container shape (mirror root = plain dir, one worktree per nested repo),
211
+ * so the form must not lock the capability away.
212
+ */
213
+ const workspaceRepos = async (path) => {
214
+ if (options.git === void 0) return {
215
+ gitAvailable: false,
216
+ repoCount: 0
217
+ };
194
218
  const hit = gitCache.get(path);
195
- if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value;
196
- let value = false;
219
+ if (hit !== void 0 && options.now() - hit.at < GIT_DETECT_TTL_MS) return {
220
+ gitAvailable: hit.value,
221
+ repoCount: hit.repoCount ?? (hit.value ? 1 : 0)
222
+ };
223
+ let rootRepo = false;
197
224
  try {
198
- value = await options.git.detect(path);
225
+ rootRepo = await options.git.detect(path);
199
226
  } catch {}
200
- gitCache.set(path, {
201
- value,
202
- at: options.now()
203
- });
204
- if (value && !gitHinted.has(path)) {
227
+ if (rootRepo && !gitHinted.has(path)) {
205
228
  gitHinted.add(path);
206
229
  if (await gitignoreMissing(path)) console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`);
207
230
  }
208
- return value;
231
+ let nestedCount = 0;
232
+ try {
233
+ nestedCount = (await sharedScanner.findNestedRepos(path)).length;
234
+ } catch {}
235
+ const value = rootRepo || nestedCount > 0;
236
+ const repoCount = (rootRepo ? 1 : 0) + nestedCount;
237
+ gitCache.set(path, {
238
+ value,
239
+ at: options.now(),
240
+ repoCount
241
+ });
242
+ return {
243
+ gitAvailable: value,
244
+ repoCount
245
+ };
209
246
  };
210
247
  /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
211
248
  const listOrphanWorktrees = async () => {
@@ -229,7 +266,7 @@ function registerTaskboardRoutes(ctx, options) {
229
266
  const listGitignoreSuggestions = async () => {
230
267
  const suggestions = [];
231
268
  for (const ws of workspaces.list()) {
232
- if (!await gitAvailable(ws.path)) continue;
269
+ if (!await rootIsRepo(ws.path)) continue;
233
270
  if (await gitignoreMissing(ws.path)) suggestions.push({
234
271
  workspaceId: ws.id,
235
272
  workspacePath: ws.path
@@ -252,13 +289,14 @@ function registerTaskboardRoutes(ctx, options) {
252
289
  }
253
290
  if (pathname === `/dsh-taskboard/workspaces`) {
254
291
  const list = workspaces.list();
255
- const flags = await Promise.all(list.map((ws) => gitAvailable(ws.path)));
292
+ const info = await Promise.all(list.map((ws) => workspaceRepos(ws.path)));
256
293
  json(res, {
257
294
  ok: true,
258
295
  value: list.map((ws, i) => ({
259
296
  ...ws,
260
297
  sessionCount: 0,
261
- gitAvailable: flags[i]
298
+ gitAvailable: info[i].gitAvailable,
299
+ repoCount: info[i].repoCount
262
300
  }))
263
301
  });
264
302
  return;
@@ -294,9 +332,19 @@ function registerTaskboardRoutes(ctx, options) {
294
332
  const filePath = url.searchParams.get("path");
295
333
  const ws = workspaces.get(task.workspaceId);
296
334
  if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
297
- const cwd = execution.worktreePath ?? ws.path;
298
- let result = commit !== null ? await options.git.showCommit(cwd, commit) : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : void 0;
299
- if (result === void 0 && execution.worktreePath !== void 0 && cwd !== ws.path) result = commit !== null ? await options.git.showCommit(ws.path, commit) : filePath !== null && execution.baseCommit !== void 0 ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit) : void 0;
335
+ const repoParam = url.searchParams.get("repo");
336
+ let cwd = execution.worktreePath ?? ws.path;
337
+ let mainRepo = ws.path;
338
+ let baseCommit = execution.baseCommit;
339
+ if (repoParam !== null) {
340
+ const entry = execution.repos?.find((r) => r.repo === repoParam);
341
+ if (entry === void 0) throw new Error("Error: invalid_input: 该执行没有此仓库的镜像记录");
342
+ cwd = entry.worktreePath;
343
+ mainRepo = repoMainPath(ws.path, entry.repo);
344
+ baseCommit = entry.baseCommit;
345
+ }
346
+ let result = commit !== null ? await options.git.showCommit(cwd, commit) : filePath !== null ? await options.git.showPathDiff(cwd, filePath, baseCommit) : void 0;
347
+ if (result === void 0 && cwd !== mainRepo) result = commit !== null ? await options.git.showCommit(mainRepo, commit) : filePath !== null && baseCommit !== void 0 ? await options.git.showPathDiff(mainRepo, filePath, baseCommit) : void 0;
300
348
  if (result === void 0) throw new Error("Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)");
301
349
  json(res, {
302
350
  ok: true,
@@ -599,17 +647,33 @@ function registerTaskboardRoutes(ctx, options) {
599
647
  const path = worktreePathOf(ws.path, id);
600
648
  if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
601
649
  try {
602
- if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
650
+ await removeMirror({
651
+ git: options.git,
652
+ scanner: options.scanner ?? createRepoScanner()
653
+ }, {
654
+ workspacePath: ws.path,
655
+ taskId: id
656
+ });
657
+ await rm(path, {
603
658
  recursive: true,
604
659
  force: true
605
660
  });
606
661
  } catch (error) {
607
662
  const message = error instanceof Error ? error.message : String(error);
608
- if (error.code === "dirty-worktree" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
663
+ if (error.code === "dirty-worktree" || error.code === "dirty-mirror" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
609
664
  throw new Error(`Error: invalid_input: ${message}`);
610
665
  }
611
- if (task.branch !== void 0) try {
612
- await options.git.deleteBranch(ws.path, task.branch);
666
+ const branchTargets = [];
667
+ if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({
668
+ repo,
669
+ branch
670
+ });
671
+ if (task.branch !== void 0 && !branchTargets.some((t) => t.repo === "")) branchTargets.push({
672
+ repo: "",
673
+ branch: task.branch
674
+ });
675
+ for (const target of branchTargets) try {
676
+ await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch);
613
677
  } catch {}
614
678
  }
615
679
  }
@@ -685,51 +749,103 @@ function registerTaskboardRoutes(ctx, options) {
685
749
  json(res, fail("invalid_input", "git integration unavailable").res, 501);
686
750
  return;
687
751
  }
688
- if (task.branch === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
689
752
  if (task.status === "in_progress") throw new Error("Error: invalid_input: 任务执行中,不能合并");
690
753
  if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务执行中,不能合并");
691
754
  const ws = workspaces.get(task.workspaceId);
692
755
  if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
693
- let noop = false;
694
- try {
695
- noop = await options.git.isAncestor(ws.path, task.branch);
696
- } catch {}
697
- if (noop) {
698
- json(res, {
699
- ok: true,
700
- value: {
701
- merged: false,
702
- noop: true,
703
- branch: task.branch
704
- }
705
- });
706
- return;
707
- }
708
- try {
709
- await options.git.merge(ws.path, task.branch);
710
- } catch (error) {
711
- throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
756
+ const targets = [];
757
+ if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) targets.push({
758
+ repo,
759
+ branch
760
+ });
761
+ if (task.branch !== void 0 && !targets.some((t) => t.repo === "")) targets.unshift({
762
+ repo: "",
763
+ branch: task.branch
764
+ });
765
+ if (targets.length === 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
766
+ const multi = task.branches !== void 0;
767
+ const results = [];
768
+ for (const target of targets) {
769
+ if (!isValidRelRepoPath(target.repo)) throw new Error("Error: invalid_input: 非法的仓库路径");
770
+ const repoRoot = repoMainPath(ws.path, target.repo);
771
+ let noop = false;
772
+ try {
773
+ noop = await options.git.isAncestor(repoRoot, target.branch);
774
+ } catch {}
775
+ if (noop) {
776
+ results.push({
777
+ repo: target.repo,
778
+ branch: target.branch,
779
+ outcome: "noop"
780
+ });
781
+ continue;
782
+ }
783
+ try {
784
+ const exempt = target.repo === "" ? await (options.scanner ?? createRepoScanner()).findNestedRepos(ws.path).then((rs) => rs.map((r) => r.relPath)) : void 0;
785
+ await options.git.merge(repoRoot, target.branch, exempt);
786
+ results.push({
787
+ repo: target.repo,
788
+ branch: target.branch,
789
+ outcome: "merged"
790
+ });
791
+ } catch (error) {
792
+ results.push({
793
+ repo: target.repo,
794
+ branch: target.branch,
795
+ outcome: "failed",
796
+ error: error instanceof Error ? error.message : String(error)
797
+ });
798
+ }
712
799
  }
713
- const mergedComment = {
714
- id: newCommentId(),
715
- body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`),
716
- version: 1,
717
- createdAt: options.now()
718
- };
719
- await store.mutate("comment-added", (ledger) => {
800
+ const pushComment = (body) => store.mutate("comment-added", (ledger) => {
720
801
  const { index, task: fresh } = liveTaskAt(ledger, id);
721
802
  const next = structuredClone(fresh);
722
- next.comments.push(mergedComment);
803
+ next.comments.push({
804
+ id: newCommentId(),
805
+ body: normalizeBody(body),
806
+ version: 1,
807
+ createdAt: options.now()
808
+ });
723
809
  next.version = fresh.version + 1;
724
810
  next.updatedAt = options.now();
725
811
  ledger.tasks[index] = next;
726
812
  return [next];
727
- });
813
+ }).then(() => void 0);
814
+ if (!multi) {
815
+ const root = results.find((r) => r.repo === "");
816
+ if (root === void 0) throw new Error("Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)");
817
+ if (root.outcome === "noop") {
818
+ json(res, {
819
+ ok: true,
820
+ value: {
821
+ merged: false,
822
+ noop: true,
823
+ branch: root.branch
824
+ }
825
+ });
826
+ return;
827
+ }
828
+ if (root.outcome === "failed") throw new Error(`Error: invalid_input: ${root.error ?? "合并失败"}`);
829
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`);
830
+ json(res, {
831
+ ok: true,
832
+ value: {
833
+ merged: true,
834
+ branch: root.branch
835
+ }
836
+ });
837
+ return;
838
+ }
839
+ const labelOf = (repo) => repo === "" ? "根仓库" : repo;
840
+ const mergedCount = results.filter((r) => r.outcome === "merged").length;
841
+ const failedCount = results.filter((r) => r.outcome === "failed").length;
842
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${results.map((r) => r.outcome === "merged" ? `${labelOf(r.repo)} ✓ 已合并` : r.outcome === "noop" ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? "合并失败").slice(0, 150)}`).join(";")}`);
728
843
  json(res, {
729
844
  ok: true,
730
845
  value: {
731
- merged: true,
732
- branch: task.branch
846
+ merged: mergedCount > 0,
847
+ ...mergedCount === 0 && failedCount === 0 ? { noop: true } : {},
848
+ results
733
849
  }
734
850
  });
735
851
  return;
@@ -745,7 +861,14 @@ function registerTaskboardRoutes(ctx, options) {
745
861
  const path = worktreePathOf(ws.path, id);
746
862
  if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
747
863
  try {
748
- if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
864
+ await removeMirror({
865
+ git: options.git,
866
+ scanner: options.scanner ?? createRepoScanner()
867
+ }, {
868
+ workspacePath: ws.path,
869
+ taskId: id
870
+ });
871
+ await rm(path, {
749
872
  recursive: true,
750
873
  force: true
751
874
  });
@@ -754,11 +877,24 @@ function registerTaskboardRoutes(ctx, options) {
754
877
  }
755
878
  let branchDeleted = false;
756
879
  let branchError;
757
- if (body.deleteBranch === true && task.branch !== void 0) try {
758
- await options.git.deleteBranch(ws.path, task.branch);
759
- branchDeleted = true;
760
- } catch (error) {
761
- branchError = error instanceof Error ? error.message : String(error);
880
+ if (body.deleteBranch === true) {
881
+ const branchTargets = [];
882
+ if (task.branches !== void 0) for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({
883
+ repo,
884
+ branch
885
+ });
886
+ if (task.branch !== void 0 && !branchTargets.some((t) => t.repo === "")) branchTargets.push({
887
+ repo: "",
888
+ branch: task.branch
889
+ });
890
+ let failures = 0;
891
+ for (const target of branchTargets) try {
892
+ await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch);
893
+ } catch (error) {
894
+ failures += 1;
895
+ branchError = `${target.repo === "" ? "根仓库" : target.repo}:${error instanceof Error ? error.message : String(error)}`;
896
+ }
897
+ branchDeleted = failures === 0;
762
898
  }
763
899
  json(res, {
764
900
  ok: true,
@@ -792,7 +928,14 @@ function registerTaskboardRoutes(ctx, options) {
792
928
  const path = worktreePathOf(ws.path, taskId);
793
929
  if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
794
930
  try {
795
- if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
931
+ await removeMirror({
932
+ git: options.git,
933
+ scanner: options.scanner ?? createRepoScanner()
934
+ }, {
935
+ workspacePath: ws.path,
936
+ taskId
937
+ });
938
+ await rm(path, {
796
939
  recursive: true,
797
940
  force: true
798
941
  });