get-tbd 0.1.30 → 0.2.1
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 +3193 -2220
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +1545 -821
- 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/SKILL.md +2 -2
- 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 +120 -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/shortcuts/system/skill-baseline.md +2 -2
- 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-CqrrLgeX.mjs → id-mapping-687_UEsy.mjs} +198 -124
- package/dist/id-mapping-687_UEsy.mjs.map +1 -0
- package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-mtoSP9Qt.mjs} +1 -1
- 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-CtZIHxYM.mjs} +3 -3
- package/dist/src-CtZIHxYM.mjs.map +1 -0
- package/dist/tbd +3193 -2220
- 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-CtZIHxYM.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-687_UEsy.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,25 +620,15 @@ var SyncError = class extends CLIError {
|
|
|
620
620
|
}
|
|
621
621
|
};
|
|
622
622
|
/**
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
623
|
+
* Unrelated-history error - the local and remote tbd-sync share no common
|
|
624
|
+
* ancestor, so a push can never fast-forward and a plain git merge refuses.
|
|
625
|
+
* `tbd sync` cannot resolve this; the rescue lives in `tbd doctor --fix`.
|
|
626
|
+
* See: plan-2026-05-29-tbd-sync-unrelated-history-hardening.md
|
|
626
627
|
*/
|
|
627
|
-
var
|
|
628
|
-
constructor(
|
|
629
|
-
super(
|
|
630
|
-
this.name = "
|
|
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";
|
|
628
|
+
var UnrelatedHistoriesError = class extends SyncError {
|
|
629
|
+
constructor(remote = "origin", syncBranch = "tbd-sync") {
|
|
630
|
+
super(`${remote}/${syncBranch} has an unrelated history (no common ancestor) — push cannot fast-forward and a merge would refuse.\nRun \`tbd doctor --fix\` to reconcile the unrelated histories (non-destructive; a backup branch is created first).`);
|
|
631
|
+
this.name = "UnrelatedHistoriesError";
|
|
642
632
|
}
|
|
643
633
|
};
|
|
644
634
|
/**
|
|
@@ -706,10 +696,7 @@ var BaseCommand = class {
|
|
|
706
696
|
try {
|
|
707
697
|
return await action();
|
|
708
698
|
} catch (error) {
|
|
709
|
-
if (error instanceof CLIError)
|
|
710
|
-
this.output.error(error.message);
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
699
|
+
if (error instanceof CLIError) throw error;
|
|
713
700
|
const originalError = error instanceof Error ? error : void 0;
|
|
714
701
|
const detail = originalError?.message;
|
|
715
702
|
const fullMessage = detail && detail !== errorMessage ? `${errorMessage}: ${detail}` : errorMessage;
|
|
@@ -740,7 +727,7 @@ var BaseCommand = class {
|
|
|
740
727
|
/**
|
|
741
728
|
* Check if a path exists on the filesystem.
|
|
742
729
|
*/
|
|
743
|
-
async function pathExists(path) {
|
|
730
|
+
async function pathExists$1(path) {
|
|
744
731
|
try {
|
|
745
732
|
await access(path);
|
|
746
733
|
return true;
|
|
@@ -780,7 +767,7 @@ function hasGitignorePattern(content, pattern) {
|
|
|
780
767
|
async function ensureGitignorePatterns(gitignorePath, patterns, header) {
|
|
781
768
|
let content = "";
|
|
782
769
|
let created = false;
|
|
783
|
-
if (await pathExists(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
770
|
+
if (await pathExists$1(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
784
771
|
else created = true;
|
|
785
772
|
const entries = [];
|
|
786
773
|
let currentPreamble = [];
|
|
@@ -942,6 +929,124 @@ function nowFilenameTimestamp() {
|
|
|
942
929
|
return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
|
|
943
930
|
}
|
|
944
931
|
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/utils/zod-error-utils.ts
|
|
934
|
+
/**
|
|
935
|
+
* Helpers for rendering Zod errors without relying on object inspection.
|
|
936
|
+
*/
|
|
937
|
+
/**
|
|
938
|
+
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
939
|
+
*/
|
|
940
|
+
function formatZodError(error) {
|
|
941
|
+
const messages = error.issues.map((issue) => {
|
|
942
|
+
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
943
|
+
});
|
|
944
|
+
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
948
|
+
*/
|
|
949
|
+
function formatUnknownError(error) {
|
|
950
|
+
if (error instanceof ZodError) return formatZodError(error);
|
|
951
|
+
if (error instanceof Error) return error.message;
|
|
952
|
+
return String(error);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/file/storage.ts
|
|
957
|
+
/**
|
|
958
|
+
* Storage layer for issue files.
|
|
959
|
+
*
|
|
960
|
+
* Provides atomic file operations and issue CRUD operations.
|
|
961
|
+
* All operations work on the data-sync directory selected by the caller. In production
|
|
962
|
+
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
963
|
+
*
|
|
964
|
+
* See: tbd-design.md §3.2 Storage Layer
|
|
965
|
+
*/
|
|
966
|
+
/**
|
|
967
|
+
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
968
|
+
*/
|
|
969
|
+
const ISSUE_READ_BATCH_SIZE = 200;
|
|
970
|
+
/**
|
|
971
|
+
* Get the path to an issue file.
|
|
972
|
+
*/
|
|
973
|
+
function getIssuePath(baseDir, id) {
|
|
974
|
+
return join(baseDir, "issues", `${id}.md`);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Read an issue from the worktree.
|
|
978
|
+
* @throws If the issue file doesn't exist or is invalid.
|
|
979
|
+
*/
|
|
980
|
+
async function readIssue(baseDir, id) {
|
|
981
|
+
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Write an issue to the worktree.
|
|
985
|
+
* Uses atomic write to prevent corruption.
|
|
986
|
+
*/
|
|
987
|
+
async function writeIssue(baseDir, issue) {
|
|
988
|
+
const validIssue = IssueSchema.parse(issue);
|
|
989
|
+
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* List all issues in the worktree.
|
|
993
|
+
* Returns empty array if issues directory doesn't exist.
|
|
994
|
+
*
|
|
995
|
+
* Uses parallel file reading for better performance with many issues.
|
|
996
|
+
*/
|
|
997
|
+
async function listIssues(baseDir, options = {}) {
|
|
998
|
+
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
999
|
+
const issuesDir = join(baseDir, "issues");
|
|
1000
|
+
let files;
|
|
1001
|
+
try {
|
|
1002
|
+
files = await readdir(issuesDir);
|
|
1003
|
+
} catch {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
1007
|
+
const issues = [];
|
|
1008
|
+
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
1009
|
+
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
1010
|
+
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
1011
|
+
const filePath = join(issuesDir, file);
|
|
1012
|
+
try {
|
|
1013
|
+
return {
|
|
1014
|
+
file,
|
|
1015
|
+
content: await readFile(filePath, "utf-8")
|
|
1016
|
+
};
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
return {
|
|
1019
|
+
file,
|
|
1020
|
+
error: formatUnknownError(error)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
}));
|
|
1024
|
+
for (const result of fileContents) {
|
|
1025
|
+
if ("error" in result) {
|
|
1026
|
+
reportInvalidIssueFile({
|
|
1027
|
+
file: result.file,
|
|
1028
|
+
reason: `failed to read file: ${result.error}`
|
|
1029
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
const issue = parseIssue(result.content);
|
|
1034
|
+
issues.push(issue);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
reportInvalidIssueFile({
|
|
1037
|
+
file: result.file,
|
|
1038
|
+
reason: formatUnknownError(error)
|
|
1039
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return issues;
|
|
1044
|
+
}
|
|
1045
|
+
function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
|
|
1046
|
+
onInvalidIssue?.(invalidIssue);
|
|
1047
|
+
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
945
1050
|
//#endregion
|
|
946
1051
|
//#region src/file/git.ts
|
|
947
1052
|
/**
|
|
@@ -956,6 +1061,55 @@ function nowFilenameTimestamp() {
|
|
|
956
1061
|
*/
|
|
957
1062
|
const execFileAsync$1 = promisify(execFile);
|
|
958
1063
|
/**
|
|
1064
|
+
* Error thrown by {@link git} when a git command exits non-zero.
|
|
1065
|
+
*
|
|
1066
|
+
* Carries the process `exitCode` so callers can branch on git's exit status
|
|
1067
|
+
* (e.g. `ls-remote --exit-code` => 2 means "ref absent", `merge-base` => 1
|
|
1068
|
+
* means "no common ancestor") instead of string-matching stderr. The original
|
|
1069
|
+
* message/stderr is preserved so existing message-based classifiers
|
|
1070
|
+
* (classifySyncError) keep working.
|
|
1071
|
+
*/
|
|
1072
|
+
var GitError = class GitError extends Error {
|
|
1073
|
+
/** Process exit code, or null for a spawn failure (e.g. git not found). */
|
|
1074
|
+
exitCode;
|
|
1075
|
+
stderr;
|
|
1076
|
+
stdout;
|
|
1077
|
+
args;
|
|
1078
|
+
constructor(message, opts) {
|
|
1079
|
+
super(message);
|
|
1080
|
+
this.name = "GitError";
|
|
1081
|
+
this.exitCode = opts.exitCode;
|
|
1082
|
+
this.stderr = opts.stderr;
|
|
1083
|
+
this.stdout = opts.stdout;
|
|
1084
|
+
this.args = opts.args;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Wrap a raw execFile rejection into a GitError.
|
|
1088
|
+
*
|
|
1089
|
+
* Node's execFile rejection carries `code` (numeric exit code for a normal
|
|
1090
|
+
* exit, or a string like 'ENOENT' for a spawn failure), plus `stderr`/`stdout`.
|
|
1091
|
+
*/
|
|
1092
|
+
static from(err, args) {
|
|
1093
|
+
const raw = err;
|
|
1094
|
+
const exitCode = typeof raw.code === "number" ? raw.code : null;
|
|
1095
|
+
const stderr = typeof raw.stderr === "string" ? raw.stderr : "";
|
|
1096
|
+
const stdout = typeof raw.stdout === "string" ? raw.stdout : "";
|
|
1097
|
+
return new GitError(raw.message ?? `git ${args.join(" ")} failed`, {
|
|
1098
|
+
exitCode,
|
|
1099
|
+
stderr,
|
|
1100
|
+
stdout,
|
|
1101
|
+
args
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
/**
|
|
1106
|
+
* Read the git exit code from a thrown value, or null if it is not a GitError
|
|
1107
|
+
* (or carries no numeric code, e.g. a spawn failure).
|
|
1108
|
+
*/
|
|
1109
|
+
function exitCodeOf(err) {
|
|
1110
|
+
return err instanceof GitError ? err.exitCode : null;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
959
1113
|
* Maximum buffer size for git command output.
|
|
960
1114
|
*
|
|
961
1115
|
* Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
|
|
@@ -970,8 +1124,43 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
|
|
|
970
1124
|
* Uses execFile for security - prevents shell injection attacks.
|
|
971
1125
|
*/
|
|
972
1126
|
async function git(...args) {
|
|
973
|
-
|
|
974
|
-
|
|
1127
|
+
try {
|
|
1128
|
+
const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
|
|
1129
|
+
return stdout.trim();
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
throw GitError.from(err, args);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Like {@link git} but with `GIT_TERMINAL_PROMPT=0` so a network operation
|
|
1136
|
+
* (e.g. a best-effort push) fails fast instead of blocking on a credential
|
|
1137
|
+
* prompt in a non-interactive environment.
|
|
1138
|
+
*/
|
|
1139
|
+
async function gitNoPrompt(...args) {
|
|
1140
|
+
try {
|
|
1141
|
+
const { stdout } = await execFileAsync$1("git", args, {
|
|
1142
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
1143
|
+
env: {
|
|
1144
|
+
...process.env,
|
|
1145
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
return stdout.trim();
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
throw GitError.from(err, args);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Run `git commit` in a worktree with gpg signing disabled at the command level.
|
|
1155
|
+
*
|
|
1156
|
+
* Internal tbd-sync commits are machine-generated data commits on the data branch,
|
|
1157
|
+
* not user commits. They must not depend on ambient `commit.gpgsign` config: in
|
|
1158
|
+
* signed-by-default environments without a usable signing key, an unguarded
|
|
1159
|
+
* `git commit` fails and leaves `tbd-sync` unborn, which the f04 fail-closed
|
|
1160
|
+
* health check then surfaces as "worktree corrupted" on every command.
|
|
1161
|
+
*/
|
|
1162
|
+
async function gitCommit(workdir, ...args) {
|
|
1163
|
+
return git("-c", "commit.gpgsign=false", "-C", workdir, "commit", ...args);
|
|
975
1164
|
}
|
|
976
1165
|
/**
|
|
977
1166
|
* Minimum Git version required.
|
|
@@ -1088,23 +1277,6 @@ function getUpgradeInstructions(currentVersion) {
|
|
|
1088
1277
|
return `Git ${versionStr} detected. Git ${MIN_GIT_VERSION}+ required for tbd.\nUpgrade: ${upgradeUrl}`;
|
|
1089
1278
|
}
|
|
1090
1279
|
/**
|
|
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
1280
|
* Field-level merge strategies for Issue fields.
|
|
1109
1281
|
* See: tbd-design.md §3.5 Merge Rules
|
|
1110
1282
|
*/
|
|
@@ -1274,15 +1446,42 @@ function mergeIssues(base, local, remote) {
|
|
|
1274
1446
|
};
|
|
1275
1447
|
}
|
|
1276
1448
|
/**
|
|
1449
|
+
* Categorize issues from two unrelated tbd-sync roots by id (which embeds the
|
|
1450
|
+
* globally unique ULID). Pure and git-free so the rescue is robust to the
|
|
1451
|
+
* missing merge base. Substantive equality ignores version/updated_at so
|
|
1452
|
+
* trivial bumps don't masquerade as conflicts.
|
|
1453
|
+
*/
|
|
1454
|
+
function categorizeIssuesByUlid(local, remote) {
|
|
1455
|
+
const localById = new Map(local.map((i) => [i.id, i]));
|
|
1456
|
+
const remoteById = new Map(remote.map((i) => [i.id, i]));
|
|
1457
|
+
const buckets = {
|
|
1458
|
+
localOnly: [],
|
|
1459
|
+
remoteOnly: [],
|
|
1460
|
+
bothIdentical: [],
|
|
1461
|
+
bothDifferent: []
|
|
1462
|
+
};
|
|
1463
|
+
for (const [id, localIssue] of localById) {
|
|
1464
|
+
const remoteIssue = remoteById.get(id);
|
|
1465
|
+
if (!remoteIssue) buckets.localOnly.push(localIssue);
|
|
1466
|
+
else if (issuesSubstantivelyEqual(localIssue, remoteIssue)) buckets.bothIdentical.push(remoteIssue);
|
|
1467
|
+
else buckets.bothDifferent.push({
|
|
1468
|
+
local: localIssue,
|
|
1469
|
+
remote: remoteIssue
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
for (const [id, remoteIssue] of remoteById) if (!localById.has(id)) buckets.remoteOnly.push(remoteIssue);
|
|
1473
|
+
return buckets;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1277
1476
|
* Maximum retry attempts for push operations.
|
|
1278
1477
|
*/
|
|
1279
1478
|
const MAX_PUSH_RETRIES = 3;
|
|
1280
1479
|
/**
|
|
1281
|
-
* Check if error is a non-fast-forward rejection.
|
|
1480
|
+
* Check if error is a non-fast-forward rejection (also the init race signal).
|
|
1282
1481
|
*/
|
|
1283
1482
|
function isNonFastForward(error) {
|
|
1284
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1285
|
-
return
|
|
1483
|
+
const msg = error instanceof GitError ? `${error.stderr}\n${error.message}` : error instanceof Error ? error.message : String(error);
|
|
1484
|
+
return /non-fast-forward|fetch first|rejected|updates were rejected/i.test(msg);
|
|
1286
1485
|
}
|
|
1287
1486
|
/**
|
|
1288
1487
|
* Push with retry and merge on conflict.
|
|
@@ -1330,36 +1529,63 @@ async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
|
|
|
1330
1529
|
/**
|
|
1331
1530
|
* Get the current branch name.
|
|
1332
1531
|
*/
|
|
1333
|
-
async function getCurrentBranch() {
|
|
1334
|
-
return git("rev-parse", "--abbrev-ref", "HEAD");
|
|
1532
|
+
async function getCurrentBranch(baseDir) {
|
|
1533
|
+
return git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--abbrev-ref", "HEAD");
|
|
1335
1534
|
}
|
|
1336
1535
|
/**
|
|
1337
1536
|
* Check if a branch exists locally.
|
|
1338
1537
|
*/
|
|
1339
|
-
async function branchExists(branch) {
|
|
1538
|
+
async function branchExists(branch, baseDir) {
|
|
1340
1539
|
try {
|
|
1341
|
-
await git("rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1540
|
+
await git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1342
1541
|
return true;
|
|
1343
1542
|
} catch {
|
|
1344
1543
|
return false;
|
|
1345
1544
|
}
|
|
1346
1545
|
}
|
|
1347
1546
|
/**
|
|
1348
|
-
*
|
|
1547
|
+
* Probe whether a remote branch exists, distinguishing a clean "absent" from a
|
|
1548
|
+
* "check failed".
|
|
1549
|
+
*
|
|
1550
|
+
* `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
|
|
1551
|
+
* matched; any other failure (auth/network/transient, or git not found) is a
|
|
1552
|
+
* check failure. Orphan-creating callers MUST branch on all three states and
|
|
1553
|
+
* never treat `check-failed` as `absent` — doing so risks creating a divergent
|
|
1554
|
+
* local branch when the remote is merely unreachable.
|
|
1349
1555
|
*/
|
|
1350
|
-
async function
|
|
1556
|
+
async function probeRemoteBranch(remote, branch, baseDir) {
|
|
1557
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1351
1558
|
try {
|
|
1352
|
-
await git("ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1559
|
+
await git(...dirArgs, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1560
|
+
return "present";
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
return exitCodeOf(err) === 2 ? "absent" : "check-failed";
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
async function pathExists(path) {
|
|
1566
|
+
try {
|
|
1567
|
+
await access(path);
|
|
1353
1568
|
return true;
|
|
1354
1569
|
} catch {
|
|
1355
1570
|
return false;
|
|
1356
1571
|
}
|
|
1357
1572
|
}
|
|
1573
|
+
async function pathsReferToSameLocation(a, b) {
|
|
1574
|
+
if (normalize(a) === normalize(b)) return true;
|
|
1575
|
+
try {
|
|
1576
|
+
return normalize(await realpath(a)) === normalize(await realpath(b));
|
|
1577
|
+
} catch {
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
async function getSharedPaths(baseDir) {
|
|
1582
|
+
return resolveSharedTbdPaths(baseDir);
|
|
1583
|
+
}
|
|
1358
1584
|
/**
|
|
1359
1585
|
* Check if the hidden worktree exists and is valid.
|
|
1360
1586
|
*/
|
|
1361
1587
|
async function worktreeExists(baseDir) {
|
|
1362
|
-
const worktreePath =
|
|
1588
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1363
1589
|
try {
|
|
1364
1590
|
await access(worktreePath);
|
|
1365
1591
|
await access(join(worktreePath, ".git"));
|
|
@@ -1373,15 +1599,15 @@ async function worktreeExists(baseDir) {
|
|
|
1373
1599
|
* See: tbd-design.md §2.3 Worktree Lifecycle
|
|
1374
1600
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §3
|
|
1375
1601
|
*/
|
|
1376
|
-
async function checkWorktreeHealth(baseDir) {
|
|
1377
|
-
const worktreePath =
|
|
1602
|
+
async function checkWorktreeHealth(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1603
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1378
1604
|
try {
|
|
1379
1605
|
const lines = (await git("-C", baseDir, "worktree", "list", "--porcelain")).split("\n");
|
|
1380
1606
|
let foundWorktree = false;
|
|
1381
1607
|
let isPrunable = false;
|
|
1382
1608
|
for (let i = 0; i < lines.length; i++) {
|
|
1383
1609
|
const line = lines[i];
|
|
1384
|
-
if (line?.startsWith("worktree ") && line.
|
|
1610
|
+
if (line?.startsWith("worktree ") && await pathsReferToSameLocation(line.slice(9), worktreePath)) {
|
|
1385
1611
|
foundWorktree = true;
|
|
1386
1612
|
for (let j = i + 1; j < lines.length && !lines[j]?.startsWith("worktree "); j++) if (lines[j]?.startsWith("prunable")) {
|
|
1387
1613
|
isPrunable = true;
|
|
@@ -1449,6 +1675,14 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1449
1675
|
} catch {
|
|
1450
1676
|
branch = null;
|
|
1451
1677
|
}
|
|
1678
|
+
if (branch !== syncBranch) return {
|
|
1679
|
+
exists: true,
|
|
1680
|
+
valid: false,
|
|
1681
|
+
status: "corrupted",
|
|
1682
|
+
branch,
|
|
1683
|
+
commit,
|
|
1684
|
+
error: branch === null ? `Shared worktree is detached; expected branch ${syncBranch}` : `Shared worktree is on ${branch}; expected branch ${syncBranch}`
|
|
1685
|
+
};
|
|
1452
1686
|
return {
|
|
1453
1687
|
exists: true,
|
|
1454
1688
|
valid: true,
|
|
@@ -1467,22 +1701,170 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1467
1701
|
};
|
|
1468
1702
|
}
|
|
1469
1703
|
}
|
|
1704
|
+
function isLegacyDataSyncWorktreePath(path, sharedWorktreePath) {
|
|
1705
|
+
const normalized = normalize(path);
|
|
1706
|
+
if (normalized === normalize(sharedWorktreePath)) return false;
|
|
1707
|
+
return basename(normalized) === WORKTREE_DIR_NAME && basename(dirname(normalized)) === TBD_DIR;
|
|
1708
|
+
}
|
|
1709
|
+
async function listLegacyWorktreePaths(baseDir, sharedWorktreePath) {
|
|
1710
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1711
|
+
try {
|
|
1712
|
+
const worktreeList = await git("-C", baseDir, "worktree", "list", "--porcelain");
|
|
1713
|
+
for (const line of worktreeList.split("\n")) {
|
|
1714
|
+
if (!line.startsWith("worktree ")) continue;
|
|
1715
|
+
const worktreePath = line.slice(9);
|
|
1716
|
+
if (isLegacyDataSyncWorktreePath(worktreePath, sharedWorktreePath)) paths.add(worktreePath);
|
|
1717
|
+
}
|
|
1718
|
+
} catch {}
|
|
1719
|
+
const currentCheckoutLegacyPath = join(baseDir, LEGACY_WORKTREE_DIR);
|
|
1720
|
+
if (currentCheckoutLegacyPath !== sharedWorktreePath && await pathExists(currentCheckoutLegacyPath)) paths.add(currentCheckoutLegacyPath);
|
|
1721
|
+
return Array.from(paths);
|
|
1722
|
+
}
|
|
1723
|
+
async function preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch) {
|
|
1724
|
+
if (!await pathExists(join(legacyPath, ".git"))) return;
|
|
1725
|
+
if ((await git("-C", legacyPath, "status", "--porcelain").catch(() => "")).trim()) {
|
|
1726
|
+
await git("-C", legacyPath, "add", "-A");
|
|
1727
|
+
await gitCommit(legacyPath, "--no-verify", "-m", "tbd: preserve legacy sync data").catch((error) => {
|
|
1728
|
+
if (!(error instanceof Error ? error.message : String(error)).includes("nothing to commit")) throw error;
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const head = await git("-C", legacyPath, "rev-parse", "HEAD").catch(() => "");
|
|
1732
|
+
if (!head) return;
|
|
1733
|
+
const branchHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1734
|
+
if (!branchHead) {
|
|
1735
|
+
await git("-C", baseDir, "branch", syncBranch, head);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (head === branchHead) return;
|
|
1739
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", branchHead, head).then(() => true).catch(() => false)) {
|
|
1740
|
+
await git("-C", baseDir, "update-ref", `refs/heads/${syncBranch}`, head);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", head, branchHead).then(() => true).catch(() => false)) return;
|
|
1744
|
+
const backupBranch = `tbd-legacy-preserve-${nowFilenameTimestamp()}`;
|
|
1745
|
+
await git("-C", baseDir, "branch", backupBranch, head);
|
|
1746
|
+
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.`);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Preserve and remove f03 per-checkout sync worktrees before creating the shared worktree.
|
|
1750
|
+
*/
|
|
1751
|
+
async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1752
|
+
const { sharedWorktreePath } = await getSharedPaths(baseDir);
|
|
1753
|
+
const legacyPaths = await listLegacyWorktreePaths(baseDir, sharedWorktreePath);
|
|
1754
|
+
let migrated = 0;
|
|
1755
|
+
try {
|
|
1756
|
+
for (const legacyPath of legacyPaths) {
|
|
1757
|
+
await preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch);
|
|
1758
|
+
try {
|
|
1759
|
+
await git("-C", baseDir, "worktree", "remove", legacyPath, "--force");
|
|
1760
|
+
} catch {
|
|
1761
|
+
await rm(legacyPath, {
|
|
1762
|
+
recursive: true,
|
|
1763
|
+
force: true
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
migrated += 1;
|
|
1767
|
+
}
|
|
1768
|
+
if (legacyPaths.length > 0) await git("-C", baseDir, "worktree", "prune");
|
|
1769
|
+
return {
|
|
1770
|
+
success: true,
|
|
1771
|
+
migrated
|
|
1772
|
+
};
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
return {
|
|
1775
|
+
success: false,
|
|
1776
|
+
migrated,
|
|
1777
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Whether the given remote is configured (has a URL) in the repo at baseDir.
|
|
1783
|
+
* A local-only repo (no remote) is safe to orphan and never pushes.
|
|
1784
|
+
*/
|
|
1785
|
+
async function remoteIsConfigured(remote, baseDir) {
|
|
1786
|
+
try {
|
|
1787
|
+
await git("-C", baseDir, "config", "--get", `remote.${remote}.url`);
|
|
1788
|
+
return true;
|
|
1789
|
+
} catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Whether the data-sync worktree carries any user issue files (is-<ulid>.md),
|
|
1795
|
+
* as opposed to only the initial orphan scaffold (.gitkeep / .gitattributes).
|
|
1796
|
+
*/
|
|
1797
|
+
async function worktreeHasUserIssues(dataSyncPath) {
|
|
1798
|
+
try {
|
|
1799
|
+
return (await readdir(join(dataSyncPath, "issues"))).some((name) => /^is-.*\.md$/.test(name));
|
|
1800
|
+
} catch {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Push a freshly-created orphan tbd-sync immediately so "first init wins"
|
|
1806
|
+
* (closes the #137 race window). Classifies the outcome:
|
|
1807
|
+
*
|
|
1808
|
+
* - success => pushed: true ("first init wins").
|
|
1809
|
+
* - transient/permanent network/auth failure => best-effort, ignored
|
|
1810
|
+
* (branch stays local-only until the first reachable sync).
|
|
1811
|
+
* - non-fast-forward rejection => a detected init race (environment B pushed
|
|
1812
|
+
* its own orphan first). Fetch, then:
|
|
1813
|
+
* - scaffold-only local => adopt the remote (reset local to remote).
|
|
1814
|
+
* - local has user issues => fail loudly toward `tbd doctor --fix` so the
|
|
1815
|
+
* local work is never silently discarded.
|
|
1816
|
+
*
|
|
1817
|
+
* MUST be called after the orphan's initial commit, while the worktree is on
|
|
1818
|
+
* syncBranch.
|
|
1819
|
+
*/
|
|
1820
|
+
async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath) {
|
|
1821
|
+
try {
|
|
1822
|
+
await gitNoPrompt("-C", worktreePath, "push", remote, `HEAD:refs/heads/${syncBranch}`);
|
|
1823
|
+
return {
|
|
1824
|
+
pushed: true,
|
|
1825
|
+
adopted: false
|
|
1826
|
+
};
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
if (!isNonFastForward(err)) return {
|
|
1829
|
+
pushed: false,
|
|
1830
|
+
adopted: false
|
|
1831
|
+
};
|
|
1832
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
1833
|
+
if (await worktreeHasUserIssues(dataSyncPath)) throw new Error(`Detected unrelated ${remote}/${syncBranch} histories during init, and the local branch already contains issues. Refusing to discard local work. Run \`tbd doctor --fix\` to reconcile the histories.`);
|
|
1834
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
1835
|
+
return {
|
|
1836
|
+
pushed: false,
|
|
1837
|
+
adopted: true
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1470
1841
|
/**
|
|
1471
1842
|
* Initialize the hidden worktree for the tbd-sync branch.
|
|
1472
1843
|
* Follows the decision tree from tbd-design.md §2.3.
|
|
1473
1844
|
*
|
|
1845
|
+
* MUST be called while holding `withSharedDataSyncLock` — it migrates legacy
|
|
1846
|
+
* per-checkout worktrees and creates the shared attached worktree on tbd-sync,
|
|
1847
|
+
* so concurrent callers can otherwise race branch ownership and migration.
|
|
1848
|
+
*
|
|
1474
1849
|
* @param baseDir - The base directory of the repository
|
|
1475
1850
|
* @param remote - The remote name (default: 'origin')
|
|
1476
1851
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1477
1852
|
* @returns Path to the worktree or error message
|
|
1478
1853
|
*/
|
|
1479
1854
|
async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1480
|
-
const
|
|
1855
|
+
const paths = await getSharedPaths(baseDir);
|
|
1856
|
+
const worktreePath = paths.sharedWorktreePath;
|
|
1481
1857
|
if (await worktreeExists(baseDir)) return {
|
|
1482
1858
|
success: true,
|
|
1483
1859
|
path: worktreePath,
|
|
1484
1860
|
created: false
|
|
1485
1861
|
};
|
|
1862
|
+
await mkdir(paths.sharedTbdDir, { recursive: true });
|
|
1863
|
+
const migrationResult = await migrateLegacyWorktreesToShared(baseDir, syncBranch);
|
|
1864
|
+
if (!migrationResult.success) return {
|
|
1865
|
+
success: false,
|
|
1866
|
+
error: migrationResult.error
|
|
1867
|
+
};
|
|
1486
1868
|
try {
|
|
1487
1869
|
await rm(worktreePath, {
|
|
1488
1870
|
recursive: true,
|
|
@@ -1490,7 +1872,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1490
1872
|
});
|
|
1491
1873
|
} catch {}
|
|
1492
1874
|
try {
|
|
1493
|
-
if (await branchExists(syncBranch)) {
|
|
1875
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
1494
1876
|
await git("-C", baseDir, "worktree", "add", worktreePath, syncBranch);
|
|
1495
1877
|
return {
|
|
1496
1878
|
success: true,
|
|
@@ -1498,7 +1880,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1498
1880
|
created: true
|
|
1499
1881
|
};
|
|
1500
1882
|
}
|
|
1501
|
-
|
|
1883
|
+
const hasRemote = await remoteIsConfigured(remote, baseDir);
|
|
1884
|
+
const probe = hasRemote ? await probeRemoteBranch(remote, syncBranch, baseDir) : "absent";
|
|
1885
|
+
if (probe === "check-failed") return {
|
|
1886
|
+
success: false,
|
|
1887
|
+
error: `Could not verify whether ${remote}/${syncBranch} exists (remote check failed); refusing to create a divergent local branch. Check connectivity/auth and retry.`
|
|
1888
|
+
};
|
|
1889
|
+
if (probe === "present") {
|
|
1502
1890
|
await git("-C", baseDir, "fetch", remote, syncBranch);
|
|
1503
1891
|
await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
|
|
1504
1892
|
return {
|
|
@@ -1513,12 +1901,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1513
1901
|
await mkdir(join(dataSyncPath, "issues"), { recursive: true });
|
|
1514
1902
|
await mkdir(join(dataSyncPath, "mappings"), { recursive: true });
|
|
1515
1903
|
await mkdir(join(dataSyncPath, "attic", "conflicts"), { recursive: true });
|
|
1516
|
-
await writeFile(join(dataSyncPath, "meta.yml"),
|
|
1904
|
+
await writeFile(join(dataSyncPath, "meta.yml"), `schema_version: ${DATA_SYNC_SCHEMA_VERSION}\n`);
|
|
1517
1905
|
await writeFile(join(dataSyncPath, "issues", ".gitkeep"), "");
|
|
1518
1906
|
await writeFile(join(dataSyncPath, "mappings", ".gitkeep"), "");
|
|
1519
1907
|
await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
|
|
1520
1908
|
await git("-C", worktreePath, "add", ".");
|
|
1521
|
-
await
|
|
1909
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
|
|
1910
|
+
if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
|
|
1522
1911
|
return {
|
|
1523
1912
|
success: true,
|
|
1524
1913
|
path: worktreePath,
|
|
@@ -1538,16 +1927,17 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1538
1927
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1539
1928
|
* @returns Health status indicating if branch exists and has commits
|
|
1540
1929
|
*/
|
|
1541
|
-
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
1930
|
+
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH, baseDir) {
|
|
1931
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1542
1932
|
try {
|
|
1543
1933
|
return {
|
|
1544
1934
|
exists: true,
|
|
1545
1935
|
orphaned: false,
|
|
1546
|
-
head: (await git("rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1936
|
+
head: (await git(...dirArgs, "rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1547
1937
|
};
|
|
1548
1938
|
} catch {
|
|
1549
1939
|
try {
|
|
1550
|
-
await git("show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1940
|
+
await git(...dirArgs, "show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1551
1941
|
return {
|
|
1552
1942
|
exists: true,
|
|
1553
1943
|
orphaned: true
|
|
@@ -1568,27 +1958,36 @@ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
|
1568
1958
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1569
1959
|
* @returns Health status indicating if remote branch exists and divergence state
|
|
1570
1960
|
*/
|
|
1571
|
-
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1961
|
+
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
1962
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1572
1963
|
try {
|
|
1573
|
-
await git("fetch", remote, syncBranch);
|
|
1574
|
-
const remoteHead = (await git("rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1964
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1965
|
+
const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1575
1966
|
let diverged = false;
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
const localHead = await git("rev-parse", syncBranch);
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1967
|
+
let unrelated = false;
|
|
1968
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
1969
|
+
const localHead = (await git(...dirArgs, "rev-parse", syncBranch)).trim();
|
|
1970
|
+
try {
|
|
1971
|
+
const mergeBase = (await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`)).trim();
|
|
1972
|
+
diverged = mergeBase !== localHead && mergeBase !== remoteHead;
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
if (exitCodeOf(err) === 1) {
|
|
1975
|
+
unrelated = true;
|
|
1976
|
+
diverged = true;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1582
1979
|
}
|
|
1583
1980
|
return {
|
|
1584
1981
|
exists: true,
|
|
1585
1982
|
diverged,
|
|
1983
|
+
unrelated,
|
|
1586
1984
|
head: remoteHead
|
|
1587
1985
|
};
|
|
1588
1986
|
} catch {
|
|
1589
1987
|
return {
|
|
1590
1988
|
exists: false,
|
|
1591
|
-
diverged: false
|
|
1989
|
+
diverged: false,
|
|
1990
|
+
unrelated: false
|
|
1592
1991
|
};
|
|
1593
1992
|
}
|
|
1594
1993
|
}
|
|
@@ -1602,7 +2001,8 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
|
|
|
1602
2001
|
* @returns Consistency status with HEAD comparisons and ahead/behind counts
|
|
1603
2002
|
*/
|
|
1604
2003
|
async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote = "origin") {
|
|
1605
|
-
const
|
|
2004
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
2005
|
+
const worktreeHead = await git("-C", worktreePath, "rev-parse", "HEAD").catch(() => "");
|
|
1606
2006
|
const localHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1607
2007
|
const remoteHead = await git("-C", baseDir, "rev-parse", `${remote}/${syncBranch}`).catch(() => "");
|
|
1608
2008
|
let localAhead = 0;
|
|
@@ -1626,6 +2026,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
1626
2026
|
localBehind
|
|
1627
2027
|
};
|
|
1628
2028
|
}
|
|
2029
|
+
/** Read all issues from a git ref's data-sync tree (no checkout needed). */
|
|
2030
|
+
async function readBranchIssues(baseDir, ref) {
|
|
2031
|
+
let listing;
|
|
2032
|
+
try {
|
|
2033
|
+
listing = await git("-C", baseDir, "ls-tree", "-r", "--name-only", ref, "--", `${DATA_SYNC_DIR}/issues/`);
|
|
2034
|
+
} catch {
|
|
2035
|
+
return [];
|
|
2036
|
+
}
|
|
2037
|
+
const issues = [];
|
|
2038
|
+
for (const path of listing.split("\n").filter((p) => /\/is-.*\.md$/.test(p))) try {
|
|
2039
|
+
const content = await git("-C", baseDir, "show", `${ref}:${path}`);
|
|
2040
|
+
issues.push(parseIssue(content));
|
|
2041
|
+
} catch {}
|
|
2042
|
+
return issues;
|
|
2043
|
+
}
|
|
2044
|
+
/** Read the ID mapping from a git ref's ids.yml (empty if absent). */
|
|
2045
|
+
async function readBranchMapping(baseDir, ref) {
|
|
2046
|
+
try {
|
|
2047
|
+
return parseIdMappingFromYaml(await git("-C", baseDir, "show", `${ref}:${DATA_SYNC_DIR}/mappings/ids.yml`));
|
|
2048
|
+
} catch {
|
|
2049
|
+
return {
|
|
2050
|
+
shortToUlid: /* @__PURE__ */ new Map(),
|
|
2051
|
+
ulidToShort: /* @__PURE__ */ new Map()
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
/** Preserve a losing issue version explicitly under attic/conflicts/. */
|
|
2056
|
+
async function preserveLosingVersion(dataSyncPath, loser) {
|
|
2057
|
+
const conflictsDir = join(dataSyncPath, "attic", "conflicts");
|
|
2058
|
+
await mkdir(conflictsDir, { recursive: true });
|
|
2059
|
+
await writeFile(join(conflictsDir, `${loser.id}__${nowFilenameTimestamp()}.md`), serializeIssue(loser));
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Non-destructively rescue an unrelated tbd-sync history (#139).
|
|
2063
|
+
*
|
|
2064
|
+
* Reconciles at the issue-file layer (ULIDs guarantee no collisions), never via
|
|
2065
|
+
* a git history merge. Adopts the remote as the canonical base and replays
|
|
2066
|
+
* local work onto it. The only destructive step (the reset) happens AFTER a
|
|
2067
|
+
* backup branch is created, so the pre-rescue HEAD is always recoverable and
|
|
2068
|
+
* the rescue is restartable.
|
|
2069
|
+
*
|
|
2070
|
+
* MUST be called while holding `withSharedDataSyncLock`. Aborts if the
|
|
2071
|
+
* data-sync worktree is dirty or has a merge in progress.
|
|
2072
|
+
*/
|
|
2073
|
+
async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
2074
|
+
const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
|
|
2075
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) throw new Error("Refusing to rescue: the tbd-sync worktree has uncommitted changes. Commit or stash them, then retry.");
|
|
2076
|
+
if (await git("-C", worktreePath, "rev-parse", "-q", "--verify", "MERGE_HEAD").then(() => true).catch(() => false)) throw new Error("Refusing to rescue: a merge is in progress in the tbd-sync worktree. Resolve or abort it, then retry.");
|
|
2077
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
2078
|
+
const localIssues = await listIssues(dataSyncPath);
|
|
2079
|
+
const localMapping = await loadIdMapping(dataSyncPath);
|
|
2080
|
+
const remoteIssues = await readBranchIssues(baseDir, `${remote}/${syncBranch}`);
|
|
2081
|
+
const remoteMapping = await readBranchMapping(baseDir, `${remote}/${syncBranch}`);
|
|
2082
|
+
const localHead = (await git("-C", baseDir, "rev-parse", syncBranch)).trim();
|
|
2083
|
+
const backupBranch = `tbd-backup-${nowFilenameTimestamp()}`;
|
|
2084
|
+
await git("-C", baseDir, "branch", backupBranch, localHead);
|
|
2085
|
+
const buckets = categorizeIssuesByUlid(localIssues, remoteIssues);
|
|
2086
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
2087
|
+
let conflicts = 0;
|
|
2088
|
+
for (const issue of buckets.localOnly) await writeIssue(dataSyncPath, issue);
|
|
2089
|
+
for (const { local, remote: remoteIssue } of buckets.bothDifferent) {
|
|
2090
|
+
const { merged } = mergeIssues(null, local, remoteIssue);
|
|
2091
|
+
await writeIssue(dataSyncPath, merged);
|
|
2092
|
+
for (const side of [local, remoteIssue]) if (!issuesSubstantivelyEqual(side, merged)) {
|
|
2093
|
+
await preserveLosingVersion(dataSyncPath, side);
|
|
2094
|
+
conflicts++;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const mergedMapping = mergeIdMappings(remoteMapping, localMapping);
|
|
2098
|
+
const allIssues = await listIssues(dataSyncPath);
|
|
2099
|
+
reconcileMappings(allIssues.map((i) => i.id), mergedMapping, remoteMapping);
|
|
2100
|
+
await saveIdMapping(dataSyncPath, mergedMapping);
|
|
2101
|
+
await git("-C", worktreePath, "add", "-A");
|
|
2102
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) await gitCommit(worktreePath, "--no-verify", "-m", `tbd rescue: adopt remote base + reconcile ${buckets.localOnly.length + buckets.bothDifferent.length} issue(s) (${buckets.localOnly.length} local-only, ${buckets.bothDifferent.length} merged)`);
|
|
2103
|
+
return {
|
|
2104
|
+
backupBranch,
|
|
2105
|
+
localOnly: buckets.localOnly.length,
|
|
2106
|
+
merged: buckets.bothDifferent.length,
|
|
2107
|
+
conflicts,
|
|
2108
|
+
totalIssues: allIssues.length
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
1629
2111
|
/**
|
|
1630
2112
|
* Count issues on a remote sync branch without creating a worktree.
|
|
1631
2113
|
* Used by doctor to show accurate statistics on fresh clones.
|
|
@@ -1634,10 +2116,12 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
1634
2116
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1635
2117
|
* @returns Number of issue files on the remote branch, or null if branch doesn't exist
|
|
1636
2118
|
*/
|
|
1637
|
-
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
2119
|
+
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
2120
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1638
2121
|
try {
|
|
1639
|
-
await git("fetch", remote, syncBranch);
|
|
1640
|
-
const
|
|
2122
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
2123
|
+
const remoteBranch = `${remote}/${syncBranch}`;
|
|
2124
|
+
const output = await git(...dirArgs, "ls-tree", "-r", "--name-only", remoteBranch);
|
|
1641
2125
|
const issuesDir = `${TBD_DIR}/${DATA_SYNC_DIR_NAME}/issues/`;
|
|
1642
2126
|
return output.split("\n").filter(Boolean).filter((line) => line.startsWith(issuesDir) && line.endsWith(".md")).length;
|
|
1643
2127
|
} catch {
|
|
@@ -1652,6 +2136,10 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1652
2136
|
* - CORRUPTED: backup to .tbd/backups/, remove, then recreate
|
|
1653
2137
|
* - MISSING: just create
|
|
1654
2138
|
*
|
|
2139
|
+
* MUST be called while holding `withSharedDataSyncLock` — repair mutates
|
|
2140
|
+
* shared worktree and branch state and shares the same locking contract as
|
|
2141
|
+
* `initWorktree`.
|
|
2142
|
+
*
|
|
1655
2143
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
|
|
1656
2144
|
*
|
|
1657
2145
|
* @param baseDir - The base directory of the repository
|
|
@@ -1660,13 +2148,12 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1660
2148
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1661
2149
|
*/
|
|
1662
2150
|
async function repairWorktree(baseDir, status, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1663
|
-
const worktreePath =
|
|
2151
|
+
const { sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1664
2152
|
try {
|
|
1665
2153
|
if (status === "missing" || status === "prunable") await git("-C", baseDir, "worktree", "prune");
|
|
1666
2154
|
if (status === "corrupted") {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
const backupPath = join(backupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
2155
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
2156
|
+
const backupPath = join(sharedBackupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1670
2157
|
try {
|
|
1671
2158
|
await cp(worktreePath, backupPath, { recursive: true });
|
|
1672
2159
|
} catch {}
|
|
@@ -1697,9 +2184,15 @@ async function repairWorktree(baseDir, status, remote = "origin", syncBranch = S
|
|
|
1697
2184
|
* @returns true if worktree was detached and repaired, false if already attached
|
|
1698
2185
|
*/
|
|
1699
2186
|
async function ensureWorktreeAttached(worktreePath) {
|
|
2187
|
+
return ensureWorktreeAttachedToBranch(worktreePath, SYNC_BRANCH);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Ensure worktree is attached to the requested sync branch, not detached HEAD.
|
|
2191
|
+
*/
|
|
2192
|
+
async function ensureWorktreeAttachedToBranch(worktreePath, syncBranch = SYNC_BRANCH) {
|
|
1700
2193
|
try {
|
|
1701
|
-
if (
|
|
1702
|
-
await git("-C", worktreePath, "checkout",
|
|
2194
|
+
if (await git("-C", worktreePath, "branch", "--show-current").catch(() => "") !== syncBranch) {
|
|
2195
|
+
await git("-C", worktreePath, "checkout", syncBranch);
|
|
1703
2196
|
return true;
|
|
1704
2197
|
}
|
|
1705
2198
|
return false;
|
|
@@ -1725,28 +2218,23 @@ async function ensureWorktreeAttached(worktreePath) {
|
|
|
1725
2218
|
*/
|
|
1726
2219
|
async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
1727
2220
|
const wrongPath = join(baseDir, TBD_DIR, DATA_SYNC_DIR_NAME);
|
|
1728
|
-
const
|
|
1729
|
-
const worktreePath = join(baseDir, WORKTREE_DIR);
|
|
2221
|
+
const { sharedDataSyncDir: correctPath, sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1730
2222
|
try {
|
|
1731
2223
|
await ensureWorktreeAttached(worktreePath);
|
|
1732
2224
|
const wrongIssuesPath = join(wrongPath, "issues");
|
|
1733
2225
|
const wrongMappingsPath = join(wrongPath, "mappings");
|
|
1734
2226
|
let issueFiles = [];
|
|
1735
2227
|
let mappingFiles = [];
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
1739
|
-
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1740
|
-
} catch {}
|
|
2228
|
+
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
2229
|
+
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1741
2230
|
issueFiles = issueFiles.filter((f) => f !== ".gitkeep");
|
|
1742
2231
|
mappingFiles = mappingFiles.filter((f) => f !== ".gitkeep");
|
|
1743
2232
|
if (issueFiles.length === 0 && mappingFiles.length === 0) return {
|
|
1744
2233
|
success: true,
|
|
1745
2234
|
migratedCount: 0
|
|
1746
2235
|
};
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const backupPath = join(backupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
2236
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
2237
|
+
const backupPath = join(sharedBackupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1750
2238
|
await cp(wrongPath, backupPath, { recursive: true });
|
|
1751
2239
|
const correctIssuesPath = join(correctPath, "issues");
|
|
1752
2240
|
const correctMappingsPath = join(correctPath, "mappings");
|
|
@@ -1754,8 +2242,6 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1754
2242
|
await mkdir(correctMappingsPath, { recursive: true });
|
|
1755
2243
|
for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
|
|
1756
2244
|
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
2245
|
const sourceMapping = resolveIdMappingConflicts(await readFile(join(wrongMappingsPath, file), "utf-8"));
|
|
1760
2246
|
let targetMapping;
|
|
1761
2247
|
try {
|
|
@@ -1767,7 +2253,7 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1767
2253
|
} else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
|
|
1768
2254
|
const totalFiles = issueFiles.length + mappingFiles.length;
|
|
1769
2255
|
await git("-C", worktreePath, "add", "-A");
|
|
1770
|
-
if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await
|
|
2256
|
+
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
2257
|
if (removeSource) {
|
|
1772
2258
|
for (const file of issueFiles) await rm(join(wrongIssuesPath, file));
|
|
1773
2259
|
for (const file of mappingFiles) await rm(join(wrongMappingsPath, file));
|
|
@@ -1786,6 +2272,83 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1786
2272
|
}
|
|
1787
2273
|
}
|
|
1788
2274
|
|
|
2275
|
+
//#endregion
|
|
2276
|
+
//#region src/file/common-dir-layout.ts
|
|
2277
|
+
/**
|
|
2278
|
+
* Git common-dir layout metadata for shared issue sync machinery.
|
|
2279
|
+
*/
|
|
2280
|
+
/**
|
|
2281
|
+
* Error thrown when common-dir layout metadata cannot be used safely.
|
|
2282
|
+
*/
|
|
2283
|
+
var CommonDirLayoutError = class extends Error {
|
|
2284
|
+
constructor(message) {
|
|
2285
|
+
super(message);
|
|
2286
|
+
this.name = "CommonDirLayoutError";
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
/**
|
|
2290
|
+
* Read $GIT_COMMON_DIR/tbd/layout.yml, returning null when it has not been created yet.
|
|
2291
|
+
*/
|
|
2292
|
+
async function readCommonDirLayout(layoutPath) {
|
|
2293
|
+
try {
|
|
2294
|
+
const content = await readFile(layoutPath, "utf-8");
|
|
2295
|
+
return CommonDirLayoutSchema.parse(parse(content));
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
if (error.code === "ENOENT") return null;
|
|
2298
|
+
throw new CommonDirLayoutError(`Invalid tbd common-dir layout metadata at ${layoutPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Validate that common-dir layout metadata matches the checkout config.
|
|
2303
|
+
*/
|
|
2304
|
+
function validateCommonDirLayout(layout, config) {
|
|
2305
|
+
if (!isCompatibleFormat(layout.tbd_format)) throw new CommonDirLayoutError(formatUpgradeMessage("Common-dir layout", layout.tbd_format, CURRENT_FORMAT));
|
|
2306
|
+
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".)`);
|
|
2307
|
+
const layoutStorage = layout.sync_storage;
|
|
2308
|
+
const configStorage = config.sync.storage;
|
|
2309
|
+
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".)`);
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Write common-dir layout metadata using the synchronized tbd_format ID.
|
|
2313
|
+
*/
|
|
2314
|
+
async function writeCommonDirLayout(paths, config, existing) {
|
|
2315
|
+
await mkdir(dirname(paths.sharedLayoutPath), { recursive: true });
|
|
2316
|
+
const timestamp = now();
|
|
2317
|
+
const layout = CommonDirLayoutSchema.parse({
|
|
2318
|
+
tbd_format: config.tbd_format,
|
|
2319
|
+
sync_storage: config.sync.storage,
|
|
2320
|
+
data_sync_worktree: "data-sync-worktree",
|
|
2321
|
+
lock_profile: "data-sync-v1",
|
|
2322
|
+
created_at: existing?.created_at ?? timestamp,
|
|
2323
|
+
updated_at: timestamp
|
|
2324
|
+
});
|
|
2325
|
+
const yaml = stringifyYaml(sortKeys(layout, COMMON_DIR_LAYOUT_FIELD_ORDER), {
|
|
2326
|
+
lineWidth: 0,
|
|
2327
|
+
sortMapEntries: false
|
|
2328
|
+
});
|
|
2329
|
+
await writeFile(paths.sharedLayoutPath, yaml);
|
|
2330
|
+
return layout;
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Ensure layout metadata exists and matches the checkout config.
|
|
2334
|
+
*/
|
|
2335
|
+
async function ensureCommonDirLayout(paths, config) {
|
|
2336
|
+
const existing = await readCommonDirLayout(paths.sharedLayoutPath);
|
|
2337
|
+
if (existing) {
|
|
2338
|
+
validateCommonDirLayout(existing, config);
|
|
2339
|
+
return existing;
|
|
2340
|
+
}
|
|
2341
|
+
return writeCommonDirLayout(paths, config);
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Run a critical section while holding the repo-scoped data-sync lock.
|
|
2345
|
+
*/
|
|
2346
|
+
async function withSharedDataSyncLock(tbdRoot, fn) {
|
|
2347
|
+
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
2348
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
2349
|
+
return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
1789
2352
|
//#endregion
|
|
1790
2353
|
//#region src/cli/commands/init.ts
|
|
1791
2354
|
/**
|
|
@@ -1816,7 +2379,7 @@ Example:
|
|
|
1816
2379
|
tbd init --prefix=${prefix} --force`);
|
|
1817
2380
|
if (this.checkDryRun("Would initialize tbd repository", options)) return;
|
|
1818
2381
|
await this.execute(async () => {
|
|
1819
|
-
await initConfig(cwd, VERSION, options.prefix);
|
|
2382
|
+
const config = await initConfig(cwd, VERSION, options.prefix);
|
|
1820
2383
|
this.output.debug(`Created ${TBD_DIR}/config.yml with prefix '${options.prefix}'`);
|
|
1821
2384
|
await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
1822
2385
|
"# Installed documentation (regenerated on setup)",
|
|
@@ -1854,152 +2417,43 @@ Example:
|
|
|
1854
2417
|
if (!supported) throw new CLIError(`Git ${`${version.major}.${version.minor}.${version.patch}`} detected. Git ${MIN_GIT_VERSION}+ is required for tbd.\n\ntbd requires Git 2.42+ for orphan worktree support.\nPlease upgrade Git: https://git-scm.com/downloads`);
|
|
1855
2418
|
this.output.debug(`Git version ${version.major}.${version.minor}.${version.patch} OK`);
|
|
1856
2419
|
} catch (error) {
|
|
1857
|
-
if (error instanceof CLIError) throw error;
|
|
1858
|
-
this.output.debug(`Git version check skipped: ${error.message}`);
|
|
1859
|
-
}
|
|
1860
|
-
const worktreeResult = await initWorktree(cwd, remote, syncBranch);
|
|
1861
|
-
if (worktreeResult.success) {
|
|
1862
|
-
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${TBD_DIR}/${WORKTREE_DIR_NAME}/`);
|
|
1863
|
-
else this.output.debug(`Worktree already exists at ${TBD_DIR}/${WORKTREE_DIR_NAME}/`);
|
|
1864
|
-
const health = await checkWorktreeHealth(cwd);
|
|
1865
|
-
if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
|
|
1866
|
-
} else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
|
|
1867
|
-
}, "Failed to initialize tbd");
|
|
1868
|
-
this.output.data({
|
|
1869
|
-
initialized: true,
|
|
1870
|
-
version: VERSION,
|
|
1871
|
-
prefix: options.prefix
|
|
1872
|
-
}, () => {
|
|
1873
|
-
this.output.success(`Initialized tbd repository (prefix: ${options.prefix})`);
|
|
1874
|
-
if (!this.output.isQuiet()) {
|
|
1875
|
-
console.log("");
|
|
1876
|
-
console.log("Next steps:");
|
|
1877
|
-
console.log(" git add .tbd/ && git commit -m \"Initialize tbd\"");
|
|
1878
|
-
console.log(" tbd setup --auto # Optional: configure agent integrations");
|
|
1879
|
-
}
|
|
1880
|
-
});
|
|
1881
|
-
}
|
|
1882
|
-
};
|
|
1883
|
-
const initCommand = new Command("init").description("Initialize tbd in a git repository").option("--prefix <name>", "Project prefix for display IDs (2-8 alphabetic recommended)").option("--force", "Allow non-recommended prefix format").option("--sync-branch <name>", "Sync branch name (default: tbd-sync)").option("--remote <name>", "Remote name (default: origin)").action(async (options, command) => {
|
|
1884
|
-
await new InitHandler(command).run(options);
|
|
1885
|
-
});
|
|
1886
|
-
|
|
1887
|
-
//#endregion
|
|
1888
|
-
//#region src/utils/zod-error-utils.ts
|
|
1889
|
-
/**
|
|
1890
|
-
* Helpers for rendering Zod errors without relying on object inspection.
|
|
1891
|
-
*/
|
|
1892
|
-
/**
|
|
1893
|
-
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
1894
|
-
*/
|
|
1895
|
-
function formatZodError(error) {
|
|
1896
|
-
const messages = error.issues.map((issue) => {
|
|
1897
|
-
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
1898
|
-
});
|
|
1899
|
-
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
1900
|
-
}
|
|
1901
|
-
/**
|
|
1902
|
-
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
1903
|
-
*/
|
|
1904
|
-
function formatUnknownError(error) {
|
|
1905
|
-
if (error instanceof ZodError) return formatZodError(error);
|
|
1906
|
-
if (error instanceof Error) return error.message;
|
|
1907
|
-
return String(error);
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
//#endregion
|
|
1911
|
-
//#region src/file/storage.ts
|
|
1912
|
-
/**
|
|
1913
|
-
* Storage layer for issue files.
|
|
1914
|
-
*
|
|
1915
|
-
* Provides atomic file operations and issue CRUD operations.
|
|
1916
|
-
* All operations work on the hidden worktree at .tbd/data-sync/issues/.
|
|
1917
|
-
*
|
|
1918
|
-
* See: tbd-design.md §3.2 Storage Layer
|
|
1919
|
-
*/
|
|
1920
|
-
/**
|
|
1921
|
-
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
1922
|
-
*/
|
|
1923
|
-
const ISSUE_READ_BATCH_SIZE = 200;
|
|
1924
|
-
/**
|
|
1925
|
-
* Get the path to an issue file.
|
|
1926
|
-
*/
|
|
1927
|
-
function getIssuePath(baseDir, id) {
|
|
1928
|
-
return join(baseDir, "issues", `${id}.md`);
|
|
1929
|
-
}
|
|
1930
|
-
/**
|
|
1931
|
-
* Read an issue from the worktree.
|
|
1932
|
-
* @throws If the issue file doesn't exist or is invalid.
|
|
1933
|
-
*/
|
|
1934
|
-
async function readIssue(baseDir, id) {
|
|
1935
|
-
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
1936
|
-
}
|
|
1937
|
-
/**
|
|
1938
|
-
* Write an issue to the worktree.
|
|
1939
|
-
* Uses atomic write to prevent corruption.
|
|
1940
|
-
*/
|
|
1941
|
-
async function writeIssue(baseDir, issue) {
|
|
1942
|
-
const validIssue = IssueSchema.parse(issue);
|
|
1943
|
-
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
1944
|
-
}
|
|
1945
|
-
/**
|
|
1946
|
-
* List all issues in the worktree.
|
|
1947
|
-
* Returns empty array if issues directory doesn't exist.
|
|
1948
|
-
*
|
|
1949
|
-
* Uses parallel file reading for better performance with many issues.
|
|
1950
|
-
*/
|
|
1951
|
-
async function listIssues(baseDir, options = {}) {
|
|
1952
|
-
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
1953
|
-
const issuesDir = join(baseDir, "issues");
|
|
1954
|
-
let files;
|
|
1955
|
-
try {
|
|
1956
|
-
files = await readdir(issuesDir);
|
|
1957
|
-
} catch {
|
|
1958
|
-
return [];
|
|
1959
|
-
}
|
|
1960
|
-
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
1961
|
-
const issues = [];
|
|
1962
|
-
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
1963
|
-
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
1964
|
-
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
1965
|
-
const filePath = join(issuesDir, file);
|
|
1966
|
-
try {
|
|
1967
|
-
return {
|
|
1968
|
-
file,
|
|
1969
|
-
content: await readFile(filePath, "utf-8")
|
|
1970
|
-
};
|
|
1971
|
-
} catch (error) {
|
|
1972
|
-
return {
|
|
1973
|
-
file,
|
|
1974
|
-
error: formatUnknownError(error)
|
|
1975
|
-
};
|
|
1976
|
-
}
|
|
1977
|
-
}));
|
|
1978
|
-
for (const result of fileContents) {
|
|
1979
|
-
if ("error" in result) {
|
|
1980
|
-
reportInvalidIssueFile({
|
|
1981
|
-
file: result.file,
|
|
1982
|
-
reason: `failed to read file: ${result.error}`
|
|
1983
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
1984
|
-
continue;
|
|
1985
|
-
}
|
|
1986
|
-
try {
|
|
1987
|
-
const issue = parseIssue(result.content);
|
|
1988
|
-
issues.push(issue);
|
|
1989
|
-
} catch (error) {
|
|
1990
|
-
reportInvalidIssueFile({
|
|
1991
|
-
file: result.file,
|
|
1992
|
-
reason: formatUnknownError(error)
|
|
1993
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
2420
|
+
if (error instanceof CLIError) throw error;
|
|
2421
|
+
this.output.debug(`Git version check skipped: ${error.message}`);
|
|
1994
2422
|
}
|
|
1995
|
-
|
|
2423
|
+
const { worktreeResult, sharedPaths } = await withSharedDataSyncLock(cwd, async () => {
|
|
2424
|
+
const result = await initWorktree(cwd, remote, syncBranch);
|
|
2425
|
+
const paths = await resolveSharedTbdPaths(cwd);
|
|
2426
|
+
if (result.success) await writeCommonDirLayout(paths, config);
|
|
2427
|
+
return {
|
|
2428
|
+
worktreeResult: result,
|
|
2429
|
+
sharedPaths: paths
|
|
2430
|
+
};
|
|
2431
|
+
});
|
|
2432
|
+
if (worktreeResult.success) {
|
|
2433
|
+
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${sharedPaths.sharedWorktreePath}`);
|
|
2434
|
+
else this.output.debug(`Worktree already exists at ${sharedPaths.sharedWorktreePath}`);
|
|
2435
|
+
const health = await checkWorktreeHealth(cwd, syncBranch);
|
|
2436
|
+
if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
|
|
2437
|
+
} else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
|
|
2438
|
+
}, "Failed to initialize tbd");
|
|
2439
|
+
this.output.data({
|
|
2440
|
+
initialized: true,
|
|
2441
|
+
version: VERSION,
|
|
2442
|
+
prefix: options.prefix
|
|
2443
|
+
}, () => {
|
|
2444
|
+
this.output.success(`Initialized tbd repository (prefix: ${options.prefix})`);
|
|
2445
|
+
if (!this.output.isQuiet()) {
|
|
2446
|
+
console.log("");
|
|
2447
|
+
console.log("Next steps:");
|
|
2448
|
+
console.log(" git add .tbd/ && git commit -m \"Initialize tbd\"");
|
|
2449
|
+
console.log(" tbd setup --auto # Optional: configure agent integrations");
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
1996
2452
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
2002
|
-
}
|
|
2453
|
+
};
|
|
2454
|
+
const initCommand = new Command("init").description("Initialize tbd in a git repository").option("--prefix <name>", "Project prefix for display IDs (2-8 alphabetic recommended)").option("--force", "Allow non-recommended prefix format").option("--sync-branch <name>", "Sync branch name (default: tbd-sync)").option("--remote <name>", "Remote name (default: origin)").action(async (options, command) => {
|
|
2455
|
+
await new InitHandler(command).run(options);
|
|
2456
|
+
});
|
|
2003
2457
|
|
|
2004
2458
|
//#endregion
|
|
2005
2459
|
//#region src/lib/priority.ts
|
|
@@ -2208,6 +2662,152 @@ function validateIssueTitle(title, options) {
|
|
|
2208
2662
|
return title;
|
|
2209
2663
|
}
|
|
2210
2664
|
|
|
2665
|
+
//#endregion
|
|
2666
|
+
//#region src/cli/lib/data-context.ts
|
|
2667
|
+
async function probeDataSyncReadiness(tbdRoot) {
|
|
2668
|
+
const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
|
|
2669
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
|
|
2670
|
+
const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
|
|
2671
|
+
if (layout) validateCommonDirLayout(layout, config);
|
|
2672
|
+
const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
|
|
2673
|
+
return {
|
|
2674
|
+
config,
|
|
2675
|
+
migrated,
|
|
2676
|
+
fromFormat,
|
|
2677
|
+
sharedPaths,
|
|
2678
|
+
layout,
|
|
2679
|
+
health,
|
|
2680
|
+
ready: !migrated && layout !== null && health.valid
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* Apply any pending first-use initialization, migration, or repair to the shared
|
|
2685
|
+
* data-sync layout. MUST be called while holding `withSharedDataSyncLock` so that
|
|
2686
|
+
* worktree repair, layout writes, and migrated-config writes are serialized.
|
|
2687
|
+
*/
|
|
2688
|
+
async function ensureSharedDataSyncLayout(tbdRoot, probe) {
|
|
2689
|
+
let repairedWorktreeStatus;
|
|
2690
|
+
if (!probe.health.valid) if (probe.health.status === "missing" || probe.health.status === "prunable") {
|
|
2691
|
+
const repairResult = await repairWorktree(tbdRoot, probe.health.status, probe.config.sync.remote, probe.config.sync.branch);
|
|
2692
|
+
if (!repairResult.success) throw new Error(`Failed to initialize shared data-sync worktree: ${repairResult.error}`);
|
|
2693
|
+
repairedWorktreeStatus = probe.health.status;
|
|
2694
|
+
} else throw new Error(`Shared data-sync worktree is ${probe.health.status}: ${probe.health.error ?? "unknown error"}. Run 'tbd doctor --fix' to repair.`);
|
|
2695
|
+
await ensureCommonDirLayout(probe.sharedPaths, probe.config);
|
|
2696
|
+
if (probe.migrated) {
|
|
2697
|
+
await writeConfig(tbdRoot, probe.config);
|
|
2698
|
+
notifyConfigMigrated(probe.fromFormat, CURRENT_FORMAT);
|
|
2699
|
+
}
|
|
2700
|
+
return repairedWorktreeStatus;
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Emit a one-time stderr notice when this checkout's `.tbd/config.yml` was migrated
|
|
2704
|
+
* (typically `fXX → fYY`). The config bump is the "publish" step of the format
|
|
2705
|
+
* migration and lands as a tracked diff on the current branch; users on a sibling
|
|
2706
|
+
* worktree (and even on main) deserve to know that without having to discover the
|
|
2707
|
+
* diff themselves later.
|
|
2708
|
+
*
|
|
2709
|
+
* See: docs/tbd-format-versioning.md (internal contributor guide) and
|
|
2710
|
+
* plan-2026-05-17-shared-common-dir-sync-worktree.md.
|
|
2711
|
+
*/
|
|
2712
|
+
function notifyConfigMigrated(fromFormat, toFormat) {
|
|
2713
|
+
if (fromFormat === toFormat) return;
|
|
2714
|
+
const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
|
|
2715
|
+
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`);
|
|
2716
|
+
}
|
|
2717
|
+
async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
|
|
2718
|
+
const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
|
|
2719
|
+
return {
|
|
2720
|
+
dataSyncDir,
|
|
2721
|
+
mapping: await loadIdMapping(dataSyncDir),
|
|
2722
|
+
config: probe.config,
|
|
2723
|
+
prefix: probe.config.display.id_prefix,
|
|
2724
|
+
sharedPaths: probe.sharedPaths,
|
|
2725
|
+
repairedWorktreeStatus
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Load all common data context needed by tbd commands.
|
|
2730
|
+
*
|
|
2731
|
+
* For writers this is called inside `withSharedDataSyncLock` by
|
|
2732
|
+
* `withDataSyncContext({ lock: true }, ...)`, so any ensure/migrate/repair work
|
|
2733
|
+
* is serialized.
|
|
2734
|
+
*/
|
|
2735
|
+
async function prepareDataSyncContext(tbdRoot) {
|
|
2736
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2737
|
+
return assembleDataContext(tbdRoot, probe, probe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, probe));
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* Prepare shared data-sync context, optionally holding the repo-scoped lock.
|
|
2741
|
+
*
|
|
2742
|
+
* - `{ lock: true }` (writers): always acquire the lock, then prepare under it.
|
|
2743
|
+
* - `{ lock: false }` (readers): probe first; only acquire the lock if first-use
|
|
2744
|
+
* init/migrate/repair is actually required. Steady-state reads take no lock.
|
|
2745
|
+
*/
|
|
2746
|
+
async function withDataSyncContext(tbdRoot, options, fn) {
|
|
2747
|
+
if (options.lock) return withSharedDataSyncLock(tbdRoot, async () => fn(await prepareDataSyncContext(tbdRoot)));
|
|
2748
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2749
|
+
if (probe.ready) return fn(await assembleDataContext(tbdRoot, probe));
|
|
2750
|
+
return withSharedDataSyncLock(tbdRoot, async () => {
|
|
2751
|
+
const reProbe = await probeDataSyncReadiness(tbdRoot);
|
|
2752
|
+
return fn(await assembleDataContext(tbdRoot, reProbe, reProbe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, reProbe)));
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Load the shared data-sync context for a read-only command.
|
|
2757
|
+
*
|
|
2758
|
+
* Read commands skip the shared lock when the layout and worktree are already
|
|
2759
|
+
* valid and the on-disk config needs no migration. When first-use
|
|
2760
|
+
* init/migrate/repair IS required, the underlying `withDataSyncContext` takes
|
|
2761
|
+
* the lock and runs the ensure path so concurrent readers cannot race
|
|
2762
|
+
* migration or worktree repair.
|
|
2763
|
+
*/
|
|
2764
|
+
async function loadDataContext(tbdRoot) {
|
|
2765
|
+
return withDataSyncContext(tbdRoot, { lock: false }, async (context) => context);
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Load unified command context with CLI options, data, and helper methods.
|
|
2769
|
+
*
|
|
2770
|
+
* This is the recommended way to initialize command context. It:
|
|
2771
|
+
* 1. Checks that tbd is initialized (calls requireInit)
|
|
2772
|
+
* 2. Loads data context (dataSyncDir, mapping, config, prefix)
|
|
2773
|
+
* 3. Extracts CLI context from Commander
|
|
2774
|
+
* 4. Provides helper methods like displayId() and resolveId()
|
|
2775
|
+
*
|
|
2776
|
+
* Usage:
|
|
2777
|
+
* ```ts
|
|
2778
|
+
* class MyHandler extends BaseCommand {
|
|
2779
|
+
* async run(id: string): Promise<void> {
|
|
2780
|
+
* const ctx = await loadFullContext(this.command);
|
|
2781
|
+
* const internalId = ctx.resolveId(id);
|
|
2782
|
+
* const issue = await readIssue(ctx.dataSyncDir, internalId);
|
|
2783
|
+
* console.log(ctx.displayId(issue.id));
|
|
2784
|
+
* }
|
|
2785
|
+
* }
|
|
2786
|
+
* ```
|
|
2787
|
+
*
|
|
2788
|
+
* @param command - The Commander command instance
|
|
2789
|
+
* @throws Error if tbd is not initialized or resources fail to load
|
|
2790
|
+
*/
|
|
2791
|
+
async function loadFullContext(command) {
|
|
2792
|
+
const tbdRoot = await requireInit();
|
|
2793
|
+
const cli = getCommandContext(command);
|
|
2794
|
+
const dataCtx = await loadDataContext(tbdRoot);
|
|
2795
|
+
return {
|
|
2796
|
+
...dataCtx,
|
|
2797
|
+
cli,
|
|
2798
|
+
displayId(internalId) {
|
|
2799
|
+
return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
|
|
2800
|
+
},
|
|
2801
|
+
resolveId(inputId) {
|
|
2802
|
+
try {
|
|
2803
|
+
return resolveToInternalId(inputId, dataCtx.mapping);
|
|
2804
|
+
} catch {
|
|
2805
|
+
throw new NotFoundError("Issue", inputId);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2211
2811
|
//#endregion
|
|
2212
2812
|
//#region src/cli/commands/create.ts
|
|
2213
2813
|
/**
|
|
@@ -2252,52 +2852,52 @@ var CreateHandler = class extends BaseCommand {
|
|
|
2252
2852
|
let prefix;
|
|
2253
2853
|
let issue;
|
|
2254
2854
|
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);
|
|
2855
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
|
|
2856
|
+
prefix = config.display.id_prefix;
|
|
2857
|
+
shortId = generateUniqueShortId(mapping);
|
|
2858
|
+
addIdMapping(mapping, ulid, shortId);
|
|
2859
|
+
let parentId;
|
|
2860
|
+
if (options.parent) try {
|
|
2861
|
+
parentId = resolveToInternalId(options.parent, mapping);
|
|
2862
|
+
} catch {
|
|
2863
|
+
throw new ValidationError(`Invalid parent ID: ${options.parent}`);
|
|
2299
2864
|
}
|
|
2300
|
-
|
|
2865
|
+
if (!specPath && parentId) {
|
|
2866
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2867
|
+
if (parentIssue.spec_path) specPath = parentIssue.spec_path;
|
|
2868
|
+
}
|
|
2869
|
+
issue = {
|
|
2870
|
+
type: "is",
|
|
2871
|
+
id,
|
|
2872
|
+
version: 1,
|
|
2873
|
+
title: validatedTitle,
|
|
2874
|
+
kind,
|
|
2875
|
+
status: "open",
|
|
2876
|
+
priority,
|
|
2877
|
+
labels: options.label ?? [],
|
|
2878
|
+
dependencies: [],
|
|
2879
|
+
created_at: timestamp,
|
|
2880
|
+
updated_at: timestamp,
|
|
2881
|
+
description: description ?? void 0,
|
|
2882
|
+
assignee: options.assignee ?? void 0,
|
|
2883
|
+
due_date: options.due ?? void 0,
|
|
2884
|
+
deferred_until: options.defer ?? void 0,
|
|
2885
|
+
parent_id: parentId,
|
|
2886
|
+
spec_path: specPath
|
|
2887
|
+
};
|
|
2888
|
+
await writeIssue(dataSyncDir, issue);
|
|
2889
|
+
await saveIdMapping(dataSyncDir, mapping);
|
|
2890
|
+
if (parentId) try {
|
|
2891
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2892
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
2893
|
+
if (!hints.includes(id)) {
|
|
2894
|
+
parentIssue.child_order_hints = [...hints, id];
|
|
2895
|
+
parentIssue.version += 1;
|
|
2896
|
+
parentIssue.updated_at = timestamp;
|
|
2897
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
2898
|
+
}
|
|
2899
|
+
} catch {}
|
|
2900
|
+
});
|
|
2301
2901
|
}, "Failed to create issue");
|
|
2302
2902
|
const displayId = `${prefix}-${shortId}`;
|
|
2303
2903
|
this.output.data({
|
|
@@ -2342,77 +2942,6 @@ function applyLimit(items, limitOption) {
|
|
|
2342
2942
|
return items.slice(0, limit);
|
|
2343
2943
|
}
|
|
2344
2944
|
|
|
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
2945
|
//#endregion
|
|
2417
2946
|
//#region src/lib/status.ts
|
|
2418
2947
|
/**
|
|
@@ -3060,81 +3589,83 @@ const showCommand = new Command("show").description("Show issue details").argume
|
|
|
3060
3589
|
var UpdateHandler = class extends BaseCommand {
|
|
3061
3590
|
async run(id, options) {
|
|
3062
3591
|
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();
|
|
3592
|
+
let displayId = id;
|
|
3593
|
+
let didUpdate = false;
|
|
3112
3594
|
await this.execute(async () => {
|
|
3113
|
-
await
|
|
3595
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3596
|
+
let internalId;
|
|
3597
|
+
try {
|
|
3598
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3599
|
+
} catch {
|
|
3600
|
+
throw new NotFoundError("Issue", id);
|
|
3601
|
+
}
|
|
3602
|
+
let issue;
|
|
3603
|
+
try {
|
|
3604
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3605
|
+
} catch {
|
|
3606
|
+
throw new NotFoundError("Issue", id);
|
|
3607
|
+
}
|
|
3608
|
+
const updates = await this.parseUpdates(options, mapping, tbdRoot);
|
|
3609
|
+
if (updates === null) return;
|
|
3610
|
+
if (this.checkDryRun("Would update issue", {
|
|
3611
|
+
id: internalId,
|
|
3612
|
+
...updates
|
|
3613
|
+
})) return;
|
|
3614
|
+
const oldSpecPath = issue.spec_path;
|
|
3615
|
+
if (updates.title !== void 0) issue.title = updates.title;
|
|
3616
|
+
if (updates.status !== void 0) issue.status = updates.status;
|
|
3617
|
+
if (updates.kind !== void 0) issue.kind = updates.kind;
|
|
3618
|
+
if (updates.priority !== void 0) issue.priority = updates.priority;
|
|
3619
|
+
if (updates.assignee !== void 0) issue.assignee = updates.assignee;
|
|
3620
|
+
if (updates.description !== void 0) issue.description = updates.description;
|
|
3621
|
+
if (updates.notes !== void 0) issue.notes = updates.notes;
|
|
3622
|
+
if (updates.due_date !== void 0) issue.due_date = updates.due_date;
|
|
3623
|
+
if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
|
|
3624
|
+
if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
|
|
3625
|
+
if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
|
|
3626
|
+
if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
|
|
3627
|
+
if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
|
|
3628
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3629
|
+
if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
|
|
3630
|
+
} catch {}
|
|
3631
|
+
if (updates.labels !== void 0) issue.labels = updates.labels;
|
|
3632
|
+
if (updates.addLabels && updates.addLabels.length > 0) {
|
|
3633
|
+
const labelsSet = new Set(issue.labels);
|
|
3634
|
+
for (const label of updates.addLabels) labelsSet.add(label);
|
|
3635
|
+
issue.labels = [...labelsSet];
|
|
3636
|
+
}
|
|
3637
|
+
if (updates.removeLabels && updates.removeLabels.length > 0) {
|
|
3638
|
+
const removeSet = new Set(updates.removeLabels);
|
|
3639
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3640
|
+
}
|
|
3641
|
+
issue.version += 1;
|
|
3642
|
+
issue.updated_at = now();
|
|
3643
|
+
await writeIssue(dataSyncDir, issue);
|
|
3644
|
+
if (updates.parent_id) try {
|
|
3645
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3646
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
3647
|
+
if (!hints.includes(internalId)) {
|
|
3648
|
+
parentIssue.child_order_hints = [...hints, internalId];
|
|
3649
|
+
parentIssue.version += 1;
|
|
3650
|
+
parentIssue.updated_at = now();
|
|
3651
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
3652
|
+
}
|
|
3653
|
+
} catch {}
|
|
3654
|
+
if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
|
|
3655
|
+
const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
|
|
3656
|
+
const timestamp = now();
|
|
3657
|
+
for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
|
|
3658
|
+
child.spec_path = issue.spec_path;
|
|
3659
|
+
child.version += 1;
|
|
3660
|
+
child.updated_at = timestamp;
|
|
3661
|
+
await writeIssue(dataSyncDir, child);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3665
|
+
didUpdate = true;
|
|
3666
|
+
});
|
|
3114
3667
|
}, "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);
|
|
3668
|
+
if (!didUpdate) return;
|
|
3138
3669
|
this.output.data({
|
|
3139
3670
|
id: displayId,
|
|
3140
3671
|
updated: true
|
|
@@ -3262,48 +3793,47 @@ const updateCommand = new Command("update").description("Update an issue").argum
|
|
|
3262
3793
|
var CloseHandler = class extends BaseCommand {
|
|
3263
3794
|
async run(id, options) {
|
|
3264
3795
|
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();
|
|
3796
|
+
let displayId = id;
|
|
3797
|
+
let alreadyClosed = false;
|
|
3798
|
+
let didClose = false;
|
|
3301
3799
|
await this.execute(async () => {
|
|
3302
|
-
await
|
|
3800
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3801
|
+
let internalId;
|
|
3802
|
+
try {
|
|
3803
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3804
|
+
} catch {
|
|
3805
|
+
throw new NotFoundError("Issue", id);
|
|
3806
|
+
}
|
|
3807
|
+
let issue;
|
|
3808
|
+
try {
|
|
3809
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3810
|
+
} catch {
|
|
3811
|
+
throw new NotFoundError("Issue", id);
|
|
3812
|
+
}
|
|
3813
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3814
|
+
if (issue.status === "closed") {
|
|
3815
|
+
alreadyClosed = true;
|
|
3816
|
+
didClose = true;
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
if (this.checkDryRun("Would close issue", {
|
|
3820
|
+
id: internalId,
|
|
3821
|
+
reason: options.reason
|
|
3822
|
+
})) return;
|
|
3823
|
+
issue.status = "closed";
|
|
3824
|
+
issue.closed_at = now();
|
|
3825
|
+
issue.close_reason = options.reason ?? null;
|
|
3826
|
+
issue.version += 1;
|
|
3827
|
+
issue.updated_at = now();
|
|
3828
|
+
await writeIssue(dataSyncDir, issue);
|
|
3829
|
+
didClose = true;
|
|
3830
|
+
});
|
|
3303
3831
|
}, "Failed to close issue");
|
|
3832
|
+
if (!didClose) return;
|
|
3304
3833
|
this.output.data({
|
|
3305
3834
|
id: displayId,
|
|
3306
|
-
closed: true
|
|
3835
|
+
closed: true,
|
|
3836
|
+
alreadyClosed
|
|
3307
3837
|
}, () => {
|
|
3308
3838
|
this.output.success(`Closed ${displayId}`);
|
|
3309
3839
|
});
|
|
@@ -3323,40 +3853,42 @@ const closeCommand = new Command("close").description("Close an issue").argument
|
|
|
3323
3853
|
var ReopenHandler = class extends BaseCommand {
|
|
3324
3854
|
async run(id, options) {
|
|
3325
3855
|
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
|
-
}
|
|
3856
|
+
let displayId = id;
|
|
3857
|
+
let didReopen = false;
|
|
3354
3858
|
await this.execute(async () => {
|
|
3355
|
-
await
|
|
3859
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3860
|
+
let internalId;
|
|
3861
|
+
try {
|
|
3862
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3863
|
+
} catch {
|
|
3864
|
+
throw new NotFoundError("Issue", id);
|
|
3865
|
+
}
|
|
3866
|
+
let issue;
|
|
3867
|
+
try {
|
|
3868
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3869
|
+
} catch {
|
|
3870
|
+
throw new NotFoundError("Issue", id);
|
|
3871
|
+
}
|
|
3872
|
+
if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
|
|
3873
|
+
if (this.checkDryRun("Would reopen issue", {
|
|
3874
|
+
id: internalId,
|
|
3875
|
+
reason: options.reason
|
|
3876
|
+
})) return;
|
|
3877
|
+
issue.status = "open";
|
|
3878
|
+
issue.closed_at = null;
|
|
3879
|
+
issue.close_reason = null;
|
|
3880
|
+
issue.version += 1;
|
|
3881
|
+
issue.updated_at = now();
|
|
3882
|
+
if (options.reason) {
|
|
3883
|
+
const reopenNote = `Reopened: ${options.reason}`;
|
|
3884
|
+
issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
|
|
3885
|
+
}
|
|
3886
|
+
await writeIssue(dataSyncDir, issue);
|
|
3887
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3888
|
+
didReopen = true;
|
|
3889
|
+
});
|
|
3356
3890
|
}, "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);
|
|
3891
|
+
if (!didReopen) return;
|
|
3360
3892
|
this.output.data({
|
|
3361
3893
|
id: displayId,
|
|
3362
3894
|
reopened: true
|
|
@@ -3604,43 +4136,45 @@ const staleCommand = new Command("stale").description("List issues not updated r
|
|
|
3604
4136
|
var LabelAddHandler = class extends BaseCommand {
|
|
3605
4137
|
async run(id, labels) {
|
|
3606
4138
|
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();
|
|
4139
|
+
let displayId = id;
|
|
4140
|
+
let didAdd = false;
|
|
3638
4141
|
await this.execute(async () => {
|
|
3639
|
-
await
|
|
4142
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4143
|
+
let internalId;
|
|
4144
|
+
try {
|
|
4145
|
+
internalId = resolveToInternalId(id, mapping);
|
|
4146
|
+
} catch {
|
|
4147
|
+
throw new NotFoundError("Issue", id);
|
|
4148
|
+
}
|
|
4149
|
+
let issue;
|
|
4150
|
+
try {
|
|
4151
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
4152
|
+
} catch {
|
|
4153
|
+
throw new NotFoundError("Issue", id);
|
|
4154
|
+
}
|
|
4155
|
+
if (this.checkDryRun("Would add labels", {
|
|
4156
|
+
id: internalId,
|
|
4157
|
+
labels
|
|
4158
|
+
})) return;
|
|
4159
|
+
const labelsSet = new Set(issue.labels);
|
|
4160
|
+
let added = 0;
|
|
4161
|
+
for (const label of labels) if (!labelsSet.has(label)) {
|
|
4162
|
+
labelsSet.add(label);
|
|
4163
|
+
added++;
|
|
4164
|
+
}
|
|
4165
|
+
if (added === 0) {
|
|
4166
|
+
this.output.info("All labels already present");
|
|
4167
|
+
return;
|
|
4168
|
+
}
|
|
4169
|
+
issue.labels = [...labelsSet];
|
|
4170
|
+
issue.version += 1;
|
|
4171
|
+
issue.updated_at = now();
|
|
4172
|
+
await writeIssue(dataSyncDir, issue);
|
|
4173
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
4174
|
+
didAdd = true;
|
|
4175
|
+
});
|
|
3640
4176
|
}, "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);
|
|
4177
|
+
if (!didAdd) return;
|
|
3644
4178
|
this.output.data({
|
|
3645
4179
|
id: displayId,
|
|
3646
4180
|
addedLabels: labels
|
|
@@ -3652,39 +4186,41 @@ var LabelAddHandler = class extends BaseCommand {
|
|
|
3652
4186
|
var LabelRemoveHandler = class extends BaseCommand {
|
|
3653
4187
|
async run(id, labels) {
|
|
3654
4188
|
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();
|
|
4189
|
+
let displayId = id;
|
|
4190
|
+
let didRemove = false;
|
|
3682
4191
|
await this.execute(async () => {
|
|
3683
|
-
await
|
|
4192
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4193
|
+
let internalId;
|
|
4194
|
+
try {
|
|
4195
|
+
internalId = resolveToInternalId(id, mapping);
|
|
4196
|
+
} catch {
|
|
4197
|
+
throw new NotFoundError("Issue", id);
|
|
4198
|
+
}
|
|
4199
|
+
let issue;
|
|
4200
|
+
try {
|
|
4201
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
4202
|
+
} catch {
|
|
4203
|
+
throw new NotFoundError("Issue", id);
|
|
4204
|
+
}
|
|
4205
|
+
if (this.checkDryRun("Would remove labels", {
|
|
4206
|
+
id: internalId,
|
|
4207
|
+
labels
|
|
4208
|
+
})) return;
|
|
4209
|
+
const removeSet = new Set(labels);
|
|
4210
|
+
const originalCount = issue.labels.length;
|
|
4211
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
4212
|
+
if (originalCount - issue.labels.length === 0) {
|
|
4213
|
+
this.output.info("No matching labels found");
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4216
|
+
issue.version += 1;
|
|
4217
|
+
issue.updated_at = now();
|
|
4218
|
+
await writeIssue(dataSyncDir, issue);
|
|
4219
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
4220
|
+
didRemove = true;
|
|
4221
|
+
});
|
|
3684
4222
|
}, "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);
|
|
4223
|
+
if (!didRemove) return;
|
|
3688
4224
|
this.output.data({
|
|
3689
4225
|
id: displayId,
|
|
3690
4226
|
removedLabels: labels
|
|
@@ -3695,7 +4231,7 @@ var LabelRemoveHandler = class extends BaseCommand {
|
|
|
3695
4231
|
};
|
|
3696
4232
|
var LabelListHandler = class extends BaseCommand {
|
|
3697
4233
|
async run() {
|
|
3698
|
-
const dataSyncDir = await
|
|
4234
|
+
const { dataSyncDir } = await loadDataContext(await requireInit());
|
|
3699
4235
|
let issues;
|
|
3700
4236
|
try {
|
|
3701
4237
|
issues = await listIssues(dataSyncDir);
|
|
@@ -3743,53 +4279,56 @@ const labelCommand = new Command("label").description("Manage issue labels").add
|
|
|
3743
4279
|
var DependsAddHandler = class extends BaseCommand {
|
|
3744
4280
|
async run(issueId, dependsOnId) {
|
|
3745
4281
|
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();
|
|
4282
|
+
let displayIssueId = issueId;
|
|
4283
|
+
let displayDependsOnId = dependsOnId;
|
|
4284
|
+
let didAdd = false;
|
|
3786
4285
|
await this.execute(async () => {
|
|
3787
|
-
await
|
|
4286
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4287
|
+
let internalIssueId;
|
|
4288
|
+
let internalDependsOnId;
|
|
4289
|
+
try {
|
|
4290
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4291
|
+
} catch {
|
|
4292
|
+
throw new NotFoundError("Issue", issueId);
|
|
4293
|
+
}
|
|
4294
|
+
try {
|
|
4295
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4296
|
+
} catch {
|
|
4297
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4298
|
+
}
|
|
4299
|
+
try {
|
|
4300
|
+
await readIssue(dataSyncDir, internalIssueId);
|
|
4301
|
+
} catch {
|
|
4302
|
+
throw new NotFoundError("Issue", issueId);
|
|
4303
|
+
}
|
|
4304
|
+
let blockerIssue;
|
|
4305
|
+
try {
|
|
4306
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4307
|
+
} catch {
|
|
4308
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4309
|
+
}
|
|
4310
|
+
if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
|
|
4311
|
+
if (this.checkDryRun("Would add dependency", {
|
|
4312
|
+
issue: internalIssueId,
|
|
4313
|
+
dependsOn: internalDependsOnId
|
|
4314
|
+
})) return;
|
|
4315
|
+
if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
|
|
4316
|
+
this.output.info("Dependency already exists");
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
4319
|
+
blockerIssue.dependencies.push({
|
|
4320
|
+
type: "blocks",
|
|
4321
|
+
target: internalIssueId
|
|
4322
|
+
});
|
|
4323
|
+
blockerIssue.version += 1;
|
|
4324
|
+
blockerIssue.updated_at = now();
|
|
4325
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4326
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4327
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4328
|
+
didAdd = true;
|
|
4329
|
+
});
|
|
3788
4330
|
}, "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);
|
|
4331
|
+
if (!didAdd) return;
|
|
3793
4332
|
this.output.data({
|
|
3794
4333
|
issue: displayIssueId,
|
|
3795
4334
|
dependsOn: displayDependsOnId
|
|
@@ -3801,45 +4340,48 @@ var DependsAddHandler = class extends BaseCommand {
|
|
|
3801
4340
|
var DependsRemoveHandler = class extends BaseCommand {
|
|
3802
4341
|
async run(issueId, dependsOnId) {
|
|
3803
4342
|
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();
|
|
4343
|
+
let displayIssueId = issueId;
|
|
4344
|
+
let displayDependsOnId = dependsOnId;
|
|
4345
|
+
let didRemove = false;
|
|
3836
4346
|
await this.execute(async () => {
|
|
3837
|
-
await
|
|
4347
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4348
|
+
let internalIssueId;
|
|
4349
|
+
let internalDependsOnId;
|
|
4350
|
+
try {
|
|
4351
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4352
|
+
} catch {
|
|
4353
|
+
throw new NotFoundError("Issue", issueId);
|
|
4354
|
+
}
|
|
4355
|
+
try {
|
|
4356
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4357
|
+
} catch {
|
|
4358
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4359
|
+
}
|
|
4360
|
+
let blockerIssue;
|
|
4361
|
+
try {
|
|
4362
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4363
|
+
} catch {
|
|
4364
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4365
|
+
}
|
|
4366
|
+
if (this.checkDryRun("Would remove dependency", {
|
|
4367
|
+
issue: internalIssueId,
|
|
4368
|
+
dependsOn: internalDependsOnId
|
|
4369
|
+
})) return;
|
|
4370
|
+
const initialLength = blockerIssue.dependencies.length;
|
|
4371
|
+
blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
|
|
4372
|
+
if (blockerIssue.dependencies.length === initialLength) {
|
|
4373
|
+
this.output.info("Dependency not found");
|
|
4374
|
+
return;
|
|
4375
|
+
}
|
|
4376
|
+
blockerIssue.version += 1;
|
|
4377
|
+
blockerIssue.updated_at = now();
|
|
4378
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4379
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4380
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4381
|
+
didRemove = true;
|
|
4382
|
+
});
|
|
3838
4383
|
}, "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);
|
|
4384
|
+
if (!didRemove) return;
|
|
3843
4385
|
this.output.data({
|
|
3844
4386
|
issue: displayIssueId,
|
|
3845
4387
|
removed: displayDependsOnId
|
|
@@ -3850,9 +4392,7 @@ var DependsRemoveHandler = class extends BaseCommand {
|
|
|
3850
4392
|
};
|
|
3851
4393
|
var DependsListHandler = class extends BaseCommand {
|
|
3852
4394
|
async run(id) {
|
|
3853
|
-
const
|
|
3854
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
3855
|
-
const mapping = await loadIdMapping(dataSyncDir);
|
|
4395
|
+
const { dataSyncDir, mapping, config } = await loadDataContext(await requireInit());
|
|
3856
4396
|
let internalId;
|
|
3857
4397
|
try {
|
|
3858
4398
|
internalId = resolveToInternalId(id, mapping);
|
|
@@ -3872,7 +4412,7 @@ var DependsListHandler = class extends BaseCommand {
|
|
|
3872
4412
|
allIssues = [];
|
|
3873
4413
|
}
|
|
3874
4414
|
const showDebug = this.ctx.debug;
|
|
3875
|
-
const prefix =
|
|
4415
|
+
const prefix = config.display.id_prefix;
|
|
3876
4416
|
const deps = getDependencyDirections(issue, allIssues, (dependencyId) => showDebug ? formatDebugId(dependencyId, mapping, prefix) : formatDisplayId(dependencyId, mapping, prefix));
|
|
3877
4417
|
this.output.data(deps, () => {
|
|
3878
4418
|
const colors = this.output.getColors();
|
|
@@ -4896,6 +5436,8 @@ async function workspaceExists(tbdRoot, name) {
|
|
|
4896
5436
|
var SyncHandler = class extends BaseCommand {
|
|
4897
5437
|
dataSyncDir = "";
|
|
4898
5438
|
tbdRoot = "";
|
|
5439
|
+
worktreePath = "";
|
|
5440
|
+
syncBranch = "";
|
|
4899
5441
|
async run(options) {
|
|
4900
5442
|
const tbdRoot = await requireInit();
|
|
4901
5443
|
this.tbdRoot = tbdRoot;
|
|
@@ -4908,42 +5450,28 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4908
5450
|
await this.syncDocs(options.status);
|
|
4909
5451
|
if (!syncIssues) return;
|
|
4910
5452
|
}
|
|
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
|
|
5453
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
|
|
5454
|
+
this.dataSyncDir = dataSyncDir;
|
|
5455
|
+
this.worktreePath = sharedPaths.sharedWorktreePath;
|
|
5456
|
+
this.syncBranch = config.sync.branch;
|
|
5457
|
+
const syncBranch = config.sync.branch;
|
|
5458
|
+
const remote = config.sync.remote;
|
|
5459
|
+
if (options.status) {
|
|
5460
|
+
await this.showIssueStatus(syncBranch, remote);
|
|
5461
|
+
return;
|
|
5462
|
+
}
|
|
5463
|
+
if (this.checkDryRun("Would sync repository", {
|
|
5464
|
+
syncBranch,
|
|
5465
|
+
remote
|
|
5466
|
+
})) return;
|
|
5467
|
+
if (repairedWorktreeStatus) this.output.success("Worktree repaired successfully");
|
|
5468
|
+
if (options.pull) await this.pullChanges(syncBranch, remote);
|
|
5469
|
+
else if (options.push) await this.pushChanges(syncBranch, remote);
|
|
5470
|
+
else await this.fullSync(syncBranch, remote, {
|
|
5471
|
+
force: options.force,
|
|
5472
|
+
autoSave: options.autoSave,
|
|
5473
|
+
outbox: options.outbox
|
|
5474
|
+
});
|
|
4947
5475
|
});
|
|
4948
5476
|
}
|
|
4949
5477
|
/**
|
|
@@ -4993,24 +5521,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4993
5521
|
if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
|
|
4994
5522
|
for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
|
|
4995
5523
|
}
|
|
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
5524
|
async showIssueStatus(syncBranch, remote) {
|
|
5015
5525
|
const status = await this.getSyncStatus(syncBranch, remote);
|
|
5016
5526
|
this.output.data(status, () => {
|
|
@@ -5041,7 +5551,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5041
5551
|
let ahead = 0;
|
|
5042
5552
|
let behind = 0;
|
|
5043
5553
|
try {
|
|
5044
|
-
const status = await git("-C",
|
|
5554
|
+
const status = await git("-C", this.worktreePath, "status", "--porcelain");
|
|
5045
5555
|
if (status) for (const line of status.split("\n")) {
|
|
5046
5556
|
if (!line) continue;
|
|
5047
5557
|
const statusCode = line.slice(0, 2).trim();
|
|
@@ -5100,11 +5610,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5100
5610
|
this.output.success("Already up to date");
|
|
5101
5611
|
return;
|
|
5102
5612
|
}
|
|
5103
|
-
await
|
|
5104
|
-
|
|
5105
|
-
const remoteCommit = await git("rev-parse", `${remote}/${syncBranch}`);
|
|
5106
|
-
await git("update-ref", `refs/heads/${syncBranch}`, remoteCommit);
|
|
5107
|
-
});
|
|
5613
|
+
await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
|
|
5614
|
+
await git("-C", this.worktreePath, "merge", "--ff-only", `${remote}/${syncBranch}`);
|
|
5108
5615
|
this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
|
|
5109
5616
|
} catch (error) {
|
|
5110
5617
|
spinner.stop();
|
|
@@ -5120,15 +5627,15 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5120
5627
|
* @returns Tallies of new/updated/deleted files committed
|
|
5121
5628
|
*/
|
|
5122
5629
|
async commitWorktreeChanges() {
|
|
5123
|
-
const worktreePath =
|
|
5630
|
+
const worktreePath = this.worktreePath;
|
|
5124
5631
|
try {
|
|
5125
|
-
await
|
|
5632
|
+
await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);
|
|
5126
5633
|
const status = await git("-C", worktreePath, "status", "--porcelain");
|
|
5127
5634
|
if (!status || status.trim() === "") return emptyTallies();
|
|
5128
5635
|
const tallies = parseGitStatus(status);
|
|
5129
5636
|
const fileCount = tallies.new + tallies.updated + tallies.deleted;
|
|
5130
5637
|
await git("-C", worktreePath, "add", "-A");
|
|
5131
|
-
await
|
|
5638
|
+
await gitCommit(worktreePath, "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
|
|
5132
5639
|
return tallies;
|
|
5133
5640
|
} catch (error) {
|
|
5134
5641
|
if (error.message.includes("nothing to commit")) return emptyTallies();
|
|
@@ -5210,7 +5717,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5210
5717
|
const spinner = this.output.spinner("Syncing with remote...");
|
|
5211
5718
|
const summary = emptySummary();
|
|
5212
5719
|
const conflicts = [];
|
|
5213
|
-
const worktreePath =
|
|
5720
|
+
const worktreePath = this.worktreePath;
|
|
5214
5721
|
try {
|
|
5215
5722
|
const committedTallies = await this.commitWorktreeChanges();
|
|
5216
5723
|
summary.sent.new += committedTallies.new;
|
|
@@ -5221,6 +5728,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5221
5728
|
this.output.debug(`Committed ${count} file(s) to sync branch`);
|
|
5222
5729
|
}
|
|
5223
5730
|
await git("fetch", remote, syncBranch);
|
|
5731
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
5224
5732
|
let behindCommits = 0;
|
|
5225
5733
|
try {
|
|
5226
5734
|
const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
|
|
@@ -5238,7 +5746,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5238
5746
|
this.output.debug("Remote sync branch does not exist yet");
|
|
5239
5747
|
}
|
|
5240
5748
|
{
|
|
5241
|
-
const { access, writeFile } = await import("node:fs/promises");
|
|
5242
5749
|
const attrPath = join(this.dataSyncDir, "mappings", ".gitattributes");
|
|
5243
5750
|
try {
|
|
5244
5751
|
await access(attrPath);
|
|
@@ -5246,7 +5753,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5246
5753
|
await writeFile(attrPath, "ids.yml merge=union\n");
|
|
5247
5754
|
await git("-C", worktreePath, "add", attrPath);
|
|
5248
5755
|
try {
|
|
5249
|
-
await
|
|
5756
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "chore: add merge=union for ids.yml");
|
|
5250
5757
|
} catch {}
|
|
5251
5758
|
}
|
|
5252
5759
|
}
|
|
@@ -5272,7 +5779,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5272
5779
|
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
5273
5780
|
await git("-C", worktreePath, "add", "-A");
|
|
5274
5781
|
try {
|
|
5275
|
-
await
|
|
5782
|
+
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
5276
5783
|
} catch {}
|
|
5277
5784
|
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
5278
5785
|
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
@@ -5294,7 +5801,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5294
5801
|
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5295
5802
|
if (remoteIdsContent) {
|
|
5296
5803
|
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5297
|
-
const { readFile } = await import("node:fs/promises");
|
|
5298
5804
|
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
5299
5805
|
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
5300
5806
|
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
@@ -5320,13 +5826,14 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5320
5826
|
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
5827
|
}
|
|
5322
5828
|
try {
|
|
5323
|
-
await
|
|
5829
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
5324
5830
|
} catch {
|
|
5325
5831
|
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
5326
5832
|
}
|
|
5327
5833
|
}
|
|
5328
5834
|
}
|
|
5329
5835
|
} catch (error) {
|
|
5836
|
+
if (error instanceof SyncError) throw error;
|
|
5330
5837
|
this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
|
|
5331
5838
|
}
|
|
5332
5839
|
let aheadCommits = 0;
|
|
@@ -5359,6 +5866,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5359
5866
|
summary.conflicts = conflicts.length;
|
|
5360
5867
|
spinner.stop();
|
|
5361
5868
|
if (pushFailed) {
|
|
5869
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
5362
5870
|
let displayError = pushError;
|
|
5363
5871
|
const httpMatch = /HTTP (\d+)/.exec(pushError);
|
|
5364
5872
|
const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
|
|
@@ -5813,16 +6321,23 @@ function renderBeadsWarning(colors) {
|
|
|
5813
6321
|
* Used by: status
|
|
5814
6322
|
*
|
|
5815
6323
|
* @param path - Worktree path
|
|
5816
|
-
* @param
|
|
6324
|
+
* @param status - 'valid' (healthy), 'missing' (created on next sync), or 'prunable' /
|
|
6325
|
+
* 'corrupted' (truly unhealthy and needs repair)
|
|
5817
6326
|
* @param colors - Color functions
|
|
5818
6327
|
*/
|
|
5819
|
-
function renderWorktreeStatus(path,
|
|
6328
|
+
function renderWorktreeStatus(path, status, colors) {
|
|
5820
6329
|
console.log("");
|
|
5821
|
-
if (
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
6330
|
+
if (status === "valid") {
|
|
6331
|
+
console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6334
|
+
if (status === "missing") {
|
|
6335
|
+
console.log(`${colors.dim("Worktree:")} ${path} (not initialized)`);
|
|
6336
|
+
console.log(" Run: tbd sync (or tbd doctor --fix) to initialize");
|
|
6337
|
+
return;
|
|
5825
6338
|
}
|
|
6339
|
+
console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
|
|
6340
|
+
console.log(" Run: tbd doctor --fix");
|
|
5826
6341
|
}
|
|
5827
6342
|
/**
|
|
5828
6343
|
* Render footer with command suggestions.
|
|
@@ -6037,6 +6552,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6037
6552
|
display_prefix: null,
|
|
6038
6553
|
worktree_path: null,
|
|
6039
6554
|
worktree_healthy: null,
|
|
6555
|
+
worktree_status: null,
|
|
6040
6556
|
workspaces: [],
|
|
6041
6557
|
integrations: {
|
|
6042
6558
|
portable_skill: false,
|
|
@@ -6150,10 +6666,11 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6150
6666
|
data.remote = config.sync.remote;
|
|
6151
6667
|
data.display_prefix = config.display.id_prefix;
|
|
6152
6668
|
} catch {}
|
|
6153
|
-
const
|
|
6154
|
-
const worktreeHealth = await checkWorktreeHealth(cwd);
|
|
6155
|
-
data.worktree_path =
|
|
6669
|
+
const sharedPaths = await resolveSharedTbdPaths(cwd);
|
|
6670
|
+
const worktreeHealth = await checkWorktreeHealth(cwd, data.sync_branch ?? void 0);
|
|
6671
|
+
data.worktree_path = sharedPaths.sharedWorktreePath;
|
|
6156
6672
|
data.worktree_healthy = worktreeHealth.valid;
|
|
6673
|
+
data.worktree_status = worktreeHealth.status;
|
|
6157
6674
|
try {
|
|
6158
6675
|
data.workspaces = await listWorkspaces(cwd);
|
|
6159
6676
|
} catch {}
|
|
@@ -6204,7 +6721,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6204
6721
|
console.log("");
|
|
6205
6722
|
console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
|
|
6206
6723
|
}
|
|
6207
|
-
if (data.
|
|
6724
|
+
if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
|
|
6208
6725
|
if (data.workspaces.length > 0) {
|
|
6209
6726
|
console.log("");
|
|
6210
6727
|
console.log(colors.bold("WORKSPACES"));
|
|
@@ -6302,7 +6819,8 @@ var StatsHandler = class extends BaseCommand {
|
|
|
6302
6819
|
const tbdRoot = await requireInit();
|
|
6303
6820
|
let issues;
|
|
6304
6821
|
try {
|
|
6305
|
-
|
|
6822
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
6823
|
+
issues = await listIssues(dataSyncDir);
|
|
6306
6824
|
} catch {
|
|
6307
6825
|
throw new NotInitializedError("No issue store found. Run `tbd init` first.");
|
|
6308
6826
|
}
|
|
@@ -6485,6 +7003,35 @@ function renderDiagnostics(results, colors) {
|
|
|
6485
7003
|
*
|
|
6486
7004
|
* See: tbd-design.md §4.9 Doctor
|
|
6487
7005
|
*/
|
|
7006
|
+
/**
|
|
7007
|
+
* Map remote sync-branch health to a "Remote sync branch" diagnostic.
|
|
7008
|
+
*
|
|
7009
|
+
* Returns null when the remote branch does not exist (the caller then falls
|
|
7010
|
+
* through to local-branch / new-repo handling). Unrelated histories are a hard
|
|
7011
|
+
* ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` — push
|
|
7012
|
+
* can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
|
|
7013
|
+
*/
|
|
7014
|
+
function classifyRemoteSyncHealth(health, remote, syncBranch) {
|
|
7015
|
+
if (!health.exists) return null;
|
|
7016
|
+
if (health.unrelated) return {
|
|
7017
|
+
name: "Remote sync branch",
|
|
7018
|
+
status: "error",
|
|
7019
|
+
fixable: true,
|
|
7020
|
+
message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) — push cannot succeed`,
|
|
7021
|
+
suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
|
|
7022
|
+
};
|
|
7023
|
+
if (health.diverged) return {
|
|
7024
|
+
name: "Remote sync branch",
|
|
7025
|
+
status: "warn",
|
|
7026
|
+
message: `${remote}/${syncBranch} has diverged`,
|
|
7027
|
+
suggestion: "Run: tbd sync to reconcile changes"
|
|
7028
|
+
};
|
|
7029
|
+
return {
|
|
7030
|
+
name: "Remote sync branch",
|
|
7031
|
+
status: "ok",
|
|
7032
|
+
message: `${remote}/${syncBranch}`
|
|
7033
|
+
};
|
|
7034
|
+
}
|
|
6488
7035
|
const CONFIG_DIR = TBD_DIR;
|
|
6489
7036
|
var DoctorHandler = class extends BaseCommand {
|
|
6490
7037
|
dataSyncDir = "";
|
|
@@ -6495,7 +7042,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6495
7042
|
async run(options) {
|
|
6496
7043
|
const tbdRoot = await requireInit();
|
|
6497
7044
|
this.cwd = tbdRoot;
|
|
6498
|
-
this.dataSyncDir = await
|
|
7045
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
|
|
6499
7046
|
try {
|
|
6500
7047
|
this.config = await readConfig(this.cwd);
|
|
6501
7048
|
} catch {}
|
|
@@ -6519,10 +7066,11 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6519
7066
|
healthChecks.push(await this.checkTempFiles(options.fix));
|
|
6520
7067
|
healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
|
|
6521
7068
|
healthChecks.push(await this.checkWorktree(options.fix));
|
|
7069
|
+
healthChecks.push(await this.checkCommonDirLayout(options.fix));
|
|
6522
7070
|
const dataLocationResult = await this.checkDataLocation(options.fix);
|
|
6523
7071
|
healthChecks.push(dataLocationResult);
|
|
6524
7072
|
if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
|
|
6525
|
-
this.dataSyncDir = await
|
|
7073
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
|
|
6526
7074
|
try {
|
|
6527
7075
|
this.invalidIssueFiles = [];
|
|
6528
7076
|
this.issues = await listIssues(this.dataSyncDir, {
|
|
@@ -6535,7 +7083,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6535
7083
|
const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
|
|
6536
7084
|
healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
|
|
6537
7085
|
healthChecks.push(await this.checkLocalSyncBranch());
|
|
6538
|
-
healthChecks.push(await this.checkRemoteSyncBranch());
|
|
7086
|
+
healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
|
|
6539
7087
|
healthChecks.push(await this.checkLocalVsRemoteData());
|
|
6540
7088
|
healthChecks.push(await this.checkCloneScenarios());
|
|
6541
7089
|
healthChecks.push(await this.checkSyncConsistency());
|
|
@@ -6546,6 +7094,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6546
7094
|
integrationChecks.push(await this.checkCodexHooks());
|
|
6547
7095
|
const allChecks = [...healthChecks, ...integrationChecks];
|
|
6548
7096
|
const allOk = allChecks.every((c) => c.status === "ok");
|
|
7097
|
+
const hasErrors = allChecks.some((c) => c.status === "error");
|
|
6549
7098
|
const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
|
|
6550
7099
|
this.output.data({
|
|
6551
7100
|
statusInfo,
|
|
@@ -6581,11 +7130,12 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6581
7130
|
else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
|
|
6582
7131
|
else this.output.warn("Issues found that may require manual intervention.");
|
|
6583
7132
|
});
|
|
7133
|
+
if (hasErrors) process.exitCode = 1;
|
|
6584
7134
|
}
|
|
6585
7135
|
async gatherStatusInfo() {
|
|
6586
7136
|
let gitBranch = null;
|
|
6587
7137
|
try {
|
|
6588
|
-
gitBranch = await getCurrentBranch();
|
|
7138
|
+
gitBranch = await getCurrentBranch(this.cwd);
|
|
6589
7139
|
} catch {}
|
|
6590
7140
|
const worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
6591
7141
|
return {
|
|
@@ -6612,7 +7162,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6612
7162
|
if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
|
|
6613
7163
|
}
|
|
6614
7164
|
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");
|
|
7165
|
+
if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync", this.cwd);
|
|
6616
7166
|
return {
|
|
6617
7167
|
total: this.issues.length,
|
|
6618
7168
|
ready: readyCount,
|
|
@@ -6670,6 +7220,13 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6670
7220
|
path: configPath,
|
|
6671
7221
|
suggestion: "Run: tbd init"
|
|
6672
7222
|
};
|
|
7223
|
+
if (error instanceof IncompatibleFormatError) return {
|
|
7224
|
+
name: "Config file",
|
|
7225
|
+
status: "error",
|
|
7226
|
+
message: `requires newer tbd (found ${error.foundFormat}, supported ${error.supportedFormat})`,
|
|
7227
|
+
path: configPath,
|
|
7228
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
7229
|
+
};
|
|
6673
7230
|
return {
|
|
6674
7231
|
name: "Config file",
|
|
6675
7232
|
status: "error",
|
|
@@ -6761,7 +7318,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6761
7318
|
status: "ok"
|
|
6762
7319
|
};
|
|
6763
7320
|
if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
|
|
6764
|
-
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-
|
|
7321
|
+
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
6765
7322
|
const resolved = resolveIdMappingConflicts(content);
|
|
6766
7323
|
await saveIdMapping(this.dataSyncDir, resolved);
|
|
6767
7324
|
return {
|
|
@@ -6810,7 +7367,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6810
7367
|
status: "ok"
|
|
6811
7368
|
};
|
|
6812
7369
|
if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
|
|
6813
|
-
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-
|
|
7370
|
+
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
6814
7371
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6815
7372
|
await saveIdMapping(this.dataSyncDir, mapping);
|
|
6816
7373
|
return {
|
|
@@ -6949,7 +7506,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6949
7506
|
name: "ID mapping coverage",
|
|
6950
7507
|
status: "ok"
|
|
6951
7508
|
};
|
|
6952
|
-
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-
|
|
7509
|
+
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
6953
7510
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6954
7511
|
const missingIds = [];
|
|
6955
7512
|
for (const issue of this.issues) {
|
|
@@ -6961,10 +7518,10 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6961
7518
|
status: "ok"
|
|
6962
7519
|
};
|
|
6963
7520
|
if (fix && !this.checkDryRun("Create missing ID mappings")) {
|
|
6964
|
-
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-
|
|
7521
|
+
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
6965
7522
|
let historicalMapping;
|
|
6966
7523
|
try {
|
|
6967
|
-
const syncBranch = (await import("./config-
|
|
7524
|
+
const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
|
|
6968
7525
|
const logArgs = ["log", "--format=%H"];
|
|
6969
7526
|
if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
|
|
6970
7527
|
logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
@@ -7095,24 +7652,51 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7095
7652
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
|
|
7096
7653
|
*/
|
|
7097
7654
|
async checkWorktree(fix) {
|
|
7098
|
-
const worktreePath =
|
|
7099
|
-
const
|
|
7655
|
+
const worktreePath = (await resolveSharedTbdPaths(this.cwd)).sharedWorktreePath;
|
|
7656
|
+
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7657
|
+
const remote = this.config?.sync.remote ?? "origin";
|
|
7658
|
+
const worktreeHealth = await checkWorktreeHealth(this.cwd, syncBranch);
|
|
7100
7659
|
switch (worktreeHealth.status) {
|
|
7101
7660
|
case "valid": return {
|
|
7102
7661
|
name: "Worktree",
|
|
7103
7662
|
status: "ok",
|
|
7104
7663
|
path: worktreePath
|
|
7105
7664
|
};
|
|
7106
|
-
case "missing":
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7665
|
+
case "missing":
|
|
7666
|
+
if (fix && !this.checkDryRun("Initialize shared data-sync worktree")) {
|
|
7667
|
+
try {
|
|
7668
|
+
await withSharedDataSyncLock(this.cwd, async () => {
|
|
7669
|
+
await prepareDataSyncContext(this.cwd);
|
|
7670
|
+
});
|
|
7671
|
+
} catch (error) {
|
|
7672
|
+
return {
|
|
7673
|
+
name: "Worktree",
|
|
7674
|
+
status: "error",
|
|
7675
|
+
message: `initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
7676
|
+
path: worktreePath
|
|
7677
|
+
};
|
|
7678
|
+
}
|
|
7679
|
+
try {
|
|
7680
|
+
this.config = await readConfig(this.cwd);
|
|
7681
|
+
} catch {}
|
|
7682
|
+
return {
|
|
7683
|
+
name: "Worktree",
|
|
7684
|
+
status: "ok",
|
|
7685
|
+
message: "initialized",
|
|
7686
|
+
path: worktreePath
|
|
7687
|
+
};
|
|
7688
|
+
}
|
|
7689
|
+
return {
|
|
7690
|
+
name: "Worktree",
|
|
7691
|
+
status: "ok",
|
|
7692
|
+
message: "not created yet",
|
|
7693
|
+
path: worktreePath
|
|
7694
|
+
};
|
|
7112
7695
|
case "prunable":
|
|
7113
7696
|
case "corrupted":
|
|
7114
7697
|
if (fix && !this.checkDryRun("Repair worktree")) {
|
|
7115
|
-
const
|
|
7698
|
+
const repairStatus = worktreeHealth.status;
|
|
7699
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => repairWorktree(this.cwd, repairStatus, remote, syncBranch));
|
|
7116
7700
|
if (result.success) return {
|
|
7117
7701
|
name: "Worktree",
|
|
7118
7702
|
status: "ok",
|
|
@@ -7155,10 +7739,80 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7155
7739
|
}
|
|
7156
7740
|
}
|
|
7157
7741
|
/**
|
|
7742
|
+
* Check $GIT_COMMON_DIR/tbd/layout.yml against the checkout config.
|
|
7743
|
+
*
|
|
7744
|
+
* Reports missing (initialized on next mutating command), mismatched (rewrite
|
|
7745
|
+
* from config under --fix), or future-format (requires newer tbd, no fix).
|
|
7746
|
+
*
|
|
7747
|
+
* See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Format And Layout
|
|
7748
|
+
* Versioning.
|
|
7749
|
+
*/
|
|
7750
|
+
async checkCommonDirLayout(fix) {
|
|
7751
|
+
if (!this.config) return {
|
|
7752
|
+
name: "Common-dir layout",
|
|
7753
|
+
status: "ok",
|
|
7754
|
+
message: "skipped (no config)"
|
|
7755
|
+
};
|
|
7756
|
+
const sharedPaths = await resolveSharedTbdPaths(this.cwd);
|
|
7757
|
+
const layoutPath = sharedPaths.sharedLayoutPath;
|
|
7758
|
+
let layout;
|
|
7759
|
+
try {
|
|
7760
|
+
layout = await readCommonDirLayout(layoutPath);
|
|
7761
|
+
} catch (error) {
|
|
7762
|
+
return {
|
|
7763
|
+
name: "Common-dir layout",
|
|
7764
|
+
status: "error",
|
|
7765
|
+
message: error instanceof Error ? error.message : String(error),
|
|
7766
|
+
path: layoutPath
|
|
7767
|
+
};
|
|
7768
|
+
}
|
|
7769
|
+
if (!layout) return {
|
|
7770
|
+
name: "Common-dir layout",
|
|
7771
|
+
status: "ok",
|
|
7772
|
+
message: "not initialized yet (created on first sync)",
|
|
7773
|
+
path: layoutPath
|
|
7774
|
+
};
|
|
7775
|
+
if (!isCompatibleFormat(layout.tbd_format)) return {
|
|
7776
|
+
name: "Common-dir layout",
|
|
7777
|
+
status: "error",
|
|
7778
|
+
message: `requires newer tbd (found ${layout.tbd_format})`,
|
|
7779
|
+
path: layoutPath,
|
|
7780
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
7781
|
+
};
|
|
7782
|
+
try {
|
|
7783
|
+
validateCommonDirLayout(layout, this.config);
|
|
7784
|
+
return {
|
|
7785
|
+
name: "Common-dir layout",
|
|
7786
|
+
status: "ok",
|
|
7787
|
+
path: layoutPath
|
|
7788
|
+
};
|
|
7789
|
+
} catch (error) {
|
|
7790
|
+
if (!(error instanceof CommonDirLayoutError)) throw error;
|
|
7791
|
+
if (fix && !this.checkDryRun("Repair common-dir layout")) {
|
|
7792
|
+
const configRef = this.config;
|
|
7793
|
+
await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef, layout));
|
|
7794
|
+
return {
|
|
7795
|
+
name: "Common-dir layout",
|
|
7796
|
+
status: "ok",
|
|
7797
|
+
message: "rewritten from config",
|
|
7798
|
+
path: layoutPath
|
|
7799
|
+
};
|
|
7800
|
+
}
|
|
7801
|
+
return {
|
|
7802
|
+
name: "Common-dir layout",
|
|
7803
|
+
status: "error",
|
|
7804
|
+
message: "mismatched with config",
|
|
7805
|
+
path: layoutPath,
|
|
7806
|
+
fixable: true,
|
|
7807
|
+
suggestion: "Run: tbd doctor --fix"
|
|
7808
|
+
};
|
|
7809
|
+
}
|
|
7810
|
+
}
|
|
7811
|
+
/**
|
|
7158
7812
|
* Check for issues in wrong location.
|
|
7159
7813
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
|
|
7160
7814
|
*
|
|
7161
|
-
* Issues should be in
|
|
7815
|
+
* Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/issues/
|
|
7162
7816
|
* If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
|
|
7163
7817
|
* and data was written to the fallback path - this is a bug requiring migration.
|
|
7164
7818
|
*/
|
|
@@ -7176,7 +7830,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7176
7830
|
if (fix && !this.checkDryRun("Migrate data to worktree")) {
|
|
7177
7831
|
let worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
7178
7832
|
if (worktreeHealth.status === "missing") {
|
|
7179
|
-
const initResult = await initWorktree(this.cwd);
|
|
7833
|
+
const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
|
|
7180
7834
|
if (!initResult.success) return {
|
|
7181
7835
|
name: "Data location",
|
|
7182
7836
|
status: "error",
|
|
@@ -7219,7 +7873,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7219
7873
|
path: wrongIssuesPath,
|
|
7220
7874
|
details: [
|
|
7221
7875
|
`Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
|
|
7222
|
-
"Issues should be in
|
|
7876
|
+
"Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/",
|
|
7223
7877
|
"This indicates the worktree was missing when issues were created"
|
|
7224
7878
|
],
|
|
7225
7879
|
fixable: true,
|
|
@@ -7232,14 +7886,14 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7232
7886
|
*/
|
|
7233
7887
|
async checkLocalSyncBranch() {
|
|
7234
7888
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7235
|
-
const localHealth = await checkLocalBranchHealth(syncBranch);
|
|
7889
|
+
const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
|
|
7236
7890
|
if (localHealth.exists && !localHealth.orphaned) return {
|
|
7237
7891
|
name: "Local sync branch",
|
|
7238
7892
|
status: "ok",
|
|
7239
7893
|
message: syncBranch
|
|
7240
7894
|
};
|
|
7241
7895
|
if (!localHealth.exists) {
|
|
7242
|
-
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7896
|
+
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7243
7897
|
name: "Local sync branch",
|
|
7244
7898
|
status: "warn",
|
|
7245
7899
|
message: `${syncBranch} not found (remote exists)`,
|
|
@@ -7262,24 +7916,28 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7262
7916
|
* Check remote sync branch health.
|
|
7263
7917
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
|
|
7264
7918
|
*/
|
|
7265
|
-
async checkRemoteSyncBranch() {
|
|
7919
|
+
async checkRemoteSyncBranch(fix) {
|
|
7266
7920
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7267
7921
|
const remote = this.config?.sync.remote ?? "origin";
|
|
7268
|
-
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
|
|
7269
|
-
if (remoteHealth.
|
|
7270
|
-
|
|
7922
|
+
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
7923
|
+
if (remoteHealth.unrelated && fix && !this.checkDryRun("Rescue unrelated tbd-sync histories")) try {
|
|
7924
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => rescueUnrelatedHistory(this.cwd, remote, syncBranch));
|
|
7925
|
+
return {
|
|
7271
7926
|
name: "Remote sync branch",
|
|
7272
|
-
status: "
|
|
7273
|
-
message:
|
|
7274
|
-
suggestion: "Run: tbd sync to reconcile changes"
|
|
7927
|
+
status: "ok",
|
|
7928
|
+
message: `rescued: adopted ${remote}/${syncBranch} base, reconciled ${result.localOnly} local-only + ${result.merged} merged (backup: ${result.backupBranch})`
|
|
7275
7929
|
};
|
|
7930
|
+
} catch (error) {
|
|
7276
7931
|
return {
|
|
7277
7932
|
name: "Remote sync branch",
|
|
7278
|
-
status: "
|
|
7279
|
-
message:
|
|
7933
|
+
status: "error",
|
|
7934
|
+
message: `rescue failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
7935
|
+
suggestion: "Resolve manually; the pre-rescue state is on the tbd-backup-* branch"
|
|
7280
7936
|
};
|
|
7281
7937
|
}
|
|
7282
|
-
|
|
7938
|
+
const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
|
|
7939
|
+
if (diag) return diag;
|
|
7940
|
+
if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
|
|
7283
7941
|
name: "Remote sync branch",
|
|
7284
7942
|
status: "warn",
|
|
7285
7943
|
message: `${remote}/${syncBranch} not found`,
|
|
@@ -7308,7 +7966,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7308
7966
|
status: "ok"
|
|
7309
7967
|
};
|
|
7310
7968
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7311
|
-
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7969
|
+
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7312
7970
|
name: "Sync status",
|
|
7313
7971
|
status: "warn",
|
|
7314
7972
|
message: `${localIssueCount} local issues, remote branch not found`,
|
|
@@ -7454,6 +8112,7 @@ var ConfigShowHandler = class extends BaseCommand {
|
|
|
7454
8112
|
console.log(`${colors.dim("sync:")}`);
|
|
7455
8113
|
console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
|
|
7456
8114
|
console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
|
|
8115
|
+
console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
|
|
7457
8116
|
console.log(`${colors.dim("display:")}`);
|
|
7458
8117
|
console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
|
|
7459
8118
|
console.log(`${colors.dim("settings:")}`);
|
|
@@ -7589,9 +8248,9 @@ async function listAtticEntries(tbdRoot, filterById) {
|
|
|
7589
8248
|
var AtticListHandler = class extends BaseCommand {
|
|
7590
8249
|
async run(id) {
|
|
7591
8250
|
const tbdRoot = await requireInit();
|
|
8251
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7592
8252
|
const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
|
|
7593
|
-
const
|
|
7594
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
8253
|
+
const prefix = config.display.id_prefix;
|
|
7595
8254
|
const showDebug = this.ctx.debug;
|
|
7596
8255
|
const output = entries.map((e) => ({
|
|
7597
8256
|
id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
|
|
@@ -7616,10 +8275,10 @@ var AtticListHandler = class extends BaseCommand {
|
|
|
7616
8275
|
var AtticShowHandler = class extends BaseCommand {
|
|
7617
8276
|
async run(id, timestamp) {
|
|
7618
8277
|
const tbdRoot = await requireInit();
|
|
8278
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7619
8279
|
const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7620
8280
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
7621
|
-
const
|
|
7622
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
8281
|
+
const prefix = config.display.id_prefix;
|
|
7623
8282
|
const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
|
|
7624
8283
|
this.output.data(entry, () => {
|
|
7625
8284
|
const colors = this.output.getColors();
|
|
@@ -7645,6 +8304,7 @@ var AtticShowHandler = class extends BaseCommand {
|
|
|
7645
8304
|
var AtticRestoreHandler = class extends BaseCommand {
|
|
7646
8305
|
async run(id, timestamp) {
|
|
7647
8306
|
const tbdRoot = await requireInit();
|
|
8307
|
+
await loadDataContext(tbdRoot);
|
|
7648
8308
|
const normalizedId = normalizeIssueId(id);
|
|
7649
8309
|
const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7650
8310
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
@@ -7652,24 +8312,24 @@ var AtticRestoreHandler = class extends BaseCommand {
|
|
|
7652
8312
|
id: normalizedId,
|
|
7653
8313
|
field: entry.field
|
|
7654
8314
|
})) 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();
|
|
8315
|
+
let displayId = id;
|
|
7667
8316
|
await this.execute(async () => {
|
|
7668
|
-
await
|
|
8317
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
8318
|
+
let issue;
|
|
8319
|
+
try {
|
|
8320
|
+
issue = await readIssue(dataSyncDir, normalizedId);
|
|
8321
|
+
} catch {
|
|
8322
|
+
throw new NotFoundError("Issue", id);
|
|
8323
|
+
}
|
|
8324
|
+
const field = entry.field;
|
|
8325
|
+
if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
|
|
8326
|
+
else throw new ValidationError(`Cannot restore field: ${entry.field}`);
|
|
8327
|
+
issue.version += 1;
|
|
8328
|
+
issue.updated_at = now();
|
|
8329
|
+
await writeIssue(dataSyncDir, issue);
|
|
8330
|
+
displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, config.display.id_prefix) : formatDisplayId(normalizedId, mapping, config.display.id_prefix);
|
|
8331
|
+
});
|
|
7669
8332
|
}, "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
8333
|
this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
|
|
7674
8334
|
}
|
|
7675
8335
|
};
|
|
@@ -7789,14 +8449,18 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7789
8449
|
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
8450
|
if (options.validate) {
|
|
7791
8451
|
this.tbdRoot = await requireInit();
|
|
7792
|
-
this.
|
|
7793
|
-
|
|
8452
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8453
|
+
this.dataSyncDir = dataSyncDir;
|
|
8454
|
+
await this.validateImport(options);
|
|
8455
|
+
});
|
|
7794
8456
|
return;
|
|
7795
8457
|
}
|
|
7796
8458
|
if (file) {
|
|
7797
8459
|
this.tbdRoot = await requireInit();
|
|
7798
|
-
this.
|
|
7799
|
-
|
|
8460
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8461
|
+
this.dataSyncDir = dataSyncDir;
|
|
8462
|
+
await this.importFromFile(file, options);
|
|
8463
|
+
});
|
|
7800
8464
|
}
|
|
7801
8465
|
}
|
|
7802
8466
|
/**
|
|
@@ -7804,7 +8468,6 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7804
8468
|
*/
|
|
7805
8469
|
async importFromWorkspaceCmd(options) {
|
|
7806
8470
|
this.tbdRoot = await requireInit();
|
|
7807
|
-
this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
|
|
7808
8471
|
const wsOptions = {
|
|
7809
8472
|
workspace: options.workspace,
|
|
7810
8473
|
dir: options.dir,
|
|
@@ -7815,7 +8478,10 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7815
8478
|
const spinner = this.output.spinner("Importing from workspace...");
|
|
7816
8479
|
wsOptions.logger = this.output.logger(spinner);
|
|
7817
8480
|
const result = await this.execute(async () => {
|
|
7818
|
-
return await
|
|
8481
|
+
return await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8482
|
+
this.dataSyncDir = dataSyncDir;
|
|
8483
|
+
return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
|
|
8484
|
+
});
|
|
7819
8485
|
}, "Failed to import from workspace");
|
|
7820
8486
|
spinner.stop();
|
|
7821
8487
|
if (!result) return;
|
|
@@ -8461,7 +9127,12 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8461
9127
|
const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
|
|
8462
9128
|
const remote = config?.sync.remote ?? "origin";
|
|
8463
9129
|
const tbdDir = join(tbdRoot, ".tbd");
|
|
8464
|
-
const
|
|
9130
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot).catch((error) => {
|
|
9131
|
+
this.output.debug(`resolveSharedTbdPaths failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
9132
|
+
return null;
|
|
9133
|
+
});
|
|
9134
|
+
const worktreePath = sharedPaths?.sharedWorktreePath ?? join(tbdDir, "data-sync-worktree");
|
|
9135
|
+
const legacyWorktreePath = join(tbdDir, "data-sync-worktree");
|
|
8465
9136
|
const displayPath = (p) => relative(process.cwd(), p) || p;
|
|
8466
9137
|
const items = [];
|
|
8467
9138
|
let worktreeExists = false;
|
|
@@ -8471,6 +9142,13 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8471
9142
|
const worktreeStats = await this.getDirectoryStats(worktreePath);
|
|
8472
9143
|
items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
|
|
8473
9144
|
} catch {}
|
|
9145
|
+
let legacyWorktreeExists = false;
|
|
9146
|
+
if (legacyWorktreePath !== worktreePath) try {
|
|
9147
|
+
await access(legacyWorktreePath);
|
|
9148
|
+
legacyWorktreeExists = true;
|
|
9149
|
+
const legacyStats = await this.getDirectoryStats(legacyWorktreePath);
|
|
9150
|
+
items.push(` - Legacy worktree: ${displayPath(legacyWorktreePath)} (${legacyStats.files} files)`);
|
|
9151
|
+
} catch {}
|
|
8474
9152
|
let localBranchExists = false;
|
|
8475
9153
|
try {
|
|
8476
9154
|
execSync(`git rev-parse --verify ${syncBranch}`, {
|
|
@@ -8534,6 +9212,23 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8534
9212
|
console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
|
|
8535
9213
|
}
|
|
8536
9214
|
}
|
|
9215
|
+
if (legacyWorktreeExists) try {
|
|
9216
|
+
execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
|
|
9217
|
+
encoding: "utf-8",
|
|
9218
|
+
stdio: [
|
|
9219
|
+
"ignore",
|
|
9220
|
+
"pipe",
|
|
9221
|
+
"ignore"
|
|
9222
|
+
]
|
|
9223
|
+
});
|
|
9224
|
+
console.log(` ${colors.success("✓")} Removed legacy git worktree`);
|
|
9225
|
+
} catch {
|
|
9226
|
+
await rm(legacyWorktreePath, {
|
|
9227
|
+
recursive: true,
|
|
9228
|
+
force: true
|
|
9229
|
+
});
|
|
9230
|
+
console.log(` ${colors.success("✓")} Removed legacy worktree directory`);
|
|
9231
|
+
}
|
|
8537
9232
|
if (localBranchExists && !options.keepBranch) try {
|
|
8538
9233
|
execSync(`git branch -D ${syncBranch}`, {
|
|
8539
9234
|
encoding: "utf-8",
|
|
@@ -8579,6 +9274,15 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8579
9274
|
} catch (error) {
|
|
8580
9275
|
throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
8581
9276
|
}
|
|
9277
|
+
if (sharedPaths) try {
|
|
9278
|
+
await rm(sharedPaths.sharedTbdDir, {
|
|
9279
|
+
recursive: true,
|
|
9280
|
+
force: true
|
|
9281
|
+
});
|
|
9282
|
+
console.log(` ${colors.success("✓")} Removed shared common-dir metadata`);
|
|
9283
|
+
} catch {
|
|
9284
|
+
console.log(` ${colors.warn("⚠")} Could not remove shared common-dir metadata`);
|
|
9285
|
+
}
|
|
8582
9286
|
console.log("");
|
|
8583
9287
|
this.output.success("tbd has been uninstalled from this repository.");
|
|
8584
9288
|
if (options.keepBranch && localBranchExists) {
|
|
@@ -9061,6 +9765,7 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
9061
9765
|
console.log(`${colors.success("✓")} Initialized in this repo`);
|
|
9062
9766
|
if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
|
|
9063
9767
|
else console.log(`${colors.dim("✗")} Hooks not installed (run: tbd setup --auto)`);
|
|
9768
|
+
console.log(colors.dim(" Run `tbd setup --auto` to refresh skills and settings (e.g. after upgrading tbd)."));
|
|
9064
9769
|
console.log("");
|
|
9065
9770
|
console.log(colors.bold("=== PROJECT STATUS ==="));
|
|
9066
9771
|
try {
|
|
@@ -9149,7 +9854,8 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
9149
9854
|
*/
|
|
9150
9855
|
async getIssueStats(tbdRoot) {
|
|
9151
9856
|
try {
|
|
9152
|
-
const
|
|
9857
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
9858
|
+
const issues = await listIssues(dataSyncDir);
|
|
9153
9859
|
let open = 0;
|
|
9154
9860
|
let inProgress = 0;
|
|
9155
9861
|
const blockedIds = /* @__PURE__ */ new Set();
|
|
@@ -10002,9 +10708,11 @@ async function getShortcutDirectory(quiet = false) {
|
|
|
10002
10708
|
}
|
|
10003
10709
|
/**
|
|
10004
10710
|
* DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
|
|
10711
|
+
* Carries the shared `format=fNN` integration code so the forward-compatibility
|
|
10712
|
+
* guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
|
|
10005
10713
|
* Formatted to match flowmark output.
|
|
10006
10714
|
*/
|
|
10007
|
-
const SKILL_DO_NOT_EDIT_MARKER =
|
|
10715
|
+
const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;
|
|
10008
10716
|
/**
|
|
10009
10717
|
* Build the full generated SKILL.md payload: the bundled skill content with the
|
|
10010
10718
|
* shortcut/guideline directory appended and a DO NOT EDIT marker after the
|
|
@@ -10022,8 +10730,19 @@ async function buildSkillPayload(quiet = false) {
|
|
|
10022
10730
|
}
|
|
10023
10731
|
/**
|
|
10024
10732
|
* Write a generated SKILL.md payload to a target path, creating parent dirs.
|
|
10733
|
+
*
|
|
10734
|
+
* Forward-compatibility guard: if an existing skill carries a newer
|
|
10735
|
+
* `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
|
|
10736
|
+
* the user to upgrade. Guarding each generated surface at its own write point
|
|
10737
|
+
* means an older tbd cannot partial-downgrade a newer committed skill,
|
|
10738
|
+
* regardless of the order in which surfaces are written.
|
|
10025
10739
|
*/
|
|
10026
10740
|
async function writeSkillFile(targetPath, payload) {
|
|
10741
|
+
let existing = null;
|
|
10742
|
+
try {
|
|
10743
|
+
existing = await readFile(targetPath, "utf-8");
|
|
10744
|
+
} catch {}
|
|
10745
|
+
if (existing !== null) assertNotNewerFormat(existing, targetPath);
|
|
10027
10746
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
10028
10747
|
await writeFile(targetPath, payload);
|
|
10029
10748
|
}
|
|
@@ -10825,25 +11544,26 @@ var SetupDefaultHandler = class extends BaseCommand {
|
|
|
10825
11544
|
if (!gitRoot) throw new CLIError("Could not determine git repository root.");
|
|
10826
11545
|
const projectDir = gitRoot;
|
|
10827
11546
|
const hasTbd = await isInitialized(projectDir);
|
|
10828
|
-
const hasBeads = await pathExists(join(projectDir, ".beads"));
|
|
11547
|
+
const hasBeads = await pathExists$1(join(projectDir, ".beads"));
|
|
10829
11548
|
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
11549
|
console.log("Checking repository...");
|
|
10831
11550
|
console.log(` ${colors.success("✓")} Git repository detected`);
|
|
10832
11551
|
if (hasTbd) {
|
|
10833
11552
|
const { config, migrated, changes } = await readConfigWithMigration(projectDir);
|
|
10834
11553
|
console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
|
|
10835
|
-
let
|
|
11554
|
+
let ghCliChanged = false;
|
|
10836
11555
|
if (options.ghCli === false && config.settings.use_gh_cli !== false) {
|
|
10837
11556
|
config.settings.use_gh_cli = false;
|
|
10838
|
-
|
|
11557
|
+
ghCliChanged = true;
|
|
10839
11558
|
}
|
|
10840
|
-
if (
|
|
11559
|
+
if (migrated) {
|
|
11560
|
+
await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
|
|
11561
|
+
console.log(` ${colors.success("✓")} Config migrated to latest format`);
|
|
11562
|
+
for (const change of changes) console.log(` ${colors.dim(change)}`);
|
|
11563
|
+
}
|
|
11564
|
+
if (ghCliChanged) {
|
|
10841
11565
|
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`);
|
|
11566
|
+
console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
|
|
10847
11567
|
}
|
|
10848
11568
|
console.log("");
|
|
10849
11569
|
await this.handleAlreadyInitialized(projectDir, isAutoMode);
|
|
@@ -10991,7 +11711,7 @@ Example:
|
|
|
10991
11711
|
}
|
|
10992
11712
|
async initializeTbd(cwd, prefix) {
|
|
10993
11713
|
const colors = this.output.getColors();
|
|
10994
|
-
await initConfig(cwd, VERSION, prefix);
|
|
11714
|
+
const config = await initConfig(cwd, VERSION, prefix);
|
|
10995
11715
|
console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
|
|
10996
11716
|
const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
10997
11717
|
"# Synced documentation cache (regenerated by tbd sync --docs)",
|
|
@@ -11022,7 +11742,10 @@ Example:
|
|
|
11022
11742
|
if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
|
|
11023
11743
|
else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
|
|
11024
11744
|
try {
|
|
11025
|
-
await
|
|
11745
|
+
await withSharedDataSyncLock(cwd, async () => {
|
|
11746
|
+
await initWorktree(cwd);
|
|
11747
|
+
await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
|
|
11748
|
+
});
|
|
11026
11749
|
const health = await checkWorktreeHealth(cwd);
|
|
11027
11750
|
if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
|
|
11028
11751
|
else {
|
|
@@ -11220,18 +11943,18 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11220
11943
|
};
|
|
11221
11944
|
if (mode === "off") return result;
|
|
11222
11945
|
if (mode === "auto") {
|
|
11223
|
-
const hasClaudeDir = await pathExists(GLOBAL_CLAUDE_DIR);
|
|
11946
|
+
const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
|
|
11224
11947
|
const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
|
|
11225
11948
|
if (!hasClaudeDir && !hasClaudeEnv) return result;
|
|
11226
11949
|
}
|
|
11227
11950
|
result.detected = true;
|
|
11228
11951
|
const claudePaths = getClaudePaths(cwd);
|
|
11229
11952
|
try {
|
|
11230
|
-
if (await pathExists(claudePaths.settings)) {
|
|
11953
|
+
if (await pathExists$1(claudePaths.settings)) {
|
|
11231
11954
|
const content = await readFile(claudePaths.settings, "utf-8");
|
|
11232
11955
|
const hooks = JSON.parse(content).hooks;
|
|
11233
11956
|
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;
|
|
11957
|
+
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
11958
|
}
|
|
11236
11959
|
}
|
|
11237
11960
|
const handler = new SetupClaudeHandler(this.cmd);
|
|
@@ -11252,7 +11975,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11252
11975
|
};
|
|
11253
11976
|
if (mode === "off") return result;
|
|
11254
11977
|
const agentsPath = getAgentsMdPath(cwd);
|
|
11255
|
-
const hasAgentsMd = await pathExists(agentsPath);
|
|
11978
|
+
const hasAgentsMd = await pathExists$1(agentsPath);
|
|
11256
11979
|
if (mode === "auto") {
|
|
11257
11980
|
const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
|
|
11258
11981
|
if (!hasAgentsMd && !hasCodexEnv) return result;
|
|
@@ -11322,7 +12045,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
|
|
|
11322
12045
|
var SaveHandler = class extends BaseCommand {
|
|
11323
12046
|
async run(options) {
|
|
11324
12047
|
const tbdRoot = await requireInit();
|
|
11325
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
11326
12048
|
if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
|
|
11327
12049
|
const saveOptions = {
|
|
11328
12050
|
workspace: options.workspace,
|
|
@@ -11334,7 +12056,9 @@ var SaveHandler = class extends BaseCommand {
|
|
|
11334
12056
|
const spinner = this.output.spinner("Saving issues...");
|
|
11335
12057
|
saveOptions.logger = this.output.logger(spinner);
|
|
11336
12058
|
const result = await this.execute(async () => {
|
|
11337
|
-
return await
|
|
12059
|
+
return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
12060
|
+
return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
|
|
12061
|
+
});
|
|
11338
12062
|
}, "Failed to save issues");
|
|
11339
12063
|
spinner.stop();
|
|
11340
12064
|
if (!result) return;
|