dsh-taskboard 0.6.2 → 0.6.4

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