get-tbd 0.1.30 → 0.2.0
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/README.md +5 -1
- package/dist/bin.mjs +2823 -2226
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +1063 -665
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-DVap9omo.mjs → config-BJz1m9eN.mjs} +179 -39
- package/dist/config-BJz1m9eN.mjs.map +1 -0
- package/dist/{config-BPHcePSm.mjs → config-DlCUMyCG.mjs} +1 -1
- package/dist/docs/README.md +5 -1
- package/dist/docs/guidelines/backward-compatibility-rules.md +4 -0
- package/dist/docs/guidelines/bun-monorepo-patterns.md +20 -4
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +38 -34
- package/dist/docs/guidelines/commit-conventions.md +4 -0
- package/dist/docs/guidelines/common-doc-guidelines.md +234 -0
- package/dist/docs/guidelines/convex-limits-best-practices.md +4 -0
- package/dist/docs/guidelines/convex-rules.md +4 -0
- package/dist/docs/guidelines/electron-app-development-patterns.md +4 -0
- package/dist/docs/guidelines/error-handling-rules.md +4 -0
- package/dist/docs/guidelines/general-coding-rules.md +4 -0
- package/dist/docs/guidelines/general-comment-rules.md +4 -0
- package/dist/docs/guidelines/general-eng-assistant-rules.md +4 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +4 -0
- package/dist/docs/guidelines/general-testing-rules.md +4 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +4 -0
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +27 -6
- package/dist/docs/guidelines/python-cli-patterns.md +4 -0
- package/dist/docs/guidelines/python-modern-guidelines.md +30 -0
- package/dist/docs/guidelines/python-rules.md +4 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +4 -0
- package/dist/docs/guidelines/supply-chain-hardening.md +11 -7
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +10 -4
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +27 -24
- package/dist/docs/guidelines/typescript-code-coverage.md +11 -7
- package/dist/docs/guidelines/typescript-rules.md +10 -6
- package/dist/docs/guidelines/typescript-sorting-patterns.md +4 -0
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +7 -3
- package/dist/docs/shortcuts/standard/agent-handoff.md +4 -0
- package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
- package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
- package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
- package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
- package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
- package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
- package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
- package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
- package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
- package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
- package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
- package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
- package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
- package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
- package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
- package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
- package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
- package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
- package/dist/docs/shortcuts/standard/review-code.md +4 -0
- package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
- package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
- package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
- package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
- package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
- package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
- package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
- package/dist/docs/tbd-closing.md +4 -0
- package/dist/docs/tbd-design.md +109 -68
- package/dist/docs/tbd-docs.md +20 -13
- package/dist/docs/tbd-prime.md +4 -0
- package/dist/docs/templates/architecture-doc.md +4 -0
- package/dist/docs/templates/plan-spec.md +4 -0
- package/dist/docs/templates/qa-playbook.md +4 -0
- package/dist/docs/templates/research-brief.md +4 -0
- package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-CFoPVinz.mjs} +1 -1
- package/dist/{id-mapping-CqrrLgeX.mjs → id-mapping-CtfTfGIh.mjs} +146 -122
- package/dist/id-mapping-CtfTfGIh.mjs.map +1 -0
- package/dist/index.d.mts +53 -1
- package/dist/index.mjs +3 -3
- package/dist/{schemas-C8mOQykE.mjs → schemas-f0EcuAVu.mjs} +40 -3
- package/dist/schemas-f0EcuAVu.mjs.map +1 -0
- package/dist/{src-BK_EF6mk.mjs → src-rIE4xSVs.mjs} +3 -3
- package/dist/src-rIE4xSVs.mjs.map +1 -0
- package/dist/tbd +2823 -2226
- package/package.json +1 -1
- package/dist/config-DVap9omo.mjs.map +0 -1
- package/dist/docs/guidelines/general-style-rules.md +0 -38
- package/dist/docs/guidelines/writing-style-guidelines.md +0 -42
- package/dist/id-mapping-CqrrLgeX.mjs.map +0 -1
- package/dist/schemas-C8mOQykE.mjs.map +0 -1
- package/dist/src-BK_EF6mk.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { S as IssueTitle,
|
|
2
|
-
import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-
|
|
1
|
+
import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
|
|
2
|
+
import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-rIE4xSVs.mjs";
|
|
3
3
|
import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
|
|
4
|
-
import { A as
|
|
5
|
-
import { _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-
|
|
4
|
+
import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
|
|
5
|
+
import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-CtfTfGIh.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError } from "zod";
|
|
8
8
|
import matter from "gray-matter";
|
|
@@ -12,7 +12,7 @@ import pc from "picocolors";
|
|
|
12
12
|
import { marked } from "marked";
|
|
13
13
|
import { markedTerminal } from "marked-terminal";
|
|
14
14
|
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
15
|
-
import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
|
|
15
|
+
import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink } from "node:fs/promises";
|
|
16
16
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { writeFile } from "atomically";
|
|
18
18
|
import { homedir } from "node:os";
|
|
@@ -620,28 +620,6 @@ var SyncError = class extends CLIError {
|
|
|
620
620
|
}
|
|
621
621
|
};
|
|
622
622
|
/**
|
|
623
|
-
* Worktree missing error - the data-sync-worktree directory doesn't exist.
|
|
624
|
-
* This indicates the worktree was never created or was deleted.
|
|
625
|
-
* See: tbd-design.md §2.3.6 Worktree Error Classes
|
|
626
|
-
*/
|
|
627
|
-
var WorktreeMissingError = class extends CLIError {
|
|
628
|
-
constructor(message = "Worktree not found at .tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.") {
|
|
629
|
-
super(message, 1);
|
|
630
|
-
this.name = "WorktreeMissingError";
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
/**
|
|
634
|
-
* Worktree corrupted error - the worktree exists but is invalid.
|
|
635
|
-
* This can occur when the .git file is missing or points to an invalid location.
|
|
636
|
-
* See: tbd-design.md §2.3.6 Worktree Error Classes
|
|
637
|
-
*/
|
|
638
|
-
var WorktreeCorruptedError = class extends CLIError {
|
|
639
|
-
constructor(message = "Worktree at .tbd/data-sync-worktree/ is corrupted. Run 'tbd doctor --fix' to repair.") {
|
|
640
|
-
super(message, 1);
|
|
641
|
-
this.name = "WorktreeCorruptedError";
|
|
642
|
-
}
|
|
643
|
-
};
|
|
644
|
-
/**
|
|
645
623
|
* Classify a sync error to determine appropriate recovery action.
|
|
646
624
|
*
|
|
647
625
|
* Used by `tbd sync` to decide whether to:
|
|
@@ -706,10 +684,7 @@ var BaseCommand = class {
|
|
|
706
684
|
try {
|
|
707
685
|
return await action();
|
|
708
686
|
} catch (error) {
|
|
709
|
-
if (error instanceof CLIError)
|
|
710
|
-
this.output.error(error.message);
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
687
|
+
if (error instanceof CLIError) throw error;
|
|
713
688
|
const originalError = error instanceof Error ? error : void 0;
|
|
714
689
|
const detail = originalError?.message;
|
|
715
690
|
const fullMessage = detail && detail !== errorMessage ? `${errorMessage}: ${detail}` : errorMessage;
|
|
@@ -740,7 +715,7 @@ var BaseCommand = class {
|
|
|
740
715
|
/**
|
|
741
716
|
* Check if a path exists on the filesystem.
|
|
742
717
|
*/
|
|
743
|
-
async function pathExists(path) {
|
|
718
|
+
async function pathExists$1(path) {
|
|
744
719
|
try {
|
|
745
720
|
await access(path);
|
|
746
721
|
return true;
|
|
@@ -780,7 +755,7 @@ function hasGitignorePattern(content, pattern) {
|
|
|
780
755
|
async function ensureGitignorePatterns(gitignorePath, patterns, header) {
|
|
781
756
|
let content = "";
|
|
782
757
|
let created = false;
|
|
783
|
-
if (await pathExists(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
758
|
+
if (await pathExists$1(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
784
759
|
else created = true;
|
|
785
760
|
const entries = [];
|
|
786
761
|
let currentPreamble = [];
|
|
@@ -974,6 +949,18 @@ async function git(...args) {
|
|
|
974
949
|
return stdout.trim();
|
|
975
950
|
}
|
|
976
951
|
/**
|
|
952
|
+
* Run `git commit` in a worktree with gpg signing disabled at the command level.
|
|
953
|
+
*
|
|
954
|
+
* Internal tbd-sync commits are machine-generated data commits on the data branch,
|
|
955
|
+
* not user commits. They must not depend on ambient `commit.gpgsign` config: in
|
|
956
|
+
* signed-by-default environments without a usable signing key, an unguarded
|
|
957
|
+
* `git commit` fails and leaves `tbd-sync` unborn, which the f04 fail-closed
|
|
958
|
+
* health check then surfaces as "worktree corrupted" on every command.
|
|
959
|
+
*/
|
|
960
|
+
async function gitCommit(workdir, ...args) {
|
|
961
|
+
return git("-c", "commit.gpgsign=false", "-C", workdir, "commit", ...args);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
977
964
|
* Minimum Git version required.
|
|
978
965
|
* Git 2.42 (August 2023) introduced `git worktree add --orphan` which tbd requires.
|
|
979
966
|
*/
|
|
@@ -1088,23 +1075,6 @@ function getUpgradeInstructions(currentVersion) {
|
|
|
1088
1075
|
return `Git ${versionStr} detected. Git ${MIN_GIT_VERSION}+ required for tbd.\nUpgrade: ${upgradeUrl}`;
|
|
1089
1076
|
}
|
|
1090
1077
|
/**
|
|
1091
|
-
* Execute a git command with isolated index.
|
|
1092
|
-
* This protects the user's staging area during sync operations.
|
|
1093
|
-
*
|
|
1094
|
-
* See: tbd-design.md §3.3.2 Writing to Sync Branch
|
|
1095
|
-
*/
|
|
1096
|
-
async function withIsolatedIndex(fn) {
|
|
1097
|
-
const isolatedIndex = join(await git("rev-parse", "--git-dir"), "tbd-index");
|
|
1098
|
-
const originalIndex = process.env.GIT_INDEX_FILE;
|
|
1099
|
-
try {
|
|
1100
|
-
process.env.GIT_INDEX_FILE = isolatedIndex;
|
|
1101
|
-
return await fn();
|
|
1102
|
-
} finally {
|
|
1103
|
-
if (originalIndex) process.env.GIT_INDEX_FILE = originalIndex;
|
|
1104
|
-
else delete process.env.GIT_INDEX_FILE;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
/**
|
|
1108
1078
|
* Field-level merge strategies for Issue fields.
|
|
1109
1079
|
* See: tbd-design.md §3.5 Merge Rules
|
|
1110
1080
|
*/
|
|
@@ -1330,15 +1300,15 @@ async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
|
|
|
1330
1300
|
/**
|
|
1331
1301
|
* Get the current branch name.
|
|
1332
1302
|
*/
|
|
1333
|
-
async function getCurrentBranch() {
|
|
1334
|
-
return git("rev-parse", "--abbrev-ref", "HEAD");
|
|
1303
|
+
async function getCurrentBranch(baseDir) {
|
|
1304
|
+
return git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--abbrev-ref", "HEAD");
|
|
1335
1305
|
}
|
|
1336
1306
|
/**
|
|
1337
1307
|
* Check if a branch exists locally.
|
|
1338
1308
|
*/
|
|
1339
|
-
async function branchExists(branch) {
|
|
1309
|
+
async function branchExists(branch, baseDir) {
|
|
1340
1310
|
try {
|
|
1341
|
-
await git("rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1311
|
+
await git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1342
1312
|
return true;
|
|
1343
1313
|
} catch {
|
|
1344
1314
|
return false;
|
|
@@ -1347,19 +1317,38 @@ async function branchExists(branch) {
|
|
|
1347
1317
|
/**
|
|
1348
1318
|
* Check if a remote branch exists.
|
|
1349
1319
|
*/
|
|
1350
|
-
async function remoteBranchExists(remote, branch) {
|
|
1320
|
+
async function remoteBranchExists(remote, branch, baseDir) {
|
|
1321
|
+
try {
|
|
1322
|
+
await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1323
|
+
return true;
|
|
1324
|
+
} catch {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
async function pathExists(path) {
|
|
1351
1329
|
try {
|
|
1352
|
-
await
|
|
1330
|
+
await access(path);
|
|
1353
1331
|
return true;
|
|
1354
1332
|
} catch {
|
|
1355
1333
|
return false;
|
|
1356
1334
|
}
|
|
1357
1335
|
}
|
|
1336
|
+
async function pathsReferToSameLocation(a, b) {
|
|
1337
|
+
if (normalize(a) === normalize(b)) return true;
|
|
1338
|
+
try {
|
|
1339
|
+
return normalize(await realpath(a)) === normalize(await realpath(b));
|
|
1340
|
+
} catch {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
async function getSharedPaths(baseDir) {
|
|
1345
|
+
return resolveSharedTbdPaths(baseDir);
|
|
1346
|
+
}
|
|
1358
1347
|
/**
|
|
1359
1348
|
* Check if the hidden worktree exists and is valid.
|
|
1360
1349
|
*/
|
|
1361
1350
|
async function worktreeExists(baseDir) {
|
|
1362
|
-
const worktreePath =
|
|
1351
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1363
1352
|
try {
|
|
1364
1353
|
await access(worktreePath);
|
|
1365
1354
|
await access(join(worktreePath, ".git"));
|
|
@@ -1373,15 +1362,15 @@ async function worktreeExists(baseDir) {
|
|
|
1373
1362
|
* See: tbd-design.md §2.3 Worktree Lifecycle
|
|
1374
1363
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §3
|
|
1375
1364
|
*/
|
|
1376
|
-
async function checkWorktreeHealth(baseDir) {
|
|
1377
|
-
const worktreePath =
|
|
1365
|
+
async function checkWorktreeHealth(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1366
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1378
1367
|
try {
|
|
1379
1368
|
const lines = (await git("-C", baseDir, "worktree", "list", "--porcelain")).split("\n");
|
|
1380
1369
|
let foundWorktree = false;
|
|
1381
1370
|
let isPrunable = false;
|
|
1382
1371
|
for (let i = 0; i < lines.length; i++) {
|
|
1383
1372
|
const line = lines[i];
|
|
1384
|
-
if (line?.startsWith("worktree ") && line.
|
|
1373
|
+
if (line?.startsWith("worktree ") && await pathsReferToSameLocation(line.slice(9), worktreePath)) {
|
|
1385
1374
|
foundWorktree = true;
|
|
1386
1375
|
for (let j = i + 1; j < lines.length && !lines[j]?.startsWith("worktree "); j++) if (lines[j]?.startsWith("prunable")) {
|
|
1387
1376
|
isPrunable = true;
|
|
@@ -1449,6 +1438,14 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1449
1438
|
} catch {
|
|
1450
1439
|
branch = null;
|
|
1451
1440
|
}
|
|
1441
|
+
if (branch !== syncBranch) return {
|
|
1442
|
+
exists: true,
|
|
1443
|
+
valid: false,
|
|
1444
|
+
status: "corrupted",
|
|
1445
|
+
branch,
|
|
1446
|
+
commit,
|
|
1447
|
+
error: branch === null ? `Shared worktree is detached; expected branch ${syncBranch}` : `Shared worktree is on ${branch}; expected branch ${syncBranch}`
|
|
1448
|
+
};
|
|
1452
1449
|
return {
|
|
1453
1450
|
exists: true,
|
|
1454
1451
|
valid: true,
|
|
@@ -1467,22 +1464,110 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1467
1464
|
};
|
|
1468
1465
|
}
|
|
1469
1466
|
}
|
|
1467
|
+
function isLegacyDataSyncWorktreePath(path, sharedWorktreePath) {
|
|
1468
|
+
const normalized = normalize(path);
|
|
1469
|
+
if (normalized === normalize(sharedWorktreePath)) return false;
|
|
1470
|
+
return basename(normalized) === WORKTREE_DIR_NAME && basename(dirname(normalized)) === TBD_DIR;
|
|
1471
|
+
}
|
|
1472
|
+
async function listLegacyWorktreePaths(baseDir, sharedWorktreePath) {
|
|
1473
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1474
|
+
try {
|
|
1475
|
+
const worktreeList = await git("-C", baseDir, "worktree", "list", "--porcelain");
|
|
1476
|
+
for (const line of worktreeList.split("\n")) {
|
|
1477
|
+
if (!line.startsWith("worktree ")) continue;
|
|
1478
|
+
const worktreePath = line.slice(9);
|
|
1479
|
+
if (isLegacyDataSyncWorktreePath(worktreePath, sharedWorktreePath)) paths.add(worktreePath);
|
|
1480
|
+
}
|
|
1481
|
+
} catch {}
|
|
1482
|
+
const currentCheckoutLegacyPath = join(baseDir, LEGACY_WORKTREE_DIR);
|
|
1483
|
+
if (currentCheckoutLegacyPath !== sharedWorktreePath && await pathExists(currentCheckoutLegacyPath)) paths.add(currentCheckoutLegacyPath);
|
|
1484
|
+
return Array.from(paths);
|
|
1485
|
+
}
|
|
1486
|
+
async function preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch) {
|
|
1487
|
+
if (!await pathExists(join(legacyPath, ".git"))) return;
|
|
1488
|
+
if ((await git("-C", legacyPath, "status", "--porcelain").catch(() => "")).trim()) {
|
|
1489
|
+
await git("-C", legacyPath, "add", "-A");
|
|
1490
|
+
await gitCommit(legacyPath, "--no-verify", "-m", "tbd: preserve legacy sync data").catch((error) => {
|
|
1491
|
+
if (!(error instanceof Error ? error.message : String(error)).includes("nothing to commit")) throw error;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
const head = await git("-C", legacyPath, "rev-parse", "HEAD").catch(() => "");
|
|
1495
|
+
if (!head) return;
|
|
1496
|
+
const branchHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1497
|
+
if (!branchHead) {
|
|
1498
|
+
await git("-C", baseDir, "branch", syncBranch, head);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (head === branchHead) return;
|
|
1502
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", branchHead, head).then(() => true).catch(() => false)) {
|
|
1503
|
+
await git("-C", baseDir, "update-ref", `refs/heads/${syncBranch}`, head);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", head, branchHead).then(() => true).catch(() => false)) return;
|
|
1507
|
+
const backupBranch = `tbd-legacy-preserve-${nowFilenameTimestamp()}`;
|
|
1508
|
+
await git("-C", baseDir, "branch", backupBranch, head);
|
|
1509
|
+
throw new Error(`Legacy sync worktree at ${legacyPath} diverges from ${syncBranch}. Preserved its HEAD as ${backupBranch}; run 'tbd doctor --fix' after reviewing the backup branch.`);
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Preserve and remove f03 per-checkout sync worktrees before creating the shared worktree.
|
|
1513
|
+
*/
|
|
1514
|
+
async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1515
|
+
const { sharedWorktreePath } = await getSharedPaths(baseDir);
|
|
1516
|
+
const legacyPaths = await listLegacyWorktreePaths(baseDir, sharedWorktreePath);
|
|
1517
|
+
let migrated = 0;
|
|
1518
|
+
try {
|
|
1519
|
+
for (const legacyPath of legacyPaths) {
|
|
1520
|
+
await preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch);
|
|
1521
|
+
try {
|
|
1522
|
+
await git("-C", baseDir, "worktree", "remove", legacyPath, "--force");
|
|
1523
|
+
} catch {
|
|
1524
|
+
await rm(legacyPath, {
|
|
1525
|
+
recursive: true,
|
|
1526
|
+
force: true
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
migrated += 1;
|
|
1530
|
+
}
|
|
1531
|
+
if (legacyPaths.length > 0) await git("-C", baseDir, "worktree", "prune");
|
|
1532
|
+
return {
|
|
1533
|
+
success: true,
|
|
1534
|
+
migrated
|
|
1535
|
+
};
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
return {
|
|
1538
|
+
success: false,
|
|
1539
|
+
migrated,
|
|
1540
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1470
1544
|
/**
|
|
1471
1545
|
* Initialize the hidden worktree for the tbd-sync branch.
|
|
1472
1546
|
* Follows the decision tree from tbd-design.md §2.3.
|
|
1473
1547
|
*
|
|
1548
|
+
* MUST be called while holding `withSharedDataSyncLock` — it migrates legacy
|
|
1549
|
+
* per-checkout worktrees and creates the shared attached worktree on tbd-sync,
|
|
1550
|
+
* so concurrent callers can otherwise race branch ownership and migration.
|
|
1551
|
+
*
|
|
1474
1552
|
* @param baseDir - The base directory of the repository
|
|
1475
1553
|
* @param remote - The remote name (default: 'origin')
|
|
1476
1554
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1477
1555
|
* @returns Path to the worktree or error message
|
|
1478
1556
|
*/
|
|
1479
1557
|
async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1480
|
-
const
|
|
1558
|
+
const paths = await getSharedPaths(baseDir);
|
|
1559
|
+
const worktreePath = paths.sharedWorktreePath;
|
|
1481
1560
|
if (await worktreeExists(baseDir)) return {
|
|
1482
1561
|
success: true,
|
|
1483
1562
|
path: worktreePath,
|
|
1484
1563
|
created: false
|
|
1485
1564
|
};
|
|
1565
|
+
await mkdir(paths.sharedTbdDir, { recursive: true });
|
|
1566
|
+
const migrationResult = await migrateLegacyWorktreesToShared(baseDir, syncBranch);
|
|
1567
|
+
if (!migrationResult.success) return {
|
|
1568
|
+
success: false,
|
|
1569
|
+
error: migrationResult.error
|
|
1570
|
+
};
|
|
1486
1571
|
try {
|
|
1487
1572
|
await rm(worktreePath, {
|
|
1488
1573
|
recursive: true,
|
|
@@ -1490,7 +1575,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1490
1575
|
});
|
|
1491
1576
|
} catch {}
|
|
1492
1577
|
try {
|
|
1493
|
-
if (await branchExists(syncBranch)) {
|
|
1578
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
1494
1579
|
await git("-C", baseDir, "worktree", "add", worktreePath, syncBranch);
|
|
1495
1580
|
return {
|
|
1496
1581
|
success: true,
|
|
@@ -1498,7 +1583,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1498
1583
|
created: true
|
|
1499
1584
|
};
|
|
1500
1585
|
}
|
|
1501
|
-
if (await remoteBranchExists(remote, syncBranch)) {
|
|
1586
|
+
if (await remoteBranchExists(remote, syncBranch, baseDir)) {
|
|
1502
1587
|
await git("-C", baseDir, "fetch", remote, syncBranch);
|
|
1503
1588
|
await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
|
|
1504
1589
|
return {
|
|
@@ -1513,12 +1598,12 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1513
1598
|
await mkdir(join(dataSyncPath, "issues"), { recursive: true });
|
|
1514
1599
|
await mkdir(join(dataSyncPath, "mappings"), { recursive: true });
|
|
1515
1600
|
await mkdir(join(dataSyncPath, "attic", "conflicts"), { recursive: true });
|
|
1516
|
-
await writeFile(join(dataSyncPath, "meta.yml"),
|
|
1601
|
+
await writeFile(join(dataSyncPath, "meta.yml"), `schema_version: ${DATA_SYNC_SCHEMA_VERSION}\n`);
|
|
1517
1602
|
await writeFile(join(dataSyncPath, "issues", ".gitkeep"), "");
|
|
1518
1603
|
await writeFile(join(dataSyncPath, "mappings", ".gitkeep"), "");
|
|
1519
1604
|
await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
|
|
1520
1605
|
await git("-C", worktreePath, "add", ".");
|
|
1521
|
-
await
|
|
1606
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
|
|
1522
1607
|
return {
|
|
1523
1608
|
success: true,
|
|
1524
1609
|
path: worktreePath,
|
|
@@ -1538,16 +1623,17 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1538
1623
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1539
1624
|
* @returns Health status indicating if branch exists and has commits
|
|
1540
1625
|
*/
|
|
1541
|
-
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
1626
|
+
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH, baseDir) {
|
|
1627
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1542
1628
|
try {
|
|
1543
1629
|
return {
|
|
1544
1630
|
exists: true,
|
|
1545
1631
|
orphaned: false,
|
|
1546
|
-
head: (await git("rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1632
|
+
head: (await git(...dirArgs, "rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1547
1633
|
};
|
|
1548
1634
|
} catch {
|
|
1549
1635
|
try {
|
|
1550
|
-
await git("show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1636
|
+
await git(...dirArgs, "show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1551
1637
|
return {
|
|
1552
1638
|
exists: true,
|
|
1553
1639
|
orphaned: true
|
|
@@ -1568,14 +1654,15 @@ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
|
1568
1654
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1569
1655
|
* @returns Health status indicating if remote branch exists and divergence state
|
|
1570
1656
|
*/
|
|
1571
|
-
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1657
|
+
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
1658
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1572
1659
|
try {
|
|
1573
|
-
await git("fetch", remote, syncBranch);
|
|
1574
|
-
const remoteHead = (await git("rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1660
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1661
|
+
const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1575
1662
|
let diverged = false;
|
|
1576
1663
|
try {
|
|
1577
|
-
const mergeBase = await git("merge-base", syncBranch, `${remote}/${syncBranch}`);
|
|
1578
|
-
const localHead = await git("rev-parse", syncBranch);
|
|
1664
|
+
const mergeBase = await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`);
|
|
1665
|
+
const localHead = await git(...dirArgs, "rev-parse", syncBranch);
|
|
1579
1666
|
diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
|
|
1580
1667
|
} catch {
|
|
1581
1668
|
diverged = false;
|
|
@@ -1602,7 +1689,8 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
|
|
|
1602
1689
|
* @returns Consistency status with HEAD comparisons and ahead/behind counts
|
|
1603
1690
|
*/
|
|
1604
1691
|
async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote = "origin") {
|
|
1605
|
-
const
|
|
1692
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1693
|
+
const worktreeHead = await git("-C", worktreePath, "rev-parse", "HEAD").catch(() => "");
|
|
1606
1694
|
const localHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1607
1695
|
const remoteHead = await git("-C", baseDir, "rev-parse", `${remote}/${syncBranch}`).catch(() => "");
|
|
1608
1696
|
let localAhead = 0;
|
|
@@ -1634,10 +1722,12 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
1634
1722
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1635
1723
|
* @returns Number of issue files on the remote branch, or null if branch doesn't exist
|
|
1636
1724
|
*/
|
|
1637
|
-
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1725
|
+
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
1726
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1638
1727
|
try {
|
|
1639
|
-
await git("fetch", remote, syncBranch);
|
|
1640
|
-
const
|
|
1728
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1729
|
+
const remoteBranch = `${remote}/${syncBranch}`;
|
|
1730
|
+
const output = await git(...dirArgs, "ls-tree", "-r", "--name-only", remoteBranch);
|
|
1641
1731
|
const issuesDir = `${TBD_DIR}/${DATA_SYNC_DIR_NAME}/issues/`;
|
|
1642
1732
|
return output.split("\n").filter(Boolean).filter((line) => line.startsWith(issuesDir) && line.endsWith(".md")).length;
|
|
1643
1733
|
} catch {
|
|
@@ -1652,6 +1742,10 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1652
1742
|
* - CORRUPTED: backup to .tbd/backups/, remove, then recreate
|
|
1653
1743
|
* - MISSING: just create
|
|
1654
1744
|
*
|
|
1745
|
+
* MUST be called while holding `withSharedDataSyncLock` — repair mutates
|
|
1746
|
+
* shared worktree and branch state and shares the same locking contract as
|
|
1747
|
+
* `initWorktree`.
|
|
1748
|
+
*
|
|
1655
1749
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
|
|
1656
1750
|
*
|
|
1657
1751
|
* @param baseDir - The base directory of the repository
|
|
@@ -1660,13 +1754,12 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1660
1754
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1661
1755
|
*/
|
|
1662
1756
|
async function repairWorktree(baseDir, status, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1663
|
-
const worktreePath =
|
|
1757
|
+
const { sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1664
1758
|
try {
|
|
1665
1759
|
if (status === "missing" || status === "prunable") await git("-C", baseDir, "worktree", "prune");
|
|
1666
1760
|
if (status === "corrupted") {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
const backupPath = join(backupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1761
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
1762
|
+
const backupPath = join(sharedBackupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1670
1763
|
try {
|
|
1671
1764
|
await cp(worktreePath, backupPath, { recursive: true });
|
|
1672
1765
|
} catch {}
|
|
@@ -1697,9 +1790,15 @@ async function repairWorktree(baseDir, status, remote = "origin", syncBranch = S
|
|
|
1697
1790
|
* @returns true if worktree was detached and repaired, false if already attached
|
|
1698
1791
|
*/
|
|
1699
1792
|
async function ensureWorktreeAttached(worktreePath) {
|
|
1793
|
+
return ensureWorktreeAttachedToBranch(worktreePath, SYNC_BRANCH);
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Ensure worktree is attached to the requested sync branch, not detached HEAD.
|
|
1797
|
+
*/
|
|
1798
|
+
async function ensureWorktreeAttachedToBranch(worktreePath, syncBranch = SYNC_BRANCH) {
|
|
1700
1799
|
try {
|
|
1701
|
-
if (
|
|
1702
|
-
await git("-C", worktreePath, "checkout",
|
|
1800
|
+
if (await git("-C", worktreePath, "branch", "--show-current").catch(() => "") !== syncBranch) {
|
|
1801
|
+
await git("-C", worktreePath, "checkout", syncBranch);
|
|
1703
1802
|
return true;
|
|
1704
1803
|
}
|
|
1705
1804
|
return false;
|
|
@@ -1725,28 +1824,23 @@ async function ensureWorktreeAttached(worktreePath) {
|
|
|
1725
1824
|
*/
|
|
1726
1825
|
async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
1727
1826
|
const wrongPath = join(baseDir, TBD_DIR, DATA_SYNC_DIR_NAME);
|
|
1728
|
-
const
|
|
1729
|
-
const worktreePath = join(baseDir, WORKTREE_DIR);
|
|
1827
|
+
const { sharedDataSyncDir: correctPath, sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1730
1828
|
try {
|
|
1731
1829
|
await ensureWorktreeAttached(worktreePath);
|
|
1732
1830
|
const wrongIssuesPath = join(wrongPath, "issues");
|
|
1733
1831
|
const wrongMappingsPath = join(wrongPath, "mappings");
|
|
1734
1832
|
let issueFiles = [];
|
|
1735
1833
|
let mappingFiles = [];
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
1739
|
-
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1740
|
-
} catch {}
|
|
1834
|
+
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
1835
|
+
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1741
1836
|
issueFiles = issueFiles.filter((f) => f !== ".gitkeep");
|
|
1742
1837
|
mappingFiles = mappingFiles.filter((f) => f !== ".gitkeep");
|
|
1743
1838
|
if (issueFiles.length === 0 && mappingFiles.length === 0) return {
|
|
1744
1839
|
success: true,
|
|
1745
1840
|
migratedCount: 0
|
|
1746
1841
|
};
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const backupPath = join(backupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1842
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
1843
|
+
const backupPath = join(sharedBackupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1750
1844
|
await cp(wrongPath, backupPath, { recursive: true });
|
|
1751
1845
|
const correctIssuesPath = join(correctPath, "issues");
|
|
1752
1846
|
const correctMappingsPath = join(correctPath, "mappings");
|
|
@@ -1754,8 +1848,6 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1754
1848
|
await mkdir(correctMappingsPath, { recursive: true });
|
|
1755
1849
|
for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
|
|
1756
1850
|
for (const file of mappingFiles) if (file === "ids.yml") {
|
|
1757
|
-
const { readFile } = await import("node:fs/promises");
|
|
1758
|
-
const { loadIdMapping, mergeIdMappings, saveIdMapping, resolveIdMappingConflicts } = await import("./id-mapping-Ctfl_nc1.mjs");
|
|
1759
1851
|
const sourceMapping = resolveIdMappingConflicts(await readFile(join(wrongMappingsPath, file), "utf-8"));
|
|
1760
1852
|
let targetMapping;
|
|
1761
1853
|
try {
|
|
@@ -1767,7 +1859,7 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1767
1859
|
} else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
|
|
1768
1860
|
const totalFiles = issueFiles.length + mappingFiles.length;
|
|
1769
1861
|
await git("-C", worktreePath, "add", "-A");
|
|
1770
|
-
if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await
|
|
1862
|
+
if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await gitCommit(worktreePath, "--no-verify", "-m", `tbd: migrate ${totalFiles} file(s) from incorrect location`);
|
|
1771
1863
|
if (removeSource) {
|
|
1772
1864
|
for (const file of issueFiles) await rm(join(wrongIssuesPath, file));
|
|
1773
1865
|
for (const file of mappingFiles) await rm(join(wrongMappingsPath, file));
|
|
@@ -1786,6 +1878,83 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1786
1878
|
}
|
|
1787
1879
|
}
|
|
1788
1880
|
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/file/common-dir-layout.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* Git common-dir layout metadata for shared issue sync machinery.
|
|
1885
|
+
*/
|
|
1886
|
+
/**
|
|
1887
|
+
* Error thrown when common-dir layout metadata cannot be used safely.
|
|
1888
|
+
*/
|
|
1889
|
+
var CommonDirLayoutError = class extends Error {
|
|
1890
|
+
constructor(message) {
|
|
1891
|
+
super(message);
|
|
1892
|
+
this.name = "CommonDirLayoutError";
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
/**
|
|
1896
|
+
* Read $GIT_COMMON_DIR/tbd/layout.yml, returning null when it has not been created yet.
|
|
1897
|
+
*/
|
|
1898
|
+
async function readCommonDirLayout(layoutPath) {
|
|
1899
|
+
try {
|
|
1900
|
+
const content = await readFile(layoutPath, "utf-8");
|
|
1901
|
+
return CommonDirLayoutSchema.parse(parse(content));
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
if (error.code === "ENOENT") return null;
|
|
1904
|
+
throw new CommonDirLayoutError(`Invalid tbd common-dir layout metadata at ${layoutPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Validate that common-dir layout metadata matches the checkout config.
|
|
1909
|
+
*/
|
|
1910
|
+
function validateCommonDirLayout(layout, config) {
|
|
1911
|
+
if (!isCompatibleFormat(layout.tbd_format)) throw new CommonDirLayoutError(formatUpgradeMessage("Common-dir layout", layout.tbd_format, CURRENT_FORMAT));
|
|
1912
|
+
if (layout.tbd_format !== config.tbd_format) throw new CommonDirLayoutError(`Common-dir layout format '${layout.tbd_format}' does not match config format '${config.tbd_format}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
|
|
1913
|
+
const layoutStorage = layout.sync_storage;
|
|
1914
|
+
const configStorage = config.sync.storage;
|
|
1915
|
+
if (layoutStorage !== configStorage) throw new CommonDirLayoutError(`Common-dir sync storage '${layoutStorage}' does not match config storage '${configStorage}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Write common-dir layout metadata using the synchronized tbd_format ID.
|
|
1919
|
+
*/
|
|
1920
|
+
async function writeCommonDirLayout(paths, config, existing) {
|
|
1921
|
+
await mkdir(dirname(paths.sharedLayoutPath), { recursive: true });
|
|
1922
|
+
const timestamp = now();
|
|
1923
|
+
const layout = CommonDirLayoutSchema.parse({
|
|
1924
|
+
tbd_format: config.tbd_format,
|
|
1925
|
+
sync_storage: config.sync.storage,
|
|
1926
|
+
data_sync_worktree: "data-sync-worktree",
|
|
1927
|
+
lock_profile: "data-sync-v1",
|
|
1928
|
+
created_at: existing?.created_at ?? timestamp,
|
|
1929
|
+
updated_at: timestamp
|
|
1930
|
+
});
|
|
1931
|
+
const yaml = stringifyYaml(sortKeys(layout, COMMON_DIR_LAYOUT_FIELD_ORDER), {
|
|
1932
|
+
lineWidth: 0,
|
|
1933
|
+
sortMapEntries: false
|
|
1934
|
+
});
|
|
1935
|
+
await writeFile(paths.sharedLayoutPath, yaml);
|
|
1936
|
+
return layout;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Ensure layout metadata exists and matches the checkout config.
|
|
1940
|
+
*/
|
|
1941
|
+
async function ensureCommonDirLayout(paths, config) {
|
|
1942
|
+
const existing = await readCommonDirLayout(paths.sharedLayoutPath);
|
|
1943
|
+
if (existing) {
|
|
1944
|
+
validateCommonDirLayout(existing, config);
|
|
1945
|
+
return existing;
|
|
1946
|
+
}
|
|
1947
|
+
return writeCommonDirLayout(paths, config);
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Run a critical section while holding the repo-scoped data-sync lock.
|
|
1951
|
+
*/
|
|
1952
|
+
async function withSharedDataSyncLock(tbdRoot, fn) {
|
|
1953
|
+
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
1954
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
1955
|
+
return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1789
1958
|
//#endregion
|
|
1790
1959
|
//#region src/cli/commands/init.ts
|
|
1791
1960
|
/**
|
|
@@ -1816,7 +1985,7 @@ Example:
|
|
|
1816
1985
|
tbd init --prefix=${prefix} --force`);
|
|
1817
1986
|
if (this.checkDryRun("Would initialize tbd repository", options)) return;
|
|
1818
1987
|
await this.execute(async () => {
|
|
1819
|
-
await initConfig(cwd, VERSION, options.prefix);
|
|
1988
|
+
const config = await initConfig(cwd, VERSION, options.prefix);
|
|
1820
1989
|
this.output.debug(`Created ${TBD_DIR}/config.yml with prefix '${options.prefix}'`);
|
|
1821
1990
|
await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
1822
1991
|
"# Installed documentation (regenerated on setup)",
|
|
@@ -1857,11 +2026,19 @@ Example:
|
|
|
1857
2026
|
if (error instanceof CLIError) throw error;
|
|
1858
2027
|
this.output.debug(`Git version check skipped: ${error.message}`);
|
|
1859
2028
|
}
|
|
1860
|
-
const worktreeResult = await
|
|
2029
|
+
const { worktreeResult, sharedPaths } = await withSharedDataSyncLock(cwd, async () => {
|
|
2030
|
+
const result = await initWorktree(cwd, remote, syncBranch);
|
|
2031
|
+
const paths = await resolveSharedTbdPaths(cwd);
|
|
2032
|
+
if (result.success) await writeCommonDirLayout(paths, config);
|
|
2033
|
+
return {
|
|
2034
|
+
worktreeResult: result,
|
|
2035
|
+
sharedPaths: paths
|
|
2036
|
+
};
|
|
2037
|
+
});
|
|
1861
2038
|
if (worktreeResult.success) {
|
|
1862
|
-
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${
|
|
1863
|
-
else this.output.debug(`Worktree already exists at ${
|
|
1864
|
-
const health = await checkWorktreeHealth(cwd);
|
|
2039
|
+
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${sharedPaths.sharedWorktreePath}`);
|
|
2040
|
+
else this.output.debug(`Worktree already exists at ${sharedPaths.sharedWorktreePath}`);
|
|
2041
|
+
const health = await checkWorktreeHealth(cwd, syncBranch);
|
|
1865
2042
|
if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
|
|
1866
2043
|
} else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
|
|
1867
2044
|
}, "Failed to initialize tbd");
|
|
@@ -1913,7 +2090,8 @@ function formatUnknownError(error) {
|
|
|
1913
2090
|
* Storage layer for issue files.
|
|
1914
2091
|
*
|
|
1915
2092
|
* Provides atomic file operations and issue CRUD operations.
|
|
1916
|
-
* All operations work on the
|
|
2093
|
+
* All operations work on the data-sync directory selected by the caller. In production
|
|
2094
|
+
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
1917
2095
|
*
|
|
1918
2096
|
* See: tbd-design.md §3.2 Storage Layer
|
|
1919
2097
|
*/
|
|
@@ -2208,6 +2386,152 @@ function validateIssueTitle(title, options) {
|
|
|
2208
2386
|
return title;
|
|
2209
2387
|
}
|
|
2210
2388
|
|
|
2389
|
+
//#endregion
|
|
2390
|
+
//#region src/cli/lib/data-context.ts
|
|
2391
|
+
async function probeDataSyncReadiness(tbdRoot) {
|
|
2392
|
+
const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
|
|
2393
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
|
|
2394
|
+
const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
|
|
2395
|
+
if (layout) validateCommonDirLayout(layout, config);
|
|
2396
|
+
const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
|
|
2397
|
+
return {
|
|
2398
|
+
config,
|
|
2399
|
+
migrated,
|
|
2400
|
+
fromFormat,
|
|
2401
|
+
sharedPaths,
|
|
2402
|
+
layout,
|
|
2403
|
+
health,
|
|
2404
|
+
ready: !migrated && layout !== null && health.valid
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Apply any pending first-use initialization, migration, or repair to the shared
|
|
2409
|
+
* data-sync layout. MUST be called while holding `withSharedDataSyncLock` so that
|
|
2410
|
+
* worktree repair, layout writes, and migrated-config writes are serialized.
|
|
2411
|
+
*/
|
|
2412
|
+
async function ensureSharedDataSyncLayout(tbdRoot, probe) {
|
|
2413
|
+
let repairedWorktreeStatus;
|
|
2414
|
+
if (!probe.health.valid) if (probe.health.status === "missing" || probe.health.status === "prunable") {
|
|
2415
|
+
const repairResult = await repairWorktree(tbdRoot, probe.health.status, probe.config.sync.remote, probe.config.sync.branch);
|
|
2416
|
+
if (!repairResult.success) throw new Error(`Failed to initialize shared data-sync worktree: ${repairResult.error}`);
|
|
2417
|
+
repairedWorktreeStatus = probe.health.status;
|
|
2418
|
+
} else throw new Error(`Shared data-sync worktree is ${probe.health.status}: ${probe.health.error ?? "unknown error"}. Run 'tbd doctor --fix' to repair.`);
|
|
2419
|
+
await ensureCommonDirLayout(probe.sharedPaths, probe.config);
|
|
2420
|
+
if (probe.migrated) {
|
|
2421
|
+
await writeConfig(tbdRoot, probe.config);
|
|
2422
|
+
notifyConfigMigrated(probe.fromFormat, CURRENT_FORMAT);
|
|
2423
|
+
}
|
|
2424
|
+
return repairedWorktreeStatus;
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Emit a one-time stderr notice when this checkout's `.tbd/config.yml` was migrated
|
|
2428
|
+
* (typically `fXX → fYY`). The config bump is the "publish" step of the format
|
|
2429
|
+
* migration and lands as a tracked diff on the current branch; users on a sibling
|
|
2430
|
+
* worktree (and even on main) deserve to know that without having to discover the
|
|
2431
|
+
* diff themselves later.
|
|
2432
|
+
*
|
|
2433
|
+
* See: docs/tbd-format-versioning.md (internal contributor guide) and
|
|
2434
|
+
* plan-2026-05-17-shared-common-dir-sync-worktree.md.
|
|
2435
|
+
*/
|
|
2436
|
+
function notifyConfigMigrated(fromFormat, toFormat) {
|
|
2437
|
+
if (fromFormat === toFormat) return;
|
|
2438
|
+
const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
|
|
2439
|
+
process.stderr.write(`• tbd_format ${arrow}: .tbd/config.yml updated in this checkout. Commit on this branch or merge main to publish the format upgrade.\n`);
|
|
2440
|
+
}
|
|
2441
|
+
async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
|
|
2442
|
+
const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
|
|
2443
|
+
return {
|
|
2444
|
+
dataSyncDir,
|
|
2445
|
+
mapping: await loadIdMapping(dataSyncDir),
|
|
2446
|
+
config: probe.config,
|
|
2447
|
+
prefix: probe.config.display.id_prefix,
|
|
2448
|
+
sharedPaths: probe.sharedPaths,
|
|
2449
|
+
repairedWorktreeStatus
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Load all common data context needed by tbd commands.
|
|
2454
|
+
*
|
|
2455
|
+
* For writers this is called inside `withSharedDataSyncLock` by
|
|
2456
|
+
* `withDataSyncContext({ lock: true }, ...)`, so any ensure/migrate/repair work
|
|
2457
|
+
* is serialized.
|
|
2458
|
+
*/
|
|
2459
|
+
async function prepareDataSyncContext(tbdRoot) {
|
|
2460
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2461
|
+
return assembleDataContext(tbdRoot, probe, probe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, probe));
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Prepare shared data-sync context, optionally holding the repo-scoped lock.
|
|
2465
|
+
*
|
|
2466
|
+
* - `{ lock: true }` (writers): always acquire the lock, then prepare under it.
|
|
2467
|
+
* - `{ lock: false }` (readers): probe first; only acquire the lock if first-use
|
|
2468
|
+
* init/migrate/repair is actually required. Steady-state reads take no lock.
|
|
2469
|
+
*/
|
|
2470
|
+
async function withDataSyncContext(tbdRoot, options, fn) {
|
|
2471
|
+
if (options.lock) return withSharedDataSyncLock(tbdRoot, async () => fn(await prepareDataSyncContext(tbdRoot)));
|
|
2472
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2473
|
+
if (probe.ready) return fn(await assembleDataContext(tbdRoot, probe));
|
|
2474
|
+
return withSharedDataSyncLock(tbdRoot, async () => {
|
|
2475
|
+
const reProbe = await probeDataSyncReadiness(tbdRoot);
|
|
2476
|
+
return fn(await assembleDataContext(tbdRoot, reProbe, reProbe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, reProbe)));
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Load the shared data-sync context for a read-only command.
|
|
2481
|
+
*
|
|
2482
|
+
* Read commands skip the shared lock when the layout and worktree are already
|
|
2483
|
+
* valid and the on-disk config needs no migration. When first-use
|
|
2484
|
+
* init/migrate/repair IS required, the underlying `withDataSyncContext` takes
|
|
2485
|
+
* the lock and runs the ensure path so concurrent readers cannot race
|
|
2486
|
+
* migration or worktree repair.
|
|
2487
|
+
*/
|
|
2488
|
+
async function loadDataContext(tbdRoot) {
|
|
2489
|
+
return withDataSyncContext(tbdRoot, { lock: false }, async (context) => context);
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Load unified command context with CLI options, data, and helper methods.
|
|
2493
|
+
*
|
|
2494
|
+
* This is the recommended way to initialize command context. It:
|
|
2495
|
+
* 1. Checks that tbd is initialized (calls requireInit)
|
|
2496
|
+
* 2. Loads data context (dataSyncDir, mapping, config, prefix)
|
|
2497
|
+
* 3. Extracts CLI context from Commander
|
|
2498
|
+
* 4. Provides helper methods like displayId() and resolveId()
|
|
2499
|
+
*
|
|
2500
|
+
* Usage:
|
|
2501
|
+
* ```ts
|
|
2502
|
+
* class MyHandler extends BaseCommand {
|
|
2503
|
+
* async run(id: string): Promise<void> {
|
|
2504
|
+
* const ctx = await loadFullContext(this.command);
|
|
2505
|
+
* const internalId = ctx.resolveId(id);
|
|
2506
|
+
* const issue = await readIssue(ctx.dataSyncDir, internalId);
|
|
2507
|
+
* console.log(ctx.displayId(issue.id));
|
|
2508
|
+
* }
|
|
2509
|
+
* }
|
|
2510
|
+
* ```
|
|
2511
|
+
*
|
|
2512
|
+
* @param command - The Commander command instance
|
|
2513
|
+
* @throws Error if tbd is not initialized or resources fail to load
|
|
2514
|
+
*/
|
|
2515
|
+
async function loadFullContext(command) {
|
|
2516
|
+
const tbdRoot = await requireInit();
|
|
2517
|
+
const cli = getCommandContext(command);
|
|
2518
|
+
const dataCtx = await loadDataContext(tbdRoot);
|
|
2519
|
+
return {
|
|
2520
|
+
...dataCtx,
|
|
2521
|
+
cli,
|
|
2522
|
+
displayId(internalId) {
|
|
2523
|
+
return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
|
|
2524
|
+
},
|
|
2525
|
+
resolveId(inputId) {
|
|
2526
|
+
try {
|
|
2527
|
+
return resolveToInternalId(inputId, dataCtx.mapping);
|
|
2528
|
+
} catch {
|
|
2529
|
+
throw new NotFoundError("Issue", inputId);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2211
2535
|
//#endregion
|
|
2212
2536
|
//#region src/cli/commands/create.ts
|
|
2213
2537
|
/**
|
|
@@ -2252,52 +2576,52 @@ var CreateHandler = class extends BaseCommand {
|
|
|
2252
2576
|
let prefix;
|
|
2253
2577
|
let issue;
|
|
2254
2578
|
await this.execute(async () => {
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
throw new ValidationError(`Invalid parent ID: ${options.parent}`);
|
|
2265
|
-
}
|
|
2266
|
-
if (!specPath && parentId) {
|
|
2267
|
-
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2268
|
-
if (parentIssue.spec_path) specPath = parentIssue.spec_path;
|
|
2269
|
-
}
|
|
2270
|
-
issue = {
|
|
2271
|
-
type: "is",
|
|
2272
|
-
id,
|
|
2273
|
-
version: 1,
|
|
2274
|
-
title: validatedTitle,
|
|
2275
|
-
kind,
|
|
2276
|
-
status: "open",
|
|
2277
|
-
priority,
|
|
2278
|
-
labels: options.label ?? [],
|
|
2279
|
-
dependencies: [],
|
|
2280
|
-
created_at: timestamp,
|
|
2281
|
-
updated_at: timestamp,
|
|
2282
|
-
description: description ?? void 0,
|
|
2283
|
-
assignee: options.assignee ?? void 0,
|
|
2284
|
-
due_date: options.due ?? void 0,
|
|
2285
|
-
deferred_until: options.defer ?? void 0,
|
|
2286
|
-
parent_id: parentId,
|
|
2287
|
-
spec_path: specPath
|
|
2288
|
-
};
|
|
2289
|
-
await writeIssue(dataSyncDir, issue);
|
|
2290
|
-
await saveIdMapping(dataSyncDir, mapping);
|
|
2291
|
-
if (parentId) try {
|
|
2292
|
-
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2293
|
-
const hints = parentIssue.child_order_hints ?? [];
|
|
2294
|
-
if (!hints.includes(id)) {
|
|
2295
|
-
parentIssue.child_order_hints = [...hints, id];
|
|
2296
|
-
parentIssue.version += 1;
|
|
2297
|
-
parentIssue.updated_at = timestamp;
|
|
2298
|
-
await writeIssue(dataSyncDir, parentIssue);
|
|
2579
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
|
|
2580
|
+
prefix = config.display.id_prefix;
|
|
2581
|
+
shortId = generateUniqueShortId(mapping);
|
|
2582
|
+
addIdMapping(mapping, ulid, shortId);
|
|
2583
|
+
let parentId;
|
|
2584
|
+
if (options.parent) try {
|
|
2585
|
+
parentId = resolveToInternalId(options.parent, mapping);
|
|
2586
|
+
} catch {
|
|
2587
|
+
throw new ValidationError(`Invalid parent ID: ${options.parent}`);
|
|
2299
2588
|
}
|
|
2300
|
-
|
|
2589
|
+
if (!specPath && parentId) {
|
|
2590
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2591
|
+
if (parentIssue.spec_path) specPath = parentIssue.spec_path;
|
|
2592
|
+
}
|
|
2593
|
+
issue = {
|
|
2594
|
+
type: "is",
|
|
2595
|
+
id,
|
|
2596
|
+
version: 1,
|
|
2597
|
+
title: validatedTitle,
|
|
2598
|
+
kind,
|
|
2599
|
+
status: "open",
|
|
2600
|
+
priority,
|
|
2601
|
+
labels: options.label ?? [],
|
|
2602
|
+
dependencies: [],
|
|
2603
|
+
created_at: timestamp,
|
|
2604
|
+
updated_at: timestamp,
|
|
2605
|
+
description: description ?? void 0,
|
|
2606
|
+
assignee: options.assignee ?? void 0,
|
|
2607
|
+
due_date: options.due ?? void 0,
|
|
2608
|
+
deferred_until: options.defer ?? void 0,
|
|
2609
|
+
parent_id: parentId,
|
|
2610
|
+
spec_path: specPath
|
|
2611
|
+
};
|
|
2612
|
+
await writeIssue(dataSyncDir, issue);
|
|
2613
|
+
await saveIdMapping(dataSyncDir, mapping);
|
|
2614
|
+
if (parentId) try {
|
|
2615
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2616
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
2617
|
+
if (!hints.includes(id)) {
|
|
2618
|
+
parentIssue.child_order_hints = [...hints, id];
|
|
2619
|
+
parentIssue.version += 1;
|
|
2620
|
+
parentIssue.updated_at = timestamp;
|
|
2621
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
2622
|
+
}
|
|
2623
|
+
} catch {}
|
|
2624
|
+
});
|
|
2301
2625
|
}, "Failed to create issue");
|
|
2302
2626
|
const displayId = `${prefix}-${shortId}`;
|
|
2303
2627
|
this.output.data({
|
|
@@ -2342,77 +2666,6 @@ function applyLimit(items, limitOption) {
|
|
|
2342
2666
|
return items.slice(0, limit);
|
|
2343
2667
|
}
|
|
2344
2668
|
|
|
2345
|
-
//#endregion
|
|
2346
|
-
//#region src/cli/lib/data-context.ts
|
|
2347
|
-
/**
|
|
2348
|
-
* Load all common data context needed by tbd commands.
|
|
2349
|
-
*
|
|
2350
|
-
* This loads:
|
|
2351
|
-
* - dataSyncDir from resolveDataSyncDir()
|
|
2352
|
-
* - mapping from loadIdMapping()
|
|
2353
|
-
* - config from readConfig()
|
|
2354
|
-
* - prefix from config.display.id_prefix
|
|
2355
|
-
*
|
|
2356
|
-
* Call this once at the start of a command handler instead of
|
|
2357
|
-
* loading each piece separately.
|
|
2358
|
-
*
|
|
2359
|
-
* @param tbdRoot - The tbd repository root directory (from requireInit or findTbdRoot)
|
|
2360
|
-
* @throws Error if any of the resources fail to load
|
|
2361
|
-
*/
|
|
2362
|
-
async function loadDataContext(tbdRoot) {
|
|
2363
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
2364
|
-
const [mapping, config] = await Promise.all([loadIdMapping(dataSyncDir), readConfig(tbdRoot)]);
|
|
2365
|
-
return {
|
|
2366
|
-
dataSyncDir,
|
|
2367
|
-
mapping,
|
|
2368
|
-
config,
|
|
2369
|
-
prefix: config.display.id_prefix
|
|
2370
|
-
};
|
|
2371
|
-
}
|
|
2372
|
-
/**
|
|
2373
|
-
* Load unified command context with CLI options, data, and helper methods.
|
|
2374
|
-
*
|
|
2375
|
-
* This is the recommended way to initialize command context. It:
|
|
2376
|
-
* 1. Checks that tbd is initialized (calls requireInit)
|
|
2377
|
-
* 2. Loads data context (dataSyncDir, mapping, config, prefix)
|
|
2378
|
-
* 3. Extracts CLI context from Commander
|
|
2379
|
-
* 4. Provides helper methods like displayId() and resolveId()
|
|
2380
|
-
*
|
|
2381
|
-
* Usage:
|
|
2382
|
-
* ```ts
|
|
2383
|
-
* class MyHandler extends BaseCommand {
|
|
2384
|
-
* async run(id: string): Promise<void> {
|
|
2385
|
-
* const ctx = await loadFullContext(this.command);
|
|
2386
|
-
* const internalId = ctx.resolveId(id);
|
|
2387
|
-
* const issue = await readIssue(ctx.dataSyncDir, internalId);
|
|
2388
|
-
* console.log(ctx.displayId(issue.id));
|
|
2389
|
-
* }
|
|
2390
|
-
* }
|
|
2391
|
-
* ```
|
|
2392
|
-
*
|
|
2393
|
-
* @param command - The Commander command instance
|
|
2394
|
-
* @throws Error if tbd is not initialized or resources fail to load
|
|
2395
|
-
*/
|
|
2396
|
-
async function loadFullContext(command) {
|
|
2397
|
-
const tbdRoot = await requireInit();
|
|
2398
|
-
const cli = getCommandContext(command);
|
|
2399
|
-
const dataCtx = await loadDataContext(tbdRoot);
|
|
2400
|
-
return {
|
|
2401
|
-
...dataCtx,
|
|
2402
|
-
cli,
|
|
2403
|
-
displayId(internalId) {
|
|
2404
|
-
return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
|
|
2405
|
-
},
|
|
2406
|
-
resolveId(inputId) {
|
|
2407
|
-
try {
|
|
2408
|
-
return resolveToInternalId(inputId, dataCtx.mapping);
|
|
2409
|
-
} catch {
|
|
2410
|
-
throw new NotFoundError("Issue", inputId);
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
};
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
2669
|
//#endregion
|
|
2417
2670
|
//#region src/lib/status.ts
|
|
2418
2671
|
/**
|
|
@@ -3060,81 +3313,83 @@ const showCommand = new Command("show").description("Show issue details").argume
|
|
|
3060
3313
|
var UpdateHandler = class extends BaseCommand {
|
|
3061
3314
|
async run(id, options) {
|
|
3062
3315
|
const tbdRoot = await requireInit();
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
let internalId;
|
|
3066
|
-
try {
|
|
3067
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3068
|
-
} catch {
|
|
3069
|
-
throw new NotFoundError("Issue", id);
|
|
3070
|
-
}
|
|
3071
|
-
let issue;
|
|
3072
|
-
try {
|
|
3073
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3074
|
-
} catch {
|
|
3075
|
-
throw new NotFoundError("Issue", id);
|
|
3076
|
-
}
|
|
3077
|
-
const updates = await this.parseUpdates(options, mapping, tbdRoot);
|
|
3078
|
-
if (updates === null) return;
|
|
3079
|
-
if (this.checkDryRun("Would update issue", {
|
|
3080
|
-
id: internalId,
|
|
3081
|
-
...updates
|
|
3082
|
-
})) return;
|
|
3083
|
-
const oldSpecPath = issue.spec_path;
|
|
3084
|
-
if (updates.title !== void 0) issue.title = updates.title;
|
|
3085
|
-
if (updates.status !== void 0) issue.status = updates.status;
|
|
3086
|
-
if (updates.kind !== void 0) issue.kind = updates.kind;
|
|
3087
|
-
if (updates.priority !== void 0) issue.priority = updates.priority;
|
|
3088
|
-
if (updates.assignee !== void 0) issue.assignee = updates.assignee;
|
|
3089
|
-
if (updates.description !== void 0) issue.description = updates.description;
|
|
3090
|
-
if (updates.notes !== void 0) issue.notes = updates.notes;
|
|
3091
|
-
if (updates.due_date !== void 0) issue.due_date = updates.due_date;
|
|
3092
|
-
if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
|
|
3093
|
-
if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
|
|
3094
|
-
if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
|
|
3095
|
-
if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
|
|
3096
|
-
if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
|
|
3097
|
-
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3098
|
-
if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
|
|
3099
|
-
} catch {}
|
|
3100
|
-
if (updates.labels !== void 0) issue.labels = updates.labels;
|
|
3101
|
-
if (updates.addLabels && updates.addLabels.length > 0) {
|
|
3102
|
-
const labelsSet = new Set(issue.labels);
|
|
3103
|
-
for (const label of updates.addLabels) labelsSet.add(label);
|
|
3104
|
-
issue.labels = [...labelsSet];
|
|
3105
|
-
}
|
|
3106
|
-
if (updates.removeLabels && updates.removeLabels.length > 0) {
|
|
3107
|
-
const removeSet = new Set(updates.removeLabels);
|
|
3108
|
-
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3109
|
-
}
|
|
3110
|
-
issue.version += 1;
|
|
3111
|
-
issue.updated_at = now();
|
|
3316
|
+
let displayId = id;
|
|
3317
|
+
let didUpdate = false;
|
|
3112
3318
|
await this.execute(async () => {
|
|
3113
|
-
await
|
|
3319
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3320
|
+
let internalId;
|
|
3321
|
+
try {
|
|
3322
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3323
|
+
} catch {
|
|
3324
|
+
throw new NotFoundError("Issue", id);
|
|
3325
|
+
}
|
|
3326
|
+
let issue;
|
|
3327
|
+
try {
|
|
3328
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3329
|
+
} catch {
|
|
3330
|
+
throw new NotFoundError("Issue", id);
|
|
3331
|
+
}
|
|
3332
|
+
const updates = await this.parseUpdates(options, mapping, tbdRoot);
|
|
3333
|
+
if (updates === null) return;
|
|
3334
|
+
if (this.checkDryRun("Would update issue", {
|
|
3335
|
+
id: internalId,
|
|
3336
|
+
...updates
|
|
3337
|
+
})) return;
|
|
3338
|
+
const oldSpecPath = issue.spec_path;
|
|
3339
|
+
if (updates.title !== void 0) issue.title = updates.title;
|
|
3340
|
+
if (updates.status !== void 0) issue.status = updates.status;
|
|
3341
|
+
if (updates.kind !== void 0) issue.kind = updates.kind;
|
|
3342
|
+
if (updates.priority !== void 0) issue.priority = updates.priority;
|
|
3343
|
+
if (updates.assignee !== void 0) issue.assignee = updates.assignee;
|
|
3344
|
+
if (updates.description !== void 0) issue.description = updates.description;
|
|
3345
|
+
if (updates.notes !== void 0) issue.notes = updates.notes;
|
|
3346
|
+
if (updates.due_date !== void 0) issue.due_date = updates.due_date;
|
|
3347
|
+
if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
|
|
3348
|
+
if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
|
|
3349
|
+
if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
|
|
3350
|
+
if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
|
|
3351
|
+
if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
|
|
3352
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3353
|
+
if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
|
|
3354
|
+
} catch {}
|
|
3355
|
+
if (updates.labels !== void 0) issue.labels = updates.labels;
|
|
3356
|
+
if (updates.addLabels && updates.addLabels.length > 0) {
|
|
3357
|
+
const labelsSet = new Set(issue.labels);
|
|
3358
|
+
for (const label of updates.addLabels) labelsSet.add(label);
|
|
3359
|
+
issue.labels = [...labelsSet];
|
|
3360
|
+
}
|
|
3361
|
+
if (updates.removeLabels && updates.removeLabels.length > 0) {
|
|
3362
|
+
const removeSet = new Set(updates.removeLabels);
|
|
3363
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3364
|
+
}
|
|
3365
|
+
issue.version += 1;
|
|
3366
|
+
issue.updated_at = now();
|
|
3367
|
+
await writeIssue(dataSyncDir, issue);
|
|
3368
|
+
if (updates.parent_id) try {
|
|
3369
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3370
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
3371
|
+
if (!hints.includes(internalId)) {
|
|
3372
|
+
parentIssue.child_order_hints = [...hints, internalId];
|
|
3373
|
+
parentIssue.version += 1;
|
|
3374
|
+
parentIssue.updated_at = now();
|
|
3375
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
3376
|
+
}
|
|
3377
|
+
} catch {}
|
|
3378
|
+
if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
|
|
3379
|
+
const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
|
|
3380
|
+
const timestamp = now();
|
|
3381
|
+
for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
|
|
3382
|
+
child.spec_path = issue.spec_path;
|
|
3383
|
+
child.version += 1;
|
|
3384
|
+
child.updated_at = timestamp;
|
|
3385
|
+
await writeIssue(dataSyncDir, child);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3389
|
+
didUpdate = true;
|
|
3390
|
+
});
|
|
3114
3391
|
}, "Failed to update issue");
|
|
3115
|
-
if (
|
|
3116
|
-
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3117
|
-
const hints = parentIssue.child_order_hints ?? [];
|
|
3118
|
-
if (!hints.includes(internalId)) {
|
|
3119
|
-
parentIssue.child_order_hints = [...hints, internalId];
|
|
3120
|
-
parentIssue.version += 1;
|
|
3121
|
-
parentIssue.updated_at = now();
|
|
3122
|
-
await writeIssue(dataSyncDir, parentIssue);
|
|
3123
|
-
}
|
|
3124
|
-
} catch {}
|
|
3125
|
-
if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
|
|
3126
|
-
const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
|
|
3127
|
-
const timestamp = now();
|
|
3128
|
-
for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
|
|
3129
|
-
child.spec_path = issue.spec_path;
|
|
3130
|
-
child.version += 1;
|
|
3131
|
-
child.updated_at = timestamp;
|
|
3132
|
-
await writeIssue(dataSyncDir, child);
|
|
3133
|
-
}
|
|
3134
|
-
}
|
|
3135
|
-
const showDebug = this.ctx.debug;
|
|
3136
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3137
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3392
|
+
if (!didUpdate) return;
|
|
3138
3393
|
this.output.data({
|
|
3139
3394
|
id: displayId,
|
|
3140
3395
|
updated: true
|
|
@@ -3262,48 +3517,47 @@ const updateCommand = new Command("update").description("Update an issue").argum
|
|
|
3262
3517
|
var CloseHandler = class extends BaseCommand {
|
|
3263
3518
|
async run(id, options) {
|
|
3264
3519
|
const tbdRoot = await requireInit();
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
let
|
|
3268
|
-
try {
|
|
3269
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3270
|
-
} catch {
|
|
3271
|
-
throw new NotFoundError("Issue", id);
|
|
3272
|
-
}
|
|
3273
|
-
let issue;
|
|
3274
|
-
try {
|
|
3275
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3276
|
-
} catch {
|
|
3277
|
-
throw new NotFoundError("Issue", id);
|
|
3278
|
-
}
|
|
3279
|
-
const showDebug = this.ctx.debug;
|
|
3280
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3281
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3282
|
-
if (issue.status === "closed") {
|
|
3283
|
-
this.output.data({
|
|
3284
|
-
id: displayId,
|
|
3285
|
-
closed: true,
|
|
3286
|
-
alreadyClosed: true
|
|
3287
|
-
}, () => {
|
|
3288
|
-
this.output.success(`Closed ${displayId}`);
|
|
3289
|
-
});
|
|
3290
|
-
return;
|
|
3291
|
-
}
|
|
3292
|
-
if (this.checkDryRun("Would close issue", {
|
|
3293
|
-
id: internalId,
|
|
3294
|
-
reason: options.reason
|
|
3295
|
-
})) return;
|
|
3296
|
-
issue.status = "closed";
|
|
3297
|
-
issue.closed_at = now();
|
|
3298
|
-
issue.close_reason = options.reason ?? null;
|
|
3299
|
-
issue.version += 1;
|
|
3300
|
-
issue.updated_at = now();
|
|
3520
|
+
let displayId = id;
|
|
3521
|
+
let alreadyClosed = false;
|
|
3522
|
+
let didClose = false;
|
|
3301
3523
|
await this.execute(async () => {
|
|
3302
|
-
await
|
|
3524
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3525
|
+
let internalId;
|
|
3526
|
+
try {
|
|
3527
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3528
|
+
} catch {
|
|
3529
|
+
throw new NotFoundError("Issue", id);
|
|
3530
|
+
}
|
|
3531
|
+
let issue;
|
|
3532
|
+
try {
|
|
3533
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3534
|
+
} catch {
|
|
3535
|
+
throw new NotFoundError("Issue", id);
|
|
3536
|
+
}
|
|
3537
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3538
|
+
if (issue.status === "closed") {
|
|
3539
|
+
alreadyClosed = true;
|
|
3540
|
+
didClose = true;
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (this.checkDryRun("Would close issue", {
|
|
3544
|
+
id: internalId,
|
|
3545
|
+
reason: options.reason
|
|
3546
|
+
})) return;
|
|
3547
|
+
issue.status = "closed";
|
|
3548
|
+
issue.closed_at = now();
|
|
3549
|
+
issue.close_reason = options.reason ?? null;
|
|
3550
|
+
issue.version += 1;
|
|
3551
|
+
issue.updated_at = now();
|
|
3552
|
+
await writeIssue(dataSyncDir, issue);
|
|
3553
|
+
didClose = true;
|
|
3554
|
+
});
|
|
3303
3555
|
}, "Failed to close issue");
|
|
3556
|
+
if (!didClose) return;
|
|
3304
3557
|
this.output.data({
|
|
3305
3558
|
id: displayId,
|
|
3306
|
-
closed: true
|
|
3559
|
+
closed: true,
|
|
3560
|
+
alreadyClosed
|
|
3307
3561
|
}, () => {
|
|
3308
3562
|
this.output.success(`Closed ${displayId}`);
|
|
3309
3563
|
});
|
|
@@ -3323,40 +3577,42 @@ const closeCommand = new Command("close").description("Close an issue").argument
|
|
|
3323
3577
|
var ReopenHandler = class extends BaseCommand {
|
|
3324
3578
|
async run(id, options) {
|
|
3325
3579
|
const tbdRoot = await requireInit();
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
let internalId;
|
|
3329
|
-
try {
|
|
3330
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3331
|
-
} catch {
|
|
3332
|
-
throw new NotFoundError("Issue", id);
|
|
3333
|
-
}
|
|
3334
|
-
let issue;
|
|
3335
|
-
try {
|
|
3336
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3337
|
-
} catch {
|
|
3338
|
-
throw new NotFoundError("Issue", id);
|
|
3339
|
-
}
|
|
3340
|
-
if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
|
|
3341
|
-
if (this.checkDryRun("Would reopen issue", {
|
|
3342
|
-
id: internalId,
|
|
3343
|
-
reason: options.reason
|
|
3344
|
-
})) return;
|
|
3345
|
-
issue.status = "open";
|
|
3346
|
-
issue.closed_at = null;
|
|
3347
|
-
issue.close_reason = null;
|
|
3348
|
-
issue.version += 1;
|
|
3349
|
-
issue.updated_at = now();
|
|
3350
|
-
if (options.reason) {
|
|
3351
|
-
const reopenNote = `Reopened: ${options.reason}`;
|
|
3352
|
-
issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
|
|
3353
|
-
}
|
|
3580
|
+
let displayId = id;
|
|
3581
|
+
let didReopen = false;
|
|
3354
3582
|
await this.execute(async () => {
|
|
3355
|
-
await
|
|
3583
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3584
|
+
let internalId;
|
|
3585
|
+
try {
|
|
3586
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3587
|
+
} catch {
|
|
3588
|
+
throw new NotFoundError("Issue", id);
|
|
3589
|
+
}
|
|
3590
|
+
let issue;
|
|
3591
|
+
try {
|
|
3592
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3593
|
+
} catch {
|
|
3594
|
+
throw new NotFoundError("Issue", id);
|
|
3595
|
+
}
|
|
3596
|
+
if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
|
|
3597
|
+
if (this.checkDryRun("Would reopen issue", {
|
|
3598
|
+
id: internalId,
|
|
3599
|
+
reason: options.reason
|
|
3600
|
+
})) return;
|
|
3601
|
+
issue.status = "open";
|
|
3602
|
+
issue.closed_at = null;
|
|
3603
|
+
issue.close_reason = null;
|
|
3604
|
+
issue.version += 1;
|
|
3605
|
+
issue.updated_at = now();
|
|
3606
|
+
if (options.reason) {
|
|
3607
|
+
const reopenNote = `Reopened: ${options.reason}`;
|
|
3608
|
+
issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
|
|
3609
|
+
}
|
|
3610
|
+
await writeIssue(dataSyncDir, issue);
|
|
3611
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3612
|
+
didReopen = true;
|
|
3613
|
+
});
|
|
3356
3614
|
}, "Failed to reopen issue");
|
|
3357
|
-
|
|
3358
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3359
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3615
|
+
if (!didReopen) return;
|
|
3360
3616
|
this.output.data({
|
|
3361
3617
|
id: displayId,
|
|
3362
3618
|
reopened: true
|
|
@@ -3604,43 +3860,45 @@ const staleCommand = new Command("stale").description("List issues not updated r
|
|
|
3604
3860
|
var LabelAddHandler = class extends BaseCommand {
|
|
3605
3861
|
async run(id, labels) {
|
|
3606
3862
|
const tbdRoot = await requireInit();
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
let internalId;
|
|
3610
|
-
try {
|
|
3611
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3612
|
-
} catch {
|
|
3613
|
-
throw new NotFoundError("Issue", id);
|
|
3614
|
-
}
|
|
3615
|
-
let issue;
|
|
3616
|
-
try {
|
|
3617
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3618
|
-
} catch {
|
|
3619
|
-
throw new NotFoundError("Issue", id);
|
|
3620
|
-
}
|
|
3621
|
-
if (this.checkDryRun("Would add labels", {
|
|
3622
|
-
id: internalId,
|
|
3623
|
-
labels
|
|
3624
|
-
})) return;
|
|
3625
|
-
const labelsSet = new Set(issue.labels);
|
|
3626
|
-
let added = 0;
|
|
3627
|
-
for (const label of labels) if (!labelsSet.has(label)) {
|
|
3628
|
-
labelsSet.add(label);
|
|
3629
|
-
added++;
|
|
3630
|
-
}
|
|
3631
|
-
if (added === 0) {
|
|
3632
|
-
this.output.info("All labels already present");
|
|
3633
|
-
return;
|
|
3634
|
-
}
|
|
3635
|
-
issue.labels = [...labelsSet];
|
|
3636
|
-
issue.version += 1;
|
|
3637
|
-
issue.updated_at = now();
|
|
3863
|
+
let displayId = id;
|
|
3864
|
+
let didAdd = false;
|
|
3638
3865
|
await this.execute(async () => {
|
|
3639
|
-
await
|
|
3866
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3867
|
+
let internalId;
|
|
3868
|
+
try {
|
|
3869
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3870
|
+
} catch {
|
|
3871
|
+
throw new NotFoundError("Issue", id);
|
|
3872
|
+
}
|
|
3873
|
+
let issue;
|
|
3874
|
+
try {
|
|
3875
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3876
|
+
} catch {
|
|
3877
|
+
throw new NotFoundError("Issue", id);
|
|
3878
|
+
}
|
|
3879
|
+
if (this.checkDryRun("Would add labels", {
|
|
3880
|
+
id: internalId,
|
|
3881
|
+
labels
|
|
3882
|
+
})) return;
|
|
3883
|
+
const labelsSet = new Set(issue.labels);
|
|
3884
|
+
let added = 0;
|
|
3885
|
+
for (const label of labels) if (!labelsSet.has(label)) {
|
|
3886
|
+
labelsSet.add(label);
|
|
3887
|
+
added++;
|
|
3888
|
+
}
|
|
3889
|
+
if (added === 0) {
|
|
3890
|
+
this.output.info("All labels already present");
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
issue.labels = [...labelsSet];
|
|
3894
|
+
issue.version += 1;
|
|
3895
|
+
issue.updated_at = now();
|
|
3896
|
+
await writeIssue(dataSyncDir, issue);
|
|
3897
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3898
|
+
didAdd = true;
|
|
3899
|
+
});
|
|
3640
3900
|
}, "Failed to update issue");
|
|
3641
|
-
|
|
3642
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3643
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3901
|
+
if (!didAdd) return;
|
|
3644
3902
|
this.output.data({
|
|
3645
3903
|
id: displayId,
|
|
3646
3904
|
addedLabels: labels
|
|
@@ -3652,39 +3910,41 @@ var LabelAddHandler = class extends BaseCommand {
|
|
|
3652
3910
|
var LabelRemoveHandler = class extends BaseCommand {
|
|
3653
3911
|
async run(id, labels) {
|
|
3654
3912
|
const tbdRoot = await requireInit();
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
let internalId;
|
|
3658
|
-
try {
|
|
3659
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3660
|
-
} catch {
|
|
3661
|
-
throw new NotFoundError("Issue", id);
|
|
3662
|
-
}
|
|
3663
|
-
let issue;
|
|
3664
|
-
try {
|
|
3665
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3666
|
-
} catch {
|
|
3667
|
-
throw new NotFoundError("Issue", id);
|
|
3668
|
-
}
|
|
3669
|
-
if (this.checkDryRun("Would remove labels", {
|
|
3670
|
-
id: internalId,
|
|
3671
|
-
labels
|
|
3672
|
-
})) return;
|
|
3673
|
-
const removeSet = new Set(labels);
|
|
3674
|
-
const originalCount = issue.labels.length;
|
|
3675
|
-
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3676
|
-
if (originalCount - issue.labels.length === 0) {
|
|
3677
|
-
this.output.info("No matching labels found");
|
|
3678
|
-
return;
|
|
3679
|
-
}
|
|
3680
|
-
issue.version += 1;
|
|
3681
|
-
issue.updated_at = now();
|
|
3913
|
+
let displayId = id;
|
|
3914
|
+
let didRemove = false;
|
|
3682
3915
|
await this.execute(async () => {
|
|
3683
|
-
await
|
|
3916
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3917
|
+
let internalId;
|
|
3918
|
+
try {
|
|
3919
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3920
|
+
} catch {
|
|
3921
|
+
throw new NotFoundError("Issue", id);
|
|
3922
|
+
}
|
|
3923
|
+
let issue;
|
|
3924
|
+
try {
|
|
3925
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3926
|
+
} catch {
|
|
3927
|
+
throw new NotFoundError("Issue", id);
|
|
3928
|
+
}
|
|
3929
|
+
if (this.checkDryRun("Would remove labels", {
|
|
3930
|
+
id: internalId,
|
|
3931
|
+
labels
|
|
3932
|
+
})) return;
|
|
3933
|
+
const removeSet = new Set(labels);
|
|
3934
|
+
const originalCount = issue.labels.length;
|
|
3935
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3936
|
+
if (originalCount - issue.labels.length === 0) {
|
|
3937
|
+
this.output.info("No matching labels found");
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
issue.version += 1;
|
|
3941
|
+
issue.updated_at = now();
|
|
3942
|
+
await writeIssue(dataSyncDir, issue);
|
|
3943
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3944
|
+
didRemove = true;
|
|
3945
|
+
});
|
|
3684
3946
|
}, "Failed to update issue");
|
|
3685
|
-
|
|
3686
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3687
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3947
|
+
if (!didRemove) return;
|
|
3688
3948
|
this.output.data({
|
|
3689
3949
|
id: displayId,
|
|
3690
3950
|
removedLabels: labels
|
|
@@ -3695,7 +3955,7 @@ var LabelRemoveHandler = class extends BaseCommand {
|
|
|
3695
3955
|
};
|
|
3696
3956
|
var LabelListHandler = class extends BaseCommand {
|
|
3697
3957
|
async run() {
|
|
3698
|
-
const dataSyncDir = await
|
|
3958
|
+
const { dataSyncDir } = await loadDataContext(await requireInit());
|
|
3699
3959
|
let issues;
|
|
3700
3960
|
try {
|
|
3701
3961
|
issues = await listIssues(dataSyncDir);
|
|
@@ -3743,53 +4003,56 @@ const labelCommand = new Command("label").description("Manage issue labels").add
|
|
|
3743
4003
|
var DependsAddHandler = class extends BaseCommand {
|
|
3744
4004
|
async run(issueId, dependsOnId) {
|
|
3745
4005
|
const tbdRoot = await requireInit();
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
let
|
|
3749
|
-
let internalDependsOnId;
|
|
3750
|
-
try {
|
|
3751
|
-
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
3752
|
-
} catch {
|
|
3753
|
-
throw new NotFoundError("Issue", issueId);
|
|
3754
|
-
}
|
|
3755
|
-
try {
|
|
3756
|
-
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
3757
|
-
} catch {
|
|
3758
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3759
|
-
}
|
|
3760
|
-
try {
|
|
3761
|
-
await readIssue(dataSyncDir, internalIssueId);
|
|
3762
|
-
} catch {
|
|
3763
|
-
throw new NotFoundError("Issue", issueId);
|
|
3764
|
-
}
|
|
3765
|
-
let blockerIssue;
|
|
3766
|
-
try {
|
|
3767
|
-
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
3768
|
-
} catch {
|
|
3769
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3770
|
-
}
|
|
3771
|
-
if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
|
|
3772
|
-
if (this.checkDryRun("Would add dependency", {
|
|
3773
|
-
issue: internalIssueId,
|
|
3774
|
-
dependsOn: internalDependsOnId
|
|
3775
|
-
})) return;
|
|
3776
|
-
if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
|
|
3777
|
-
this.output.info("Dependency already exists");
|
|
3778
|
-
return;
|
|
3779
|
-
}
|
|
3780
|
-
blockerIssue.dependencies.push({
|
|
3781
|
-
type: "blocks",
|
|
3782
|
-
target: internalIssueId
|
|
3783
|
-
});
|
|
3784
|
-
blockerIssue.version += 1;
|
|
3785
|
-
blockerIssue.updated_at = now();
|
|
4006
|
+
let displayIssueId = issueId;
|
|
4007
|
+
let displayDependsOnId = dependsOnId;
|
|
4008
|
+
let didAdd = false;
|
|
3786
4009
|
await this.execute(async () => {
|
|
3787
|
-
await
|
|
4010
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4011
|
+
let internalIssueId;
|
|
4012
|
+
let internalDependsOnId;
|
|
4013
|
+
try {
|
|
4014
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4015
|
+
} catch {
|
|
4016
|
+
throw new NotFoundError("Issue", issueId);
|
|
4017
|
+
}
|
|
4018
|
+
try {
|
|
4019
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4020
|
+
} catch {
|
|
4021
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4022
|
+
}
|
|
4023
|
+
try {
|
|
4024
|
+
await readIssue(dataSyncDir, internalIssueId);
|
|
4025
|
+
} catch {
|
|
4026
|
+
throw new NotFoundError("Issue", issueId);
|
|
4027
|
+
}
|
|
4028
|
+
let blockerIssue;
|
|
4029
|
+
try {
|
|
4030
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4031
|
+
} catch {
|
|
4032
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4033
|
+
}
|
|
4034
|
+
if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
|
|
4035
|
+
if (this.checkDryRun("Would add dependency", {
|
|
4036
|
+
issue: internalIssueId,
|
|
4037
|
+
dependsOn: internalDependsOnId
|
|
4038
|
+
})) return;
|
|
4039
|
+
if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
|
|
4040
|
+
this.output.info("Dependency already exists");
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
blockerIssue.dependencies.push({
|
|
4044
|
+
type: "blocks",
|
|
4045
|
+
target: internalIssueId
|
|
4046
|
+
});
|
|
4047
|
+
blockerIssue.version += 1;
|
|
4048
|
+
blockerIssue.updated_at = now();
|
|
4049
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4050
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4051
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4052
|
+
didAdd = true;
|
|
4053
|
+
});
|
|
3788
4054
|
}, "Failed to update issue");
|
|
3789
|
-
|
|
3790
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3791
|
-
const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
|
|
3792
|
-
const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
|
|
4055
|
+
if (!didAdd) return;
|
|
3793
4056
|
this.output.data({
|
|
3794
4057
|
issue: displayIssueId,
|
|
3795
4058
|
dependsOn: displayDependsOnId
|
|
@@ -3801,45 +4064,48 @@ var DependsAddHandler = class extends BaseCommand {
|
|
|
3801
4064
|
var DependsRemoveHandler = class extends BaseCommand {
|
|
3802
4065
|
async run(issueId, dependsOnId) {
|
|
3803
4066
|
const tbdRoot = await requireInit();
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
let
|
|
3807
|
-
let internalDependsOnId;
|
|
3808
|
-
try {
|
|
3809
|
-
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
3810
|
-
} catch {
|
|
3811
|
-
throw new NotFoundError("Issue", issueId);
|
|
3812
|
-
}
|
|
3813
|
-
try {
|
|
3814
|
-
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
3815
|
-
} catch {
|
|
3816
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3817
|
-
}
|
|
3818
|
-
let blockerIssue;
|
|
3819
|
-
try {
|
|
3820
|
-
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
3821
|
-
} catch {
|
|
3822
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3823
|
-
}
|
|
3824
|
-
if (this.checkDryRun("Would remove dependency", {
|
|
3825
|
-
issue: internalIssueId,
|
|
3826
|
-
dependsOn: internalDependsOnId
|
|
3827
|
-
})) return;
|
|
3828
|
-
const initialLength = blockerIssue.dependencies.length;
|
|
3829
|
-
blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
|
|
3830
|
-
if (blockerIssue.dependencies.length === initialLength) {
|
|
3831
|
-
this.output.info("Dependency not found");
|
|
3832
|
-
return;
|
|
3833
|
-
}
|
|
3834
|
-
blockerIssue.version += 1;
|
|
3835
|
-
blockerIssue.updated_at = now();
|
|
4067
|
+
let displayIssueId = issueId;
|
|
4068
|
+
let displayDependsOnId = dependsOnId;
|
|
4069
|
+
let didRemove = false;
|
|
3836
4070
|
await this.execute(async () => {
|
|
3837
|
-
await
|
|
4071
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4072
|
+
let internalIssueId;
|
|
4073
|
+
let internalDependsOnId;
|
|
4074
|
+
try {
|
|
4075
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4076
|
+
} catch {
|
|
4077
|
+
throw new NotFoundError("Issue", issueId);
|
|
4078
|
+
}
|
|
4079
|
+
try {
|
|
4080
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4081
|
+
} catch {
|
|
4082
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4083
|
+
}
|
|
4084
|
+
let blockerIssue;
|
|
4085
|
+
try {
|
|
4086
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4087
|
+
} catch {
|
|
4088
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4089
|
+
}
|
|
4090
|
+
if (this.checkDryRun("Would remove dependency", {
|
|
4091
|
+
issue: internalIssueId,
|
|
4092
|
+
dependsOn: internalDependsOnId
|
|
4093
|
+
})) return;
|
|
4094
|
+
const initialLength = blockerIssue.dependencies.length;
|
|
4095
|
+
blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
|
|
4096
|
+
if (blockerIssue.dependencies.length === initialLength) {
|
|
4097
|
+
this.output.info("Dependency not found");
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
blockerIssue.version += 1;
|
|
4101
|
+
blockerIssue.updated_at = now();
|
|
4102
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4103
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4104
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4105
|
+
didRemove = true;
|
|
4106
|
+
});
|
|
3838
4107
|
}, "Failed to update issue");
|
|
3839
|
-
|
|
3840
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3841
|
-
const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
|
|
3842
|
-
const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
|
|
4108
|
+
if (!didRemove) return;
|
|
3843
4109
|
this.output.data({
|
|
3844
4110
|
issue: displayIssueId,
|
|
3845
4111
|
removed: displayDependsOnId
|
|
@@ -3850,9 +4116,7 @@ var DependsRemoveHandler = class extends BaseCommand {
|
|
|
3850
4116
|
};
|
|
3851
4117
|
var DependsListHandler = class extends BaseCommand {
|
|
3852
4118
|
async run(id) {
|
|
3853
|
-
const
|
|
3854
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
3855
|
-
const mapping = await loadIdMapping(dataSyncDir);
|
|
4119
|
+
const { dataSyncDir, mapping, config } = await loadDataContext(await requireInit());
|
|
3856
4120
|
let internalId;
|
|
3857
4121
|
try {
|
|
3858
4122
|
internalId = resolveToInternalId(id, mapping);
|
|
@@ -3872,7 +4136,7 @@ var DependsListHandler = class extends BaseCommand {
|
|
|
3872
4136
|
allIssues = [];
|
|
3873
4137
|
}
|
|
3874
4138
|
const showDebug = this.ctx.debug;
|
|
3875
|
-
const prefix =
|
|
4139
|
+
const prefix = config.display.id_prefix;
|
|
3876
4140
|
const deps = getDependencyDirections(issue, allIssues, (dependencyId) => showDebug ? formatDebugId(dependencyId, mapping, prefix) : formatDisplayId(dependencyId, mapping, prefix));
|
|
3877
4141
|
this.output.data(deps, () => {
|
|
3878
4142
|
const colors = this.output.getColors();
|
|
@@ -4896,6 +5160,8 @@ async function workspaceExists(tbdRoot, name) {
|
|
|
4896
5160
|
var SyncHandler = class extends BaseCommand {
|
|
4897
5161
|
dataSyncDir = "";
|
|
4898
5162
|
tbdRoot = "";
|
|
5163
|
+
worktreePath = "";
|
|
5164
|
+
syncBranch = "";
|
|
4899
5165
|
async run(options) {
|
|
4900
5166
|
const tbdRoot = await requireInit();
|
|
4901
5167
|
this.tbdRoot = tbdRoot;
|
|
@@ -4908,42 +5174,28 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4908
5174
|
await this.syncDocs(options.status);
|
|
4909
5175
|
if (!syncIssues) return;
|
|
4910
5176
|
}
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
if (
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
if (options.status) {
|
|
4934
|
-
await this.showIssueStatus(syncBranch, remote);
|
|
4935
|
-
return;
|
|
4936
|
-
}
|
|
4937
|
-
if (this.checkDryRun("Would sync repository", {
|
|
4938
|
-
syncBranch,
|
|
4939
|
-
remote
|
|
4940
|
-
})) return;
|
|
4941
|
-
if (options.pull) await this.pullChanges(syncBranch, remote);
|
|
4942
|
-
else if (options.push) await this.pushChanges(syncBranch, remote);
|
|
4943
|
-
else await this.fullSync(syncBranch, remote, {
|
|
4944
|
-
force: options.force,
|
|
4945
|
-
autoSave: options.autoSave,
|
|
4946
|
-
outbox: options.outbox
|
|
5177
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
|
|
5178
|
+
this.dataSyncDir = dataSyncDir;
|
|
5179
|
+
this.worktreePath = sharedPaths.sharedWorktreePath;
|
|
5180
|
+
this.syncBranch = config.sync.branch;
|
|
5181
|
+
const syncBranch = config.sync.branch;
|
|
5182
|
+
const remote = config.sync.remote;
|
|
5183
|
+
if (options.status) {
|
|
5184
|
+
await this.showIssueStatus(syncBranch, remote);
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
5187
|
+
if (this.checkDryRun("Would sync repository", {
|
|
5188
|
+
syncBranch,
|
|
5189
|
+
remote
|
|
5190
|
+
})) return;
|
|
5191
|
+
if (repairedWorktreeStatus) this.output.success("Worktree repaired successfully");
|
|
5192
|
+
if (options.pull) await this.pullChanges(syncBranch, remote);
|
|
5193
|
+
else if (options.push) await this.pushChanges(syncBranch, remote);
|
|
5194
|
+
else await this.fullSync(syncBranch, remote, {
|
|
5195
|
+
force: options.force,
|
|
5196
|
+
autoSave: options.autoSave,
|
|
5197
|
+
outbox: options.outbox
|
|
5198
|
+
});
|
|
4947
5199
|
});
|
|
4948
5200
|
}
|
|
4949
5201
|
/**
|
|
@@ -4993,24 +5245,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4993
5245
|
if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
|
|
4994
5246
|
for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
|
|
4995
5247
|
}
|
|
4996
|
-
/**
|
|
4997
|
-
* Attempt to repair an unhealthy worktree.
|
|
4998
|
-
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
|
|
4999
|
-
*/
|
|
5000
|
-
async doRepairWorktree(tbdRoot, status) {
|
|
5001
|
-
const spinner = this.output.spinner(`Repairing worktree (${status})...`);
|
|
5002
|
-
try {
|
|
5003
|
-
const result = await repairWorktree(tbdRoot, status);
|
|
5004
|
-
spinner.stop();
|
|
5005
|
-
if (!result.success) throw new WorktreeCorruptedError(`Failed to repair worktree: ${result.error}`);
|
|
5006
|
-
if (result.backedUp) this.output.info(`Corrupted worktree backed up to: ${result.backedUp}`);
|
|
5007
|
-
this.output.success("Worktree repaired successfully");
|
|
5008
|
-
} catch (error) {
|
|
5009
|
-
spinner.stop();
|
|
5010
|
-
if (error instanceof WorktreeCorruptedError) throw error;
|
|
5011
|
-
throw new WorktreeCorruptedError(`Failed to repair worktree: ${error.message}`);
|
|
5012
|
-
}
|
|
5013
|
-
}
|
|
5014
5248
|
async showIssueStatus(syncBranch, remote) {
|
|
5015
5249
|
const status = await this.getSyncStatus(syncBranch, remote);
|
|
5016
5250
|
this.output.data(status, () => {
|
|
@@ -5041,7 +5275,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5041
5275
|
let ahead = 0;
|
|
5042
5276
|
let behind = 0;
|
|
5043
5277
|
try {
|
|
5044
|
-
const status = await git("-C",
|
|
5278
|
+
const status = await git("-C", this.worktreePath, "status", "--porcelain");
|
|
5045
5279
|
if (status) for (const line of status.split("\n")) {
|
|
5046
5280
|
if (!line) continue;
|
|
5047
5281
|
const statusCode = line.slice(0, 2).trim();
|
|
@@ -5100,11 +5334,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5100
5334
|
this.output.success("Already up to date");
|
|
5101
5335
|
return;
|
|
5102
5336
|
}
|
|
5103
|
-
await
|
|
5104
|
-
|
|
5105
|
-
const remoteCommit = await git("rev-parse", `${remote}/${syncBranch}`);
|
|
5106
|
-
await git("update-ref", `refs/heads/${syncBranch}`, remoteCommit);
|
|
5107
|
-
});
|
|
5337
|
+
await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
|
|
5338
|
+
await git("-C", this.worktreePath, "merge", "--ff-only", `${remote}/${syncBranch}`);
|
|
5108
5339
|
this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
|
|
5109
5340
|
} catch (error) {
|
|
5110
5341
|
spinner.stop();
|
|
@@ -5120,15 +5351,15 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5120
5351
|
* @returns Tallies of new/updated/deleted files committed
|
|
5121
5352
|
*/
|
|
5122
5353
|
async commitWorktreeChanges() {
|
|
5123
|
-
const worktreePath =
|
|
5354
|
+
const worktreePath = this.worktreePath;
|
|
5124
5355
|
try {
|
|
5125
|
-
await
|
|
5356
|
+
await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);
|
|
5126
5357
|
const status = await git("-C", worktreePath, "status", "--porcelain");
|
|
5127
5358
|
if (!status || status.trim() === "") return emptyTallies();
|
|
5128
5359
|
const tallies = parseGitStatus(status);
|
|
5129
5360
|
const fileCount = tallies.new + tallies.updated + tallies.deleted;
|
|
5130
5361
|
await git("-C", worktreePath, "add", "-A");
|
|
5131
|
-
await
|
|
5362
|
+
await gitCommit(worktreePath, "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
|
|
5132
5363
|
return tallies;
|
|
5133
5364
|
} catch (error) {
|
|
5134
5365
|
if (error.message.includes("nothing to commit")) return emptyTallies();
|
|
@@ -5210,7 +5441,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5210
5441
|
const spinner = this.output.spinner("Syncing with remote...");
|
|
5211
5442
|
const summary = emptySummary();
|
|
5212
5443
|
const conflicts = [];
|
|
5213
|
-
const worktreePath =
|
|
5444
|
+
const worktreePath = this.worktreePath;
|
|
5214
5445
|
try {
|
|
5215
5446
|
const committedTallies = await this.commitWorktreeChanges();
|
|
5216
5447
|
summary.sent.new += committedTallies.new;
|
|
@@ -5238,7 +5469,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5238
5469
|
this.output.debug("Remote sync branch does not exist yet");
|
|
5239
5470
|
}
|
|
5240
5471
|
{
|
|
5241
|
-
const { access, writeFile } = await import("node:fs/promises");
|
|
5242
5472
|
const attrPath = join(this.dataSyncDir, "mappings", ".gitattributes");
|
|
5243
5473
|
try {
|
|
5244
5474
|
await access(attrPath);
|
|
@@ -5246,7 +5476,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5246
5476
|
await writeFile(attrPath, "ids.yml merge=union\n");
|
|
5247
5477
|
await git("-C", worktreePath, "add", attrPath);
|
|
5248
5478
|
try {
|
|
5249
|
-
await
|
|
5479
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "chore: add merge=union for ids.yml");
|
|
5250
5480
|
} catch {}
|
|
5251
5481
|
}
|
|
5252
5482
|
}
|
|
@@ -5272,7 +5502,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5272
5502
|
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
5273
5503
|
await git("-C", worktreePath, "add", "-A");
|
|
5274
5504
|
try {
|
|
5275
|
-
await
|
|
5505
|
+
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
5276
5506
|
} catch {}
|
|
5277
5507
|
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
5278
5508
|
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
@@ -5294,7 +5524,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5294
5524
|
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5295
5525
|
if (remoteIdsContent) {
|
|
5296
5526
|
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5297
|
-
const { readFile } = await import("node:fs/promises");
|
|
5298
5527
|
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
5299
5528
|
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
5300
5529
|
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
@@ -5320,7 +5549,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5320
5549
|
throw new SyncError(`Cannot commit: ${conflictedFiles.length} file(s) still have merge conflict markers:\n` + conflictedFiles.map((f) => ` - ${f}`).join("\n") + `\n\nThis is a bug in tbd sync. Please report it and manually resolve conflicts in:\n ${worktreePath}`);
|
|
5321
5550
|
}
|
|
5322
5551
|
try {
|
|
5323
|
-
await
|
|
5552
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
5324
5553
|
} catch {
|
|
5325
5554
|
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
5326
5555
|
}
|
|
@@ -5813,16 +6042,23 @@ function renderBeadsWarning(colors) {
|
|
|
5813
6042
|
* Used by: status
|
|
5814
6043
|
*
|
|
5815
6044
|
* @param path - Worktree path
|
|
5816
|
-
* @param
|
|
6045
|
+
* @param status - 'valid' (healthy), 'missing' (created on next sync), or 'prunable' /
|
|
6046
|
+
* 'corrupted' (truly unhealthy and needs repair)
|
|
5817
6047
|
* @param colors - Color functions
|
|
5818
6048
|
*/
|
|
5819
|
-
function renderWorktreeStatus(path,
|
|
6049
|
+
function renderWorktreeStatus(path, status, colors) {
|
|
5820
6050
|
console.log("");
|
|
5821
|
-
if (
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
6051
|
+
if (status === "valid") {
|
|
6052
|
+
console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
|
|
6053
|
+
return;
|
|
6054
|
+
}
|
|
6055
|
+
if (status === "missing") {
|
|
6056
|
+
console.log(`${colors.dim("Worktree:")} ${path} (not initialized)`);
|
|
6057
|
+
console.log(" Run: tbd sync (or tbd doctor --fix) to initialize");
|
|
6058
|
+
return;
|
|
5825
6059
|
}
|
|
6060
|
+
console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
|
|
6061
|
+
console.log(" Run: tbd doctor --fix");
|
|
5826
6062
|
}
|
|
5827
6063
|
/**
|
|
5828
6064
|
* Render footer with command suggestions.
|
|
@@ -6037,6 +6273,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6037
6273
|
display_prefix: null,
|
|
6038
6274
|
worktree_path: null,
|
|
6039
6275
|
worktree_healthy: null,
|
|
6276
|
+
worktree_status: null,
|
|
6040
6277
|
workspaces: [],
|
|
6041
6278
|
integrations: {
|
|
6042
6279
|
portable_skill: false,
|
|
@@ -6150,10 +6387,11 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6150
6387
|
data.remote = config.sync.remote;
|
|
6151
6388
|
data.display_prefix = config.display.id_prefix;
|
|
6152
6389
|
} catch {}
|
|
6153
|
-
const
|
|
6154
|
-
const worktreeHealth = await checkWorktreeHealth(cwd);
|
|
6155
|
-
data.worktree_path =
|
|
6390
|
+
const sharedPaths = await resolveSharedTbdPaths(cwd);
|
|
6391
|
+
const worktreeHealth = await checkWorktreeHealth(cwd, data.sync_branch ?? void 0);
|
|
6392
|
+
data.worktree_path = sharedPaths.sharedWorktreePath;
|
|
6156
6393
|
data.worktree_healthy = worktreeHealth.valid;
|
|
6394
|
+
data.worktree_status = worktreeHealth.status;
|
|
6157
6395
|
try {
|
|
6158
6396
|
data.workspaces = await listWorkspaces(cwd);
|
|
6159
6397
|
} catch {}
|
|
@@ -6204,7 +6442,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6204
6442
|
console.log("");
|
|
6205
6443
|
console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
|
|
6206
6444
|
}
|
|
6207
|
-
if (data.
|
|
6445
|
+
if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
|
|
6208
6446
|
if (data.workspaces.length > 0) {
|
|
6209
6447
|
console.log("");
|
|
6210
6448
|
console.log(colors.bold("WORKSPACES"));
|
|
@@ -6302,7 +6540,8 @@ var StatsHandler = class extends BaseCommand {
|
|
|
6302
6540
|
const tbdRoot = await requireInit();
|
|
6303
6541
|
let issues;
|
|
6304
6542
|
try {
|
|
6305
|
-
|
|
6543
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
6544
|
+
issues = await listIssues(dataSyncDir);
|
|
6306
6545
|
} catch {
|
|
6307
6546
|
throw new NotInitializedError("No issue store found. Run `tbd init` first.");
|
|
6308
6547
|
}
|
|
@@ -6495,7 +6734,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6495
6734
|
async run(options) {
|
|
6496
6735
|
const tbdRoot = await requireInit();
|
|
6497
6736
|
this.cwd = tbdRoot;
|
|
6498
|
-
this.dataSyncDir = await
|
|
6737
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
|
|
6499
6738
|
try {
|
|
6500
6739
|
this.config = await readConfig(this.cwd);
|
|
6501
6740
|
} catch {}
|
|
@@ -6519,10 +6758,11 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6519
6758
|
healthChecks.push(await this.checkTempFiles(options.fix));
|
|
6520
6759
|
healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
|
|
6521
6760
|
healthChecks.push(await this.checkWorktree(options.fix));
|
|
6761
|
+
healthChecks.push(await this.checkCommonDirLayout(options.fix));
|
|
6522
6762
|
const dataLocationResult = await this.checkDataLocation(options.fix);
|
|
6523
6763
|
healthChecks.push(dataLocationResult);
|
|
6524
6764
|
if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
|
|
6525
|
-
this.dataSyncDir = await
|
|
6765
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
|
|
6526
6766
|
try {
|
|
6527
6767
|
this.invalidIssueFiles = [];
|
|
6528
6768
|
this.issues = await listIssues(this.dataSyncDir, {
|
|
@@ -6546,6 +6786,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6546
6786
|
integrationChecks.push(await this.checkCodexHooks());
|
|
6547
6787
|
const allChecks = [...healthChecks, ...integrationChecks];
|
|
6548
6788
|
const allOk = allChecks.every((c) => c.status === "ok");
|
|
6789
|
+
const hasErrors = allChecks.some((c) => c.status === "error");
|
|
6549
6790
|
const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
|
|
6550
6791
|
this.output.data({
|
|
6551
6792
|
statusInfo,
|
|
@@ -6581,11 +6822,12 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6581
6822
|
else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
|
|
6582
6823
|
else this.output.warn("Issues found that may require manual intervention.");
|
|
6583
6824
|
});
|
|
6825
|
+
if (hasErrors) process.exitCode = 1;
|
|
6584
6826
|
}
|
|
6585
6827
|
async gatherStatusInfo() {
|
|
6586
6828
|
let gitBranch = null;
|
|
6587
6829
|
try {
|
|
6588
|
-
gitBranch = await getCurrentBranch();
|
|
6830
|
+
gitBranch = await getCurrentBranch(this.cwd);
|
|
6589
6831
|
} catch {}
|
|
6590
6832
|
const worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
6591
6833
|
return {
|
|
@@ -6612,7 +6854,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6612
6854
|
if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
|
|
6613
6855
|
}
|
|
6614
6856
|
let remoteTotal = null;
|
|
6615
|
-
if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync");
|
|
6857
|
+
if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync", this.cwd);
|
|
6616
6858
|
return {
|
|
6617
6859
|
total: this.issues.length,
|
|
6618
6860
|
ready: readyCount,
|
|
@@ -6670,6 +6912,13 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6670
6912
|
path: configPath,
|
|
6671
6913
|
suggestion: "Run: tbd init"
|
|
6672
6914
|
};
|
|
6915
|
+
if (error instanceof IncompatibleFormatError) return {
|
|
6916
|
+
name: "Config file",
|
|
6917
|
+
status: "error",
|
|
6918
|
+
message: `requires newer tbd (found ${error.foundFormat}, supported ${error.supportedFormat})`,
|
|
6919
|
+
path: configPath,
|
|
6920
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
6921
|
+
};
|
|
6673
6922
|
return {
|
|
6674
6923
|
name: "Config file",
|
|
6675
6924
|
status: "error",
|
|
@@ -6761,7 +7010,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6761
7010
|
status: "ok"
|
|
6762
7011
|
};
|
|
6763
7012
|
if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
|
|
6764
|
-
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-
|
|
7013
|
+
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6765
7014
|
const resolved = resolveIdMappingConflicts(content);
|
|
6766
7015
|
await saveIdMapping(this.dataSyncDir, resolved);
|
|
6767
7016
|
return {
|
|
@@ -6810,7 +7059,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6810
7059
|
status: "ok"
|
|
6811
7060
|
};
|
|
6812
7061
|
if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
|
|
6813
|
-
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-
|
|
7062
|
+
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6814
7063
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6815
7064
|
await saveIdMapping(this.dataSyncDir, mapping);
|
|
6816
7065
|
return {
|
|
@@ -6949,7 +7198,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6949
7198
|
name: "ID mapping coverage",
|
|
6950
7199
|
status: "ok"
|
|
6951
7200
|
};
|
|
6952
|
-
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-
|
|
7201
|
+
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6953
7202
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6954
7203
|
const missingIds = [];
|
|
6955
7204
|
for (const issue of this.issues) {
|
|
@@ -6961,10 +7210,10 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6961
7210
|
status: "ok"
|
|
6962
7211
|
};
|
|
6963
7212
|
if (fix && !this.checkDryRun("Create missing ID mappings")) {
|
|
6964
|
-
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-
|
|
7213
|
+
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6965
7214
|
let historicalMapping;
|
|
6966
7215
|
try {
|
|
6967
|
-
const syncBranch = (await import("./config-
|
|
7216
|
+
const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
|
|
6968
7217
|
const logArgs = ["log", "--format=%H"];
|
|
6969
7218
|
if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
|
|
6970
7219
|
logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
@@ -7095,24 +7344,51 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7095
7344
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
|
|
7096
7345
|
*/
|
|
7097
7346
|
async checkWorktree(fix) {
|
|
7098
|
-
const worktreePath =
|
|
7099
|
-
const
|
|
7347
|
+
const worktreePath = (await resolveSharedTbdPaths(this.cwd)).sharedWorktreePath;
|
|
7348
|
+
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7349
|
+
const remote = this.config?.sync.remote ?? "origin";
|
|
7350
|
+
const worktreeHealth = await checkWorktreeHealth(this.cwd, syncBranch);
|
|
7100
7351
|
switch (worktreeHealth.status) {
|
|
7101
7352
|
case "valid": return {
|
|
7102
7353
|
name: "Worktree",
|
|
7103
7354
|
status: "ok",
|
|
7104
7355
|
path: worktreePath
|
|
7105
7356
|
};
|
|
7106
|
-
case "missing":
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7357
|
+
case "missing":
|
|
7358
|
+
if (fix && !this.checkDryRun("Initialize shared data-sync worktree")) {
|
|
7359
|
+
try {
|
|
7360
|
+
await withSharedDataSyncLock(this.cwd, async () => {
|
|
7361
|
+
await prepareDataSyncContext(this.cwd);
|
|
7362
|
+
});
|
|
7363
|
+
} catch (error) {
|
|
7364
|
+
return {
|
|
7365
|
+
name: "Worktree",
|
|
7366
|
+
status: "error",
|
|
7367
|
+
message: `initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
7368
|
+
path: worktreePath
|
|
7369
|
+
};
|
|
7370
|
+
}
|
|
7371
|
+
try {
|
|
7372
|
+
this.config = await readConfig(this.cwd);
|
|
7373
|
+
} catch {}
|
|
7374
|
+
return {
|
|
7375
|
+
name: "Worktree",
|
|
7376
|
+
status: "ok",
|
|
7377
|
+
message: "initialized",
|
|
7378
|
+
path: worktreePath
|
|
7379
|
+
};
|
|
7380
|
+
}
|
|
7381
|
+
return {
|
|
7382
|
+
name: "Worktree",
|
|
7383
|
+
status: "ok",
|
|
7384
|
+
message: "not created yet",
|
|
7385
|
+
path: worktreePath
|
|
7386
|
+
};
|
|
7112
7387
|
case "prunable":
|
|
7113
7388
|
case "corrupted":
|
|
7114
7389
|
if (fix && !this.checkDryRun("Repair worktree")) {
|
|
7115
|
-
const
|
|
7390
|
+
const repairStatus = worktreeHealth.status;
|
|
7391
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => repairWorktree(this.cwd, repairStatus, remote, syncBranch));
|
|
7116
7392
|
if (result.success) return {
|
|
7117
7393
|
name: "Worktree",
|
|
7118
7394
|
status: "ok",
|
|
@@ -7155,10 +7431,80 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7155
7431
|
}
|
|
7156
7432
|
}
|
|
7157
7433
|
/**
|
|
7434
|
+
* Check $GIT_COMMON_DIR/tbd/layout.yml against the checkout config.
|
|
7435
|
+
*
|
|
7436
|
+
* Reports missing (initialized on next mutating command), mismatched (rewrite
|
|
7437
|
+
* from config under --fix), or future-format (requires newer tbd, no fix).
|
|
7438
|
+
*
|
|
7439
|
+
* See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Format And Layout
|
|
7440
|
+
* Versioning.
|
|
7441
|
+
*/
|
|
7442
|
+
async checkCommonDirLayout(fix) {
|
|
7443
|
+
if (!this.config) return {
|
|
7444
|
+
name: "Common-dir layout",
|
|
7445
|
+
status: "ok",
|
|
7446
|
+
message: "skipped (no config)"
|
|
7447
|
+
};
|
|
7448
|
+
const sharedPaths = await resolveSharedTbdPaths(this.cwd);
|
|
7449
|
+
const layoutPath = sharedPaths.sharedLayoutPath;
|
|
7450
|
+
let layout;
|
|
7451
|
+
try {
|
|
7452
|
+
layout = await readCommonDirLayout(layoutPath);
|
|
7453
|
+
} catch (error) {
|
|
7454
|
+
return {
|
|
7455
|
+
name: "Common-dir layout",
|
|
7456
|
+
status: "error",
|
|
7457
|
+
message: error instanceof Error ? error.message : String(error),
|
|
7458
|
+
path: layoutPath
|
|
7459
|
+
};
|
|
7460
|
+
}
|
|
7461
|
+
if (!layout) return {
|
|
7462
|
+
name: "Common-dir layout",
|
|
7463
|
+
status: "ok",
|
|
7464
|
+
message: "not initialized yet (created on first sync)",
|
|
7465
|
+
path: layoutPath
|
|
7466
|
+
};
|
|
7467
|
+
if (!isCompatibleFormat(layout.tbd_format)) return {
|
|
7468
|
+
name: "Common-dir layout",
|
|
7469
|
+
status: "error",
|
|
7470
|
+
message: `requires newer tbd (found ${layout.tbd_format})`,
|
|
7471
|
+
path: layoutPath,
|
|
7472
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
7473
|
+
};
|
|
7474
|
+
try {
|
|
7475
|
+
validateCommonDirLayout(layout, this.config);
|
|
7476
|
+
return {
|
|
7477
|
+
name: "Common-dir layout",
|
|
7478
|
+
status: "ok",
|
|
7479
|
+
path: layoutPath
|
|
7480
|
+
};
|
|
7481
|
+
} catch (error) {
|
|
7482
|
+
if (!(error instanceof CommonDirLayoutError)) throw error;
|
|
7483
|
+
if (fix && !this.checkDryRun("Repair common-dir layout")) {
|
|
7484
|
+
const configRef = this.config;
|
|
7485
|
+
await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef, layout));
|
|
7486
|
+
return {
|
|
7487
|
+
name: "Common-dir layout",
|
|
7488
|
+
status: "ok",
|
|
7489
|
+
message: "rewritten from config",
|
|
7490
|
+
path: layoutPath
|
|
7491
|
+
};
|
|
7492
|
+
}
|
|
7493
|
+
return {
|
|
7494
|
+
name: "Common-dir layout",
|
|
7495
|
+
status: "error",
|
|
7496
|
+
message: "mismatched with config",
|
|
7497
|
+
path: layoutPath,
|
|
7498
|
+
fixable: true,
|
|
7499
|
+
suggestion: "Run: tbd doctor --fix"
|
|
7500
|
+
};
|
|
7501
|
+
}
|
|
7502
|
+
}
|
|
7503
|
+
/**
|
|
7158
7504
|
* Check for issues in wrong location.
|
|
7159
7505
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
|
|
7160
7506
|
*
|
|
7161
|
-
* Issues should be in
|
|
7507
|
+
* Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/issues/
|
|
7162
7508
|
* If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
|
|
7163
7509
|
* and data was written to the fallback path - this is a bug requiring migration.
|
|
7164
7510
|
*/
|
|
@@ -7176,7 +7522,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7176
7522
|
if (fix && !this.checkDryRun("Migrate data to worktree")) {
|
|
7177
7523
|
let worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
7178
7524
|
if (worktreeHealth.status === "missing") {
|
|
7179
|
-
const initResult = await initWorktree(this.cwd);
|
|
7525
|
+
const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
|
|
7180
7526
|
if (!initResult.success) return {
|
|
7181
7527
|
name: "Data location",
|
|
7182
7528
|
status: "error",
|
|
@@ -7219,7 +7565,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7219
7565
|
path: wrongIssuesPath,
|
|
7220
7566
|
details: [
|
|
7221
7567
|
`Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
|
|
7222
|
-
"Issues should be in
|
|
7568
|
+
"Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/",
|
|
7223
7569
|
"This indicates the worktree was missing when issues were created"
|
|
7224
7570
|
],
|
|
7225
7571
|
fixable: true,
|
|
@@ -7232,14 +7578,14 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7232
7578
|
*/
|
|
7233
7579
|
async checkLocalSyncBranch() {
|
|
7234
7580
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7235
|
-
const localHealth = await checkLocalBranchHealth(syncBranch);
|
|
7581
|
+
const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
|
|
7236
7582
|
if (localHealth.exists && !localHealth.orphaned) return {
|
|
7237
7583
|
name: "Local sync branch",
|
|
7238
7584
|
status: "ok",
|
|
7239
7585
|
message: syncBranch
|
|
7240
7586
|
};
|
|
7241
7587
|
if (!localHealth.exists) {
|
|
7242
|
-
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7588
|
+
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7243
7589
|
name: "Local sync branch",
|
|
7244
7590
|
status: "warn",
|
|
7245
7591
|
message: `${syncBranch} not found (remote exists)`,
|
|
@@ -7265,7 +7611,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7265
7611
|
async checkRemoteSyncBranch() {
|
|
7266
7612
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7267
7613
|
const remote = this.config?.sync.remote ?? "origin";
|
|
7268
|
-
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
|
|
7614
|
+
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
7269
7615
|
if (remoteHealth.exists) {
|
|
7270
7616
|
if (remoteHealth.diverged) return {
|
|
7271
7617
|
name: "Remote sync branch",
|
|
@@ -7279,7 +7625,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7279
7625
|
message: `${remote}/${syncBranch}`
|
|
7280
7626
|
};
|
|
7281
7627
|
}
|
|
7282
|
-
if ((await checkLocalBranchHealth(syncBranch)).exists) return {
|
|
7628
|
+
if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
|
|
7283
7629
|
name: "Remote sync branch",
|
|
7284
7630
|
status: "warn",
|
|
7285
7631
|
message: `${remote}/${syncBranch} not found`,
|
|
@@ -7308,7 +7654,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7308
7654
|
status: "ok"
|
|
7309
7655
|
};
|
|
7310
7656
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7311
|
-
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7657
|
+
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7312
7658
|
name: "Sync status",
|
|
7313
7659
|
status: "warn",
|
|
7314
7660
|
message: `${localIssueCount} local issues, remote branch not found`,
|
|
@@ -7454,6 +7800,7 @@ var ConfigShowHandler = class extends BaseCommand {
|
|
|
7454
7800
|
console.log(`${colors.dim("sync:")}`);
|
|
7455
7801
|
console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
|
|
7456
7802
|
console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
|
|
7803
|
+
console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
|
|
7457
7804
|
console.log(`${colors.dim("display:")}`);
|
|
7458
7805
|
console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
|
|
7459
7806
|
console.log(`${colors.dim("settings:")}`);
|
|
@@ -7589,9 +7936,9 @@ async function listAtticEntries(tbdRoot, filterById) {
|
|
|
7589
7936
|
var AtticListHandler = class extends BaseCommand {
|
|
7590
7937
|
async run(id) {
|
|
7591
7938
|
const tbdRoot = await requireInit();
|
|
7939
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7592
7940
|
const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
|
|
7593
|
-
const
|
|
7594
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7941
|
+
const prefix = config.display.id_prefix;
|
|
7595
7942
|
const showDebug = this.ctx.debug;
|
|
7596
7943
|
const output = entries.map((e) => ({
|
|
7597
7944
|
id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
|
|
@@ -7616,10 +7963,10 @@ var AtticListHandler = class extends BaseCommand {
|
|
|
7616
7963
|
var AtticShowHandler = class extends BaseCommand {
|
|
7617
7964
|
async run(id, timestamp) {
|
|
7618
7965
|
const tbdRoot = await requireInit();
|
|
7966
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7619
7967
|
const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7620
7968
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
7621
|
-
const
|
|
7622
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7969
|
+
const prefix = config.display.id_prefix;
|
|
7623
7970
|
const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
|
|
7624
7971
|
this.output.data(entry, () => {
|
|
7625
7972
|
const colors = this.output.getColors();
|
|
@@ -7645,6 +7992,7 @@ var AtticShowHandler = class extends BaseCommand {
|
|
|
7645
7992
|
var AtticRestoreHandler = class extends BaseCommand {
|
|
7646
7993
|
async run(id, timestamp) {
|
|
7647
7994
|
const tbdRoot = await requireInit();
|
|
7995
|
+
await loadDataContext(tbdRoot);
|
|
7648
7996
|
const normalizedId = normalizeIssueId(id);
|
|
7649
7997
|
const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7650
7998
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
@@ -7652,24 +8000,24 @@ var AtticRestoreHandler = class extends BaseCommand {
|
|
|
7652
8000
|
id: normalizedId,
|
|
7653
8001
|
field: entry.field
|
|
7654
8002
|
})) return;
|
|
7655
|
-
|
|
7656
|
-
let issue;
|
|
7657
|
-
try {
|
|
7658
|
-
issue = await readIssue(dataSyncDir, normalizedId);
|
|
7659
|
-
} catch {
|
|
7660
|
-
throw new NotFoundError("Issue", id);
|
|
7661
|
-
}
|
|
7662
|
-
const field = entry.field;
|
|
7663
|
-
if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
|
|
7664
|
-
else throw new ValidationError(`Cannot restore field: ${entry.field}`);
|
|
7665
|
-
issue.version += 1;
|
|
7666
|
-
issue.updated_at = now();
|
|
8003
|
+
let displayId = id;
|
|
7667
8004
|
await this.execute(async () => {
|
|
7668
|
-
await
|
|
8005
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
8006
|
+
let issue;
|
|
8007
|
+
try {
|
|
8008
|
+
issue = await readIssue(dataSyncDir, normalizedId);
|
|
8009
|
+
} catch {
|
|
8010
|
+
throw new NotFoundError("Issue", id);
|
|
8011
|
+
}
|
|
8012
|
+
const field = entry.field;
|
|
8013
|
+
if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
|
|
8014
|
+
else throw new ValidationError(`Cannot restore field: ${entry.field}`);
|
|
8015
|
+
issue.version += 1;
|
|
8016
|
+
issue.updated_at = now();
|
|
8017
|
+
await writeIssue(dataSyncDir, issue);
|
|
8018
|
+
displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, config.display.id_prefix) : formatDisplayId(normalizedId, mapping, config.display.id_prefix);
|
|
8019
|
+
});
|
|
7669
8020
|
}, "Failed to restore from attic");
|
|
7670
|
-
const mapping = await loadIdMapping(dataSyncDir);
|
|
7671
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7672
|
-
const displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, prefix) : formatDisplayId(normalizedId, mapping, prefix);
|
|
7673
8021
|
this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
|
|
7674
8022
|
}
|
|
7675
8023
|
};
|
|
@@ -7789,14 +8137,18 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7789
8137
|
if (!file && !options.validate) throw new ValidationError("Provide a JSONL file path to import.\n\nFor Beads migration, use: tbd setup --from-beads\nFor workspace import, use: tbd import --workspace=<name> or --outbox");
|
|
7790
8138
|
if (options.validate) {
|
|
7791
8139
|
this.tbdRoot = await requireInit();
|
|
7792
|
-
this.
|
|
7793
|
-
|
|
8140
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8141
|
+
this.dataSyncDir = dataSyncDir;
|
|
8142
|
+
await this.validateImport(options);
|
|
8143
|
+
});
|
|
7794
8144
|
return;
|
|
7795
8145
|
}
|
|
7796
8146
|
if (file) {
|
|
7797
8147
|
this.tbdRoot = await requireInit();
|
|
7798
|
-
this.
|
|
7799
|
-
|
|
8148
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8149
|
+
this.dataSyncDir = dataSyncDir;
|
|
8150
|
+
await this.importFromFile(file, options);
|
|
8151
|
+
});
|
|
7800
8152
|
}
|
|
7801
8153
|
}
|
|
7802
8154
|
/**
|
|
@@ -7804,7 +8156,6 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7804
8156
|
*/
|
|
7805
8157
|
async importFromWorkspaceCmd(options) {
|
|
7806
8158
|
this.tbdRoot = await requireInit();
|
|
7807
|
-
this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
|
|
7808
8159
|
const wsOptions = {
|
|
7809
8160
|
workspace: options.workspace,
|
|
7810
8161
|
dir: options.dir,
|
|
@@ -7815,7 +8166,10 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7815
8166
|
const spinner = this.output.spinner("Importing from workspace...");
|
|
7816
8167
|
wsOptions.logger = this.output.logger(spinner);
|
|
7817
8168
|
const result = await this.execute(async () => {
|
|
7818
|
-
return await
|
|
8169
|
+
return await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8170
|
+
this.dataSyncDir = dataSyncDir;
|
|
8171
|
+
return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
|
|
8172
|
+
});
|
|
7819
8173
|
}, "Failed to import from workspace");
|
|
7820
8174
|
spinner.stop();
|
|
7821
8175
|
if (!result) return;
|
|
@@ -8461,7 +8815,12 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8461
8815
|
const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
|
|
8462
8816
|
const remote = config?.sync.remote ?? "origin";
|
|
8463
8817
|
const tbdDir = join(tbdRoot, ".tbd");
|
|
8464
|
-
const
|
|
8818
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot).catch((error) => {
|
|
8819
|
+
this.output.debug(`resolveSharedTbdPaths failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8820
|
+
return null;
|
|
8821
|
+
});
|
|
8822
|
+
const worktreePath = sharedPaths?.sharedWorktreePath ?? join(tbdDir, "data-sync-worktree");
|
|
8823
|
+
const legacyWorktreePath = join(tbdDir, "data-sync-worktree");
|
|
8465
8824
|
const displayPath = (p) => relative(process.cwd(), p) || p;
|
|
8466
8825
|
const items = [];
|
|
8467
8826
|
let worktreeExists = false;
|
|
@@ -8471,6 +8830,13 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8471
8830
|
const worktreeStats = await this.getDirectoryStats(worktreePath);
|
|
8472
8831
|
items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
|
|
8473
8832
|
} catch {}
|
|
8833
|
+
let legacyWorktreeExists = false;
|
|
8834
|
+
if (legacyWorktreePath !== worktreePath) try {
|
|
8835
|
+
await access(legacyWorktreePath);
|
|
8836
|
+
legacyWorktreeExists = true;
|
|
8837
|
+
const legacyStats = await this.getDirectoryStats(legacyWorktreePath);
|
|
8838
|
+
items.push(` - Legacy worktree: ${displayPath(legacyWorktreePath)} (${legacyStats.files} files)`);
|
|
8839
|
+
} catch {}
|
|
8474
8840
|
let localBranchExists = false;
|
|
8475
8841
|
try {
|
|
8476
8842
|
execSync(`git rev-parse --verify ${syncBranch}`, {
|
|
@@ -8534,6 +8900,23 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8534
8900
|
console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
|
|
8535
8901
|
}
|
|
8536
8902
|
}
|
|
8903
|
+
if (legacyWorktreeExists) try {
|
|
8904
|
+
execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
|
|
8905
|
+
encoding: "utf-8",
|
|
8906
|
+
stdio: [
|
|
8907
|
+
"ignore",
|
|
8908
|
+
"pipe",
|
|
8909
|
+
"ignore"
|
|
8910
|
+
]
|
|
8911
|
+
});
|
|
8912
|
+
console.log(` ${colors.success("✓")} Removed legacy git worktree`);
|
|
8913
|
+
} catch {
|
|
8914
|
+
await rm(legacyWorktreePath, {
|
|
8915
|
+
recursive: true,
|
|
8916
|
+
force: true
|
|
8917
|
+
});
|
|
8918
|
+
console.log(` ${colors.success("✓")} Removed legacy worktree directory`);
|
|
8919
|
+
}
|
|
8537
8920
|
if (localBranchExists && !options.keepBranch) try {
|
|
8538
8921
|
execSync(`git branch -D ${syncBranch}`, {
|
|
8539
8922
|
encoding: "utf-8",
|
|
@@ -8579,6 +8962,15 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8579
8962
|
} catch (error) {
|
|
8580
8963
|
throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
8581
8964
|
}
|
|
8965
|
+
if (sharedPaths) try {
|
|
8966
|
+
await rm(sharedPaths.sharedTbdDir, {
|
|
8967
|
+
recursive: true,
|
|
8968
|
+
force: true
|
|
8969
|
+
});
|
|
8970
|
+
console.log(` ${colors.success("✓")} Removed shared common-dir metadata`);
|
|
8971
|
+
} catch {
|
|
8972
|
+
console.log(` ${colors.warn("⚠")} Could not remove shared common-dir metadata`);
|
|
8973
|
+
}
|
|
8582
8974
|
console.log("");
|
|
8583
8975
|
this.output.success("tbd has been uninstalled from this repository.");
|
|
8584
8976
|
if (options.keepBranch && localBranchExists) {
|
|
@@ -9149,7 +9541,8 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
9149
9541
|
*/
|
|
9150
9542
|
async getIssueStats(tbdRoot) {
|
|
9151
9543
|
try {
|
|
9152
|
-
const
|
|
9544
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
9545
|
+
const issues = await listIssues(dataSyncDir);
|
|
9153
9546
|
let open = 0;
|
|
9154
9547
|
let inProgress = 0;
|
|
9155
9548
|
const blockedIds = /* @__PURE__ */ new Set();
|
|
@@ -10825,25 +11218,26 @@ var SetupDefaultHandler = class extends BaseCommand {
|
|
|
10825
11218
|
if (!gitRoot) throw new CLIError("Could not determine git repository root.");
|
|
10826
11219
|
const projectDir = gitRoot;
|
|
10827
11220
|
const hasTbd = await isInitialized(projectDir);
|
|
10828
|
-
const hasBeads = await pathExists(join(projectDir, ".beads"));
|
|
11221
|
+
const hasBeads = await pathExists$1(join(projectDir, ".beads"));
|
|
10829
11222
|
if (options.fromBeads && !hasBeads) throw new CLIError("The --from-beads flag requires a .beads/ directory to migrate from.\nFor fresh setup, use: tbd setup --auto --prefix=<name>");
|
|
10830
11223
|
console.log("Checking repository...");
|
|
10831
11224
|
console.log(` ${colors.success("✓")} Git repository detected`);
|
|
10832
11225
|
if (hasTbd) {
|
|
10833
11226
|
const { config, migrated, changes } = await readConfigWithMigration(projectDir);
|
|
10834
11227
|
console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
|
|
10835
|
-
let
|
|
11228
|
+
let ghCliChanged = false;
|
|
10836
11229
|
if (options.ghCli === false && config.settings.use_gh_cli !== false) {
|
|
10837
11230
|
config.settings.use_gh_cli = false;
|
|
10838
|
-
|
|
11231
|
+
ghCliChanged = true;
|
|
10839
11232
|
}
|
|
10840
|
-
if (
|
|
11233
|
+
if (migrated) {
|
|
11234
|
+
await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
|
|
11235
|
+
console.log(` ${colors.success("✓")} Config migrated to latest format`);
|
|
11236
|
+
for (const change of changes) console.log(` ${colors.dim(change)}`);
|
|
11237
|
+
}
|
|
11238
|
+
if (ghCliChanged) {
|
|
10841
11239
|
await writeConfig(projectDir, config);
|
|
10842
|
-
|
|
10843
|
-
console.log(` ${colors.success("✓")} Config migrated to latest format`);
|
|
10844
|
-
for (const change of changes) console.log(` ${colors.dim(change)}`);
|
|
10845
|
-
}
|
|
10846
|
-
if (options.ghCli === false) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
|
|
11240
|
+
console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
|
|
10847
11241
|
}
|
|
10848
11242
|
console.log("");
|
|
10849
11243
|
await this.handleAlreadyInitialized(projectDir, isAutoMode);
|
|
@@ -10991,7 +11385,7 @@ Example:
|
|
|
10991
11385
|
}
|
|
10992
11386
|
async initializeTbd(cwd, prefix) {
|
|
10993
11387
|
const colors = this.output.getColors();
|
|
10994
|
-
await initConfig(cwd, VERSION, prefix);
|
|
11388
|
+
const config = await initConfig(cwd, VERSION, prefix);
|
|
10995
11389
|
console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
|
|
10996
11390
|
const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
10997
11391
|
"# Synced documentation cache (regenerated by tbd sync --docs)",
|
|
@@ -11022,7 +11416,10 @@ Example:
|
|
|
11022
11416
|
if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
|
|
11023
11417
|
else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
|
|
11024
11418
|
try {
|
|
11025
|
-
await
|
|
11419
|
+
await withSharedDataSyncLock(cwd, async () => {
|
|
11420
|
+
await initWorktree(cwd);
|
|
11421
|
+
await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
|
|
11422
|
+
});
|
|
11026
11423
|
const health = await checkWorktreeHealth(cwd);
|
|
11027
11424
|
if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
|
|
11028
11425
|
else {
|
|
@@ -11220,18 +11617,18 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11220
11617
|
};
|
|
11221
11618
|
if (mode === "off") return result;
|
|
11222
11619
|
if (mode === "auto") {
|
|
11223
|
-
const hasClaudeDir = await pathExists(GLOBAL_CLAUDE_DIR);
|
|
11620
|
+
const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
|
|
11224
11621
|
const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
|
|
11225
11622
|
if (!hasClaudeDir && !hasClaudeEnv) return result;
|
|
11226
11623
|
}
|
|
11227
11624
|
result.detected = true;
|
|
11228
11625
|
const claudePaths = getClaudePaths(cwd);
|
|
11229
11626
|
try {
|
|
11230
|
-
if (await pathExists(claudePaths.settings)) {
|
|
11627
|
+
if (await pathExists$1(claudePaths.settings)) {
|
|
11231
11628
|
const content = await readFile(claudePaths.settings, "utf-8");
|
|
11232
11629
|
const hooks = JSON.parse(content).hooks;
|
|
11233
11630
|
if (hooks) {
|
|
11234
|
-
if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists(claudePaths.skill)) result.alreadyInstalled = true;
|
|
11631
|
+
if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists$1(claudePaths.skill)) result.alreadyInstalled = true;
|
|
11235
11632
|
}
|
|
11236
11633
|
}
|
|
11237
11634
|
const handler = new SetupClaudeHandler(this.cmd);
|
|
@@ -11252,7 +11649,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11252
11649
|
};
|
|
11253
11650
|
if (mode === "off") return result;
|
|
11254
11651
|
const agentsPath = getAgentsMdPath(cwd);
|
|
11255
|
-
const hasAgentsMd = await pathExists(agentsPath);
|
|
11652
|
+
const hasAgentsMd = await pathExists$1(agentsPath);
|
|
11256
11653
|
if (mode === "auto") {
|
|
11257
11654
|
const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
|
|
11258
11655
|
if (!hasAgentsMd && !hasCodexEnv) return result;
|
|
@@ -11322,7 +11719,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
|
|
|
11322
11719
|
var SaveHandler = class extends BaseCommand {
|
|
11323
11720
|
async run(options) {
|
|
11324
11721
|
const tbdRoot = await requireInit();
|
|
11325
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
11326
11722
|
if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
|
|
11327
11723
|
const saveOptions = {
|
|
11328
11724
|
workspace: options.workspace,
|
|
@@ -11334,7 +11730,9 @@ var SaveHandler = class extends BaseCommand {
|
|
|
11334
11730
|
const spinner = this.output.spinner("Saving issues...");
|
|
11335
11731
|
saveOptions.logger = this.output.logger(spinner);
|
|
11336
11732
|
const result = await this.execute(async () => {
|
|
11337
|
-
return await
|
|
11733
|
+
return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
11734
|
+
return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
|
|
11735
|
+
});
|
|
11338
11736
|
}, "Failed to save issues");
|
|
11339
11737
|
spinner.stop();
|
|
11340
11738
|
if (!result) return;
|