get-tbd 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +551 -168
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +492 -159
- package/dist/cli.mjs.map +1 -1
- package/dist/docs/SKILL.md +2 -2
- package/dist/docs/guidelines/bun-monorepo-patterns.md +65 -66
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +396 -158
- package/dist/docs/guidelines/common-doc-guidelines.md +2 -2
- package/dist/docs/guidelines/convex-limits-best-practices.md +39 -39
- package/dist/docs/guidelines/convex-rules.md +13 -13
- package/dist/docs/guidelines/electron-app-development-patterns.md +18 -18
- package/dist/docs/guidelines/general-comment-rules.md +1 -1
- package/dist/docs/guidelines/general-tdd-guidelines.md +4 -4
- package/dist/docs/guidelines/golden-testing-guidelines.md +9 -9
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +49 -49
- package/dist/docs/guidelines/python-cli-patterns.md +1 -1
- package/dist/docs/guidelines/python-modern-guidelines.md +4 -4
- package/dist/docs/guidelines/release-notes-guidelines.md +18 -2
- package/dist/docs/guidelines/supply-chain-hardening.md +84 -29
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +3 -3
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +17 -17
- package/dist/docs/guidelines/typescript-code-coverage.md +5 -5
- package/dist/docs/guidelines/typescript-rules.md +3 -3
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +3 -3
- package/dist/docs/shortcuts/system/skill-baseline.md +2 -2
- package/dist/docs/tbd-design.md +40 -40
- package/dist/docs/tbd-docs.md +1 -1
- package/dist/docs/tbd-prime.md +3 -3
- package/dist/{id-mapping-CtfTfGIh.mjs → id-mapping-687_UEsy.mjs} +66 -16
- package/dist/id-mapping-687_UEsy.mjs.map +1 -0
- package/dist/{id-mapping-CFoPVinz.mjs → id-mapping-mtoSP9Qt.mjs} +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-rIE4xSVs.mjs → src-BpvcrLnq.mjs} +2 -2
- package/dist/{src-rIE4xSVs.mjs.map → src-BpvcrLnq.mjs.map} +1 -1
- package/dist/tbd +551 -168
- package/package.json +1 -1
- package/dist/id-mapping-CtfTfGIh.mjs.map +0 -1
package/dist/tbd
CHANGED
|
@@ -11,7 +11,7 @@ import tty from "node:tty";
|
|
|
11
11
|
import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
|
-
import crypto, { randomBytes } from "node:crypto";
|
|
14
|
+
import crypto, { randomBytes, randomUUID } from "node:crypto";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import prettyBytes from "pretty-bytes";
|
|
17
17
|
import prettyMs from "pretty-ms";
|
|
@@ -14104,7 +14104,7 @@ function serializeIssue(issue) {
|
|
|
14104
14104
|
* Package version, derived from git at build time.
|
|
14105
14105
|
* Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.
|
|
14106
14106
|
*/
|
|
14107
|
-
const VERSION$1 = "0.2.
|
|
14107
|
+
const VERSION$1 = "0.2.2";
|
|
14108
14108
|
|
|
14109
14109
|
//#endregion
|
|
14110
14110
|
//#region src/cli/lib/version.ts
|
|
@@ -98354,6 +98354,18 @@ var SyncError = class extends CLIError {
|
|
|
98354
98354
|
}
|
|
98355
98355
|
};
|
|
98356
98356
|
/**
|
|
98357
|
+
* Unrelated-history error - the local and remote tbd-sync share no common
|
|
98358
|
+
* ancestor, so a push can never fast-forward and a plain git merge refuses.
|
|
98359
|
+
* `tbd sync` cannot resolve this; the rescue lives in `tbd doctor --fix`.
|
|
98360
|
+
* See: plan-2026-05-29-tbd-sync-unrelated-history-hardening.md
|
|
98361
|
+
*/
|
|
98362
|
+
var UnrelatedHistoriesError = class extends SyncError {
|
|
98363
|
+
constructor(remote = "origin", syncBranch = "tbd-sync") {
|
|
98364
|
+
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).`);
|
|
98365
|
+
this.name = "UnrelatedHistoriesError";
|
|
98366
|
+
}
|
|
98367
|
+
};
|
|
98368
|
+
/**
|
|
98357
98369
|
* Classify a sync error to determine appropriate recovery action.
|
|
98358
98370
|
*
|
|
98359
98371
|
* Used by `tbd sync` to decide whether to:
|
|
@@ -98651,6 +98663,124 @@ function nowFilenameTimestamp() {
|
|
|
98651
98663
|
return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
|
|
98652
98664
|
}
|
|
98653
98665
|
|
|
98666
|
+
//#endregion
|
|
98667
|
+
//#region src/utils/zod-error-utils.ts
|
|
98668
|
+
/**
|
|
98669
|
+
* Helpers for rendering Zod errors without relying on object inspection.
|
|
98670
|
+
*/
|
|
98671
|
+
/**
|
|
98672
|
+
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
98673
|
+
*/
|
|
98674
|
+
function formatZodError(error) {
|
|
98675
|
+
const messages = error.issues.map((issue) => {
|
|
98676
|
+
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
98677
|
+
});
|
|
98678
|
+
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
98679
|
+
}
|
|
98680
|
+
/**
|
|
98681
|
+
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
98682
|
+
*/
|
|
98683
|
+
function formatUnknownError(error) {
|
|
98684
|
+
if (error instanceof ZodError) return formatZodError(error);
|
|
98685
|
+
if (error instanceof Error) return error.message;
|
|
98686
|
+
return String(error);
|
|
98687
|
+
}
|
|
98688
|
+
|
|
98689
|
+
//#endregion
|
|
98690
|
+
//#region src/file/storage.ts
|
|
98691
|
+
/**
|
|
98692
|
+
* Storage layer for issue files.
|
|
98693
|
+
*
|
|
98694
|
+
* Provides atomic file operations and issue CRUD operations.
|
|
98695
|
+
* All operations work on the data-sync directory selected by the caller. In production
|
|
98696
|
+
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
98697
|
+
*
|
|
98698
|
+
* See: tbd-design.md §3.2 Storage Layer
|
|
98699
|
+
*/
|
|
98700
|
+
/**
|
|
98701
|
+
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
98702
|
+
*/
|
|
98703
|
+
const ISSUE_READ_BATCH_SIZE = 200;
|
|
98704
|
+
/**
|
|
98705
|
+
* Get the path to an issue file.
|
|
98706
|
+
*/
|
|
98707
|
+
function getIssuePath(baseDir, id) {
|
|
98708
|
+
return join(baseDir, "issues", `${id}.md`);
|
|
98709
|
+
}
|
|
98710
|
+
/**
|
|
98711
|
+
* Read an issue from the worktree.
|
|
98712
|
+
* @throws If the issue file doesn't exist or is invalid.
|
|
98713
|
+
*/
|
|
98714
|
+
async function readIssue(baseDir, id) {
|
|
98715
|
+
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
98716
|
+
}
|
|
98717
|
+
/**
|
|
98718
|
+
* Write an issue to the worktree.
|
|
98719
|
+
* Uses atomic write to prevent corruption.
|
|
98720
|
+
*/
|
|
98721
|
+
async function writeIssue(baseDir, issue) {
|
|
98722
|
+
const validIssue = IssueSchema.parse(issue);
|
|
98723
|
+
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
98724
|
+
}
|
|
98725
|
+
/**
|
|
98726
|
+
* List all issues in the worktree.
|
|
98727
|
+
* Returns empty array if issues directory doesn't exist.
|
|
98728
|
+
*
|
|
98729
|
+
* Uses parallel file reading for better performance with many issues.
|
|
98730
|
+
*/
|
|
98731
|
+
async function listIssues(baseDir, options = {}) {
|
|
98732
|
+
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
98733
|
+
const issuesDir = join(baseDir, "issues");
|
|
98734
|
+
let files;
|
|
98735
|
+
try {
|
|
98736
|
+
files = await readdir(issuesDir);
|
|
98737
|
+
} catch {
|
|
98738
|
+
return [];
|
|
98739
|
+
}
|
|
98740
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
98741
|
+
const issues = [];
|
|
98742
|
+
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
98743
|
+
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
98744
|
+
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
98745
|
+
const filePath = join(issuesDir, file);
|
|
98746
|
+
try {
|
|
98747
|
+
return {
|
|
98748
|
+
file,
|
|
98749
|
+
content: await readFile(filePath, "utf-8")
|
|
98750
|
+
};
|
|
98751
|
+
} catch (error) {
|
|
98752
|
+
return {
|
|
98753
|
+
file,
|
|
98754
|
+
error: formatUnknownError(error)
|
|
98755
|
+
};
|
|
98756
|
+
}
|
|
98757
|
+
}));
|
|
98758
|
+
for (const result of fileContents) {
|
|
98759
|
+
if ("error" in result) {
|
|
98760
|
+
reportInvalidIssueFile({
|
|
98761
|
+
file: result.file,
|
|
98762
|
+
reason: `failed to read file: ${result.error}`
|
|
98763
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
98764
|
+
continue;
|
|
98765
|
+
}
|
|
98766
|
+
try {
|
|
98767
|
+
const issue = parseIssue(result.content);
|
|
98768
|
+
issues.push(issue);
|
|
98769
|
+
} catch (error) {
|
|
98770
|
+
reportInvalidIssueFile({
|
|
98771
|
+
file: result.file,
|
|
98772
|
+
reason: formatUnknownError(error)
|
|
98773
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
98774
|
+
}
|
|
98775
|
+
}
|
|
98776
|
+
}
|
|
98777
|
+
return issues;
|
|
98778
|
+
}
|
|
98779
|
+
function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
|
|
98780
|
+
onInvalidIssue?.(invalidIssue);
|
|
98781
|
+
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
98782
|
+
}
|
|
98783
|
+
|
|
98654
98784
|
//#endregion
|
|
98655
98785
|
//#region src/utils/lockfile.ts
|
|
98656
98786
|
/**
|
|
@@ -98682,10 +98812,15 @@ function nowFilenameTimestamp() {
|
|
|
98682
98812
|
*
|
|
98683
98813
|
* 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process
|
|
98684
98814
|
* 2. **Hold**: Execute the critical section
|
|
98685
|
-
* 3. **Release**: `rmdir(lockDir)` — in a finally block
|
|
98815
|
+
* 3. **Release**: `rmdir(lockDir)` — in a finally block, with a bounded retry to
|
|
98816
|
+
* absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering
|
|
98817
|
+
* handles) that would otherwise orphan the lock directory.
|
|
98686
98818
|
* 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder
|
|
98687
|
-
* crashed and break the lock.
|
|
98688
|
-
*
|
|
98819
|
+
* crashed and break the lock. Breaking is done **atomically** by renaming the
|
|
98820
|
+
* stale directory aside (only one waiter can win the rename), so two waiters can
|
|
98821
|
+
* never both break the same lock and end up running concurrently. This is a
|
|
98822
|
+
* heuristic — safe when the critical section is short-lived (sub-second for
|
|
98823
|
+
* file I/O).
|
|
98689
98824
|
*
|
|
98690
98825
|
* ## Failure on timeout
|
|
98691
98826
|
*
|
|
@@ -98733,6 +98868,53 @@ var LockAcquisitionError = class extends Error {
|
|
|
98733
98868
|
this.name = "LockAcquisitionError";
|
|
98734
98869
|
}
|
|
98735
98870
|
};
|
|
98871
|
+
/** Filesystem error codes that are transient on Windows and worth retrying. */
|
|
98872
|
+
const TRANSIENT_RMDIR_CODES = new Set([
|
|
98873
|
+
"EBUSY",
|
|
98874
|
+
"EPERM",
|
|
98875
|
+
"EACCES",
|
|
98876
|
+
"ENOTEMPTY"
|
|
98877
|
+
]);
|
|
98878
|
+
/**
|
|
98879
|
+
* Remove a lock directory, tolerating transient Windows failures.
|
|
98880
|
+
*
|
|
98881
|
+
* `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners
|
|
98882
|
+
* or lingering directory handles). A few short retries make release reliable; if it
|
|
98883
|
+
* still fails, we give up and let stale detection reclaim the directory rather than
|
|
98884
|
+
* throwing from a best-effort cleanup path.
|
|
98885
|
+
*/
|
|
98886
|
+
async function removeLockDir(lockPath, attempts = 5) {
|
|
98887
|
+
for (let attempt = 0; attempt < attempts; attempt++) try {
|
|
98888
|
+
await rmdir(lockPath);
|
|
98889
|
+
return;
|
|
98890
|
+
} catch (error) {
|
|
98891
|
+
const code = error.code;
|
|
98892
|
+
if (code === "ENOENT") return;
|
|
98893
|
+
if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {
|
|
98894
|
+
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
|
|
98895
|
+
continue;
|
|
98896
|
+
}
|
|
98897
|
+
return;
|
|
98898
|
+
}
|
|
98899
|
+
}
|
|
98900
|
+
/**
|
|
98901
|
+
* Atomically break a stale lock.
|
|
98902
|
+
*
|
|
98903
|
+
* Renames the stale directory to a unique sidecar path and removes it. `rename` is
|
|
98904
|
+
* atomic, so when several waiters race to break the same stale lock only one wins the
|
|
98905
|
+
* rename; the losers see ENOENT and simply retry. This prevents the classic
|
|
98906
|
+
* non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both
|
|
98907
|
+
* acquire it, defeating mutual exclusion.
|
|
98908
|
+
*/
|
|
98909
|
+
async function breakStaleLock(lockPath) {
|
|
98910
|
+
const sidecar = `${lockPath}.stale-${randomUUID()}`;
|
|
98911
|
+
try {
|
|
98912
|
+
await rename(lockPath, sidecar);
|
|
98913
|
+
} catch {
|
|
98914
|
+
return;
|
|
98915
|
+
}
|
|
98916
|
+
await removeLockDir(sidecar);
|
|
98917
|
+
}
|
|
98736
98918
|
/**
|
|
98737
98919
|
* Execute `fn` while holding a lockfile.
|
|
98738
98920
|
*
|
|
@@ -98771,26 +98953,24 @@ async function withLockfile(lockPath, fn, options) {
|
|
|
98771
98953
|
break;
|
|
98772
98954
|
} catch (error) {
|
|
98773
98955
|
if (error.code !== "EEXIST") throw error;
|
|
98956
|
+
let lockStat;
|
|
98774
98957
|
try {
|
|
98775
|
-
|
|
98776
|
-
if (Date.now() - lockStat.mtimeMs > staleMs) {
|
|
98777
|
-
try {
|
|
98778
|
-
await rmdir(lockPath);
|
|
98779
|
-
} catch {}
|
|
98780
|
-
continue;
|
|
98781
|
-
}
|
|
98958
|
+
lockStat = await stat(lockPath);
|
|
98782
98959
|
} catch {
|
|
98783
98960
|
continue;
|
|
98784
98961
|
}
|
|
98962
|
+
if (!lockStat.isDirectory()) throw new Error(`Lock path exists but is not a directory: ${lockPath}. Refusing to break it; remove the conflicting file and retry.`);
|
|
98963
|
+
if (Date.now() - lockStat.mtimeMs > staleMs) {
|
|
98964
|
+
await breakStaleLock(lockPath);
|
|
98965
|
+
continue;
|
|
98966
|
+
}
|
|
98785
98967
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
98786
98968
|
}
|
|
98787
98969
|
if (!acquired) throw new LockAcquisitionError(lockPath, timeoutMs);
|
|
98788
98970
|
try {
|
|
98789
98971
|
return await fn();
|
|
98790
98972
|
} finally {
|
|
98791
|
-
|
|
98792
|
-
await rmdir(lockPath);
|
|
98793
|
-
} catch {}
|
|
98973
|
+
await removeLockDir(lockPath);
|
|
98794
98974
|
}
|
|
98795
98975
|
}
|
|
98796
98976
|
|
|
@@ -99521,6 +99701,55 @@ function resolveIdMappingConflicts(content) {
|
|
|
99521
99701
|
*/
|
|
99522
99702
|
const execFileAsync$1 = promisify(execFile);
|
|
99523
99703
|
/**
|
|
99704
|
+
* Error thrown by {@link git} when a git command exits non-zero.
|
|
99705
|
+
*
|
|
99706
|
+
* Carries the process `exitCode` so callers can branch on git's exit status
|
|
99707
|
+
* (e.g. `ls-remote --exit-code` => 2 means "ref absent", `merge-base` => 1
|
|
99708
|
+
* means "no common ancestor") instead of string-matching stderr. The original
|
|
99709
|
+
* message/stderr is preserved so existing message-based classifiers
|
|
99710
|
+
* (classifySyncError) keep working.
|
|
99711
|
+
*/
|
|
99712
|
+
var GitError = class GitError extends Error {
|
|
99713
|
+
/** Process exit code, or null for a spawn failure (e.g. git not found). */
|
|
99714
|
+
exitCode;
|
|
99715
|
+
stderr;
|
|
99716
|
+
stdout;
|
|
99717
|
+
args;
|
|
99718
|
+
constructor(message, opts) {
|
|
99719
|
+
super(message);
|
|
99720
|
+
this.name = "GitError";
|
|
99721
|
+
this.exitCode = opts.exitCode;
|
|
99722
|
+
this.stderr = opts.stderr;
|
|
99723
|
+
this.stdout = opts.stdout;
|
|
99724
|
+
this.args = opts.args;
|
|
99725
|
+
}
|
|
99726
|
+
/**
|
|
99727
|
+
* Wrap a raw execFile rejection into a GitError.
|
|
99728
|
+
*
|
|
99729
|
+
* Node's execFile rejection carries `code` (numeric exit code for a normal
|
|
99730
|
+
* exit, or a string like 'ENOENT' for a spawn failure), plus `stderr`/`stdout`.
|
|
99731
|
+
*/
|
|
99732
|
+
static from(err, args) {
|
|
99733
|
+
const raw = err;
|
|
99734
|
+
const exitCode = typeof raw.code === "number" ? raw.code : null;
|
|
99735
|
+
const stderr = typeof raw.stderr === "string" ? raw.stderr : "";
|
|
99736
|
+
const stdout = typeof raw.stdout === "string" ? raw.stdout : "";
|
|
99737
|
+
return new GitError(raw.message ?? `git ${args.join(" ")} failed`, {
|
|
99738
|
+
exitCode,
|
|
99739
|
+
stderr,
|
|
99740
|
+
stdout,
|
|
99741
|
+
args
|
|
99742
|
+
});
|
|
99743
|
+
}
|
|
99744
|
+
};
|
|
99745
|
+
/**
|
|
99746
|
+
* Read the git exit code from a thrown value, or null if it is not a GitError
|
|
99747
|
+
* (or carries no numeric code, e.g. a spawn failure).
|
|
99748
|
+
*/
|
|
99749
|
+
function exitCodeOf(err) {
|
|
99750
|
+
return err instanceof GitError ? err.exitCode : null;
|
|
99751
|
+
}
|
|
99752
|
+
/**
|
|
99524
99753
|
* Maximum buffer size for git command output.
|
|
99525
99754
|
*
|
|
99526
99755
|
* Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
|
|
@@ -99535,8 +99764,31 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
|
|
|
99535
99764
|
* Uses execFile for security - prevents shell injection attacks.
|
|
99536
99765
|
*/
|
|
99537
99766
|
async function git(...args) {
|
|
99538
|
-
|
|
99539
|
-
|
|
99767
|
+
try {
|
|
99768
|
+
const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
|
|
99769
|
+
return stdout.trim();
|
|
99770
|
+
} catch (err) {
|
|
99771
|
+
throw GitError.from(err, args);
|
|
99772
|
+
}
|
|
99773
|
+
}
|
|
99774
|
+
/**
|
|
99775
|
+
* Like {@link git} but with `GIT_TERMINAL_PROMPT=0` so a network operation
|
|
99776
|
+
* (e.g. a best-effort push) fails fast instead of blocking on a credential
|
|
99777
|
+
* prompt in a non-interactive environment.
|
|
99778
|
+
*/
|
|
99779
|
+
async function gitNoPrompt(...args) {
|
|
99780
|
+
try {
|
|
99781
|
+
const { stdout } = await execFileAsync$1("git", args, {
|
|
99782
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
99783
|
+
env: {
|
|
99784
|
+
...process.env,
|
|
99785
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
99786
|
+
}
|
|
99787
|
+
});
|
|
99788
|
+
return stdout.trim();
|
|
99789
|
+
} catch (err) {
|
|
99790
|
+
throw GitError.from(err, args);
|
|
99791
|
+
}
|
|
99540
99792
|
}
|
|
99541
99793
|
/**
|
|
99542
99794
|
* Run `git commit` in a worktree with gpg signing disabled at the command level.
|
|
@@ -99834,15 +100086,42 @@ function mergeIssues(base, local, remote) {
|
|
|
99834
100086
|
};
|
|
99835
100087
|
}
|
|
99836
100088
|
/**
|
|
100089
|
+
* Categorize issues from two unrelated tbd-sync roots by id (which embeds the
|
|
100090
|
+
* globally unique ULID). Pure and git-free so the rescue is robust to the
|
|
100091
|
+
* missing merge base. Substantive equality ignores version/updated_at so
|
|
100092
|
+
* trivial bumps don't masquerade as conflicts.
|
|
100093
|
+
*/
|
|
100094
|
+
function categorizeIssuesByUlid(local, remote) {
|
|
100095
|
+
const localById = new Map(local.map((i) => [i.id, i]));
|
|
100096
|
+
const remoteById = new Map(remote.map((i) => [i.id, i]));
|
|
100097
|
+
const buckets = {
|
|
100098
|
+
localOnly: [],
|
|
100099
|
+
remoteOnly: [],
|
|
100100
|
+
bothIdentical: [],
|
|
100101
|
+
bothDifferent: []
|
|
100102
|
+
};
|
|
100103
|
+
for (const [id, localIssue] of localById) {
|
|
100104
|
+
const remoteIssue = remoteById.get(id);
|
|
100105
|
+
if (!remoteIssue) buckets.localOnly.push(localIssue);
|
|
100106
|
+
else if (issuesSubstantivelyEqual(localIssue, remoteIssue)) buckets.bothIdentical.push(remoteIssue);
|
|
100107
|
+
else buckets.bothDifferent.push({
|
|
100108
|
+
local: localIssue,
|
|
100109
|
+
remote: remoteIssue
|
|
100110
|
+
});
|
|
100111
|
+
}
|
|
100112
|
+
for (const [id, remoteIssue] of remoteById) if (!localById.has(id)) buckets.remoteOnly.push(remoteIssue);
|
|
100113
|
+
return buckets;
|
|
100114
|
+
}
|
|
100115
|
+
/**
|
|
99837
100116
|
* Maximum retry attempts for push operations.
|
|
99838
100117
|
*/
|
|
99839
100118
|
const MAX_PUSH_RETRIES = 3;
|
|
99840
100119
|
/**
|
|
99841
|
-
* Check if error is a non-fast-forward rejection.
|
|
100120
|
+
* Check if error is a non-fast-forward rejection (also the init race signal).
|
|
99842
100121
|
*/
|
|
99843
100122
|
function isNonFastForward(error) {
|
|
99844
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
99845
|
-
return
|
|
100123
|
+
const msg = error instanceof GitError ? `${error.stderr}\n${error.message}` : error instanceof Error ? error.message : String(error);
|
|
100124
|
+
return /non-fast-forward|fetch first|rejected|updates were rejected/i.test(msg);
|
|
99846
100125
|
}
|
|
99847
100126
|
/**
|
|
99848
100127
|
* Push with retry and merge on conflict.
|
|
@@ -99905,14 +100184,22 @@ async function branchExists(branch, baseDir) {
|
|
|
99905
100184
|
}
|
|
99906
100185
|
}
|
|
99907
100186
|
/**
|
|
99908
|
-
*
|
|
100187
|
+
* Probe whether a remote branch exists, distinguishing a clean "absent" from a
|
|
100188
|
+
* "check failed".
|
|
100189
|
+
*
|
|
100190
|
+
* `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
|
|
100191
|
+
* matched; any other failure (auth/network/transient, or git not found) is a
|
|
100192
|
+
* check failure. Orphan-creating callers MUST branch on all three states and
|
|
100193
|
+
* never treat `check-failed` as `absent` — doing so risks creating a divergent
|
|
100194
|
+
* local branch when the remote is merely unreachable.
|
|
99909
100195
|
*/
|
|
99910
|
-
async function
|
|
100196
|
+
async function probeRemoteBranch(remote, branch, baseDir) {
|
|
100197
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
99911
100198
|
try {
|
|
99912
|
-
await git(...
|
|
99913
|
-
return
|
|
99914
|
-
} catch {
|
|
99915
|
-
return
|
|
100199
|
+
await git(...dirArgs, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
100200
|
+
return "present";
|
|
100201
|
+
} catch (err) {
|
|
100202
|
+
return exitCodeOf(err) === 2 ? "absent" : "check-failed";
|
|
99916
100203
|
}
|
|
99917
100204
|
}
|
|
99918
100205
|
async function pathExists(path) {
|
|
@@ -100132,6 +100419,66 @@ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH)
|
|
|
100132
100419
|
}
|
|
100133
100420
|
}
|
|
100134
100421
|
/**
|
|
100422
|
+
* Whether the given remote is configured (has a URL) in the repo at baseDir.
|
|
100423
|
+
* A local-only repo (no remote) is safe to orphan and never pushes.
|
|
100424
|
+
*/
|
|
100425
|
+
async function remoteIsConfigured(remote, baseDir) {
|
|
100426
|
+
try {
|
|
100427
|
+
await git("-C", baseDir, "config", "--get", `remote.${remote}.url`);
|
|
100428
|
+
return true;
|
|
100429
|
+
} catch {
|
|
100430
|
+
return false;
|
|
100431
|
+
}
|
|
100432
|
+
}
|
|
100433
|
+
/**
|
|
100434
|
+
* Whether the data-sync worktree carries any user issue files (is-<ulid>.md),
|
|
100435
|
+
* as opposed to only the initial orphan scaffold (.gitkeep / .gitattributes).
|
|
100436
|
+
*/
|
|
100437
|
+
async function worktreeHasUserIssues(dataSyncPath) {
|
|
100438
|
+
try {
|
|
100439
|
+
return (await readdir(join(dataSyncPath, "issues"))).some((name) => /^is-.*\.md$/.test(name));
|
|
100440
|
+
} catch {
|
|
100441
|
+
return false;
|
|
100442
|
+
}
|
|
100443
|
+
}
|
|
100444
|
+
/**
|
|
100445
|
+
* Push a freshly-created orphan tbd-sync immediately so "first init wins"
|
|
100446
|
+
* (closes the #137 race window). Classifies the outcome:
|
|
100447
|
+
*
|
|
100448
|
+
* - success => pushed: true ("first init wins").
|
|
100449
|
+
* - transient/permanent network/auth failure => best-effort, ignored
|
|
100450
|
+
* (branch stays local-only until the first reachable sync).
|
|
100451
|
+
* - non-fast-forward rejection => a detected init race (environment B pushed
|
|
100452
|
+
* its own orphan first). Fetch, then:
|
|
100453
|
+
* - scaffold-only local => adopt the remote (reset local to remote).
|
|
100454
|
+
* - local has user issues => fail loudly toward `tbd doctor --fix` so the
|
|
100455
|
+
* local work is never silently discarded.
|
|
100456
|
+
*
|
|
100457
|
+
* MUST be called after the orphan's initial commit, while the worktree is on
|
|
100458
|
+
* syncBranch.
|
|
100459
|
+
*/
|
|
100460
|
+
async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath) {
|
|
100461
|
+
try {
|
|
100462
|
+
await gitNoPrompt("-C", worktreePath, "push", remote, `HEAD:refs/heads/${syncBranch}`);
|
|
100463
|
+
return {
|
|
100464
|
+
pushed: true,
|
|
100465
|
+
adopted: false
|
|
100466
|
+
};
|
|
100467
|
+
} catch (err) {
|
|
100468
|
+
if (!isNonFastForward(err)) return {
|
|
100469
|
+
pushed: false,
|
|
100470
|
+
adopted: false
|
|
100471
|
+
};
|
|
100472
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
100473
|
+
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.`);
|
|
100474
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
100475
|
+
return {
|
|
100476
|
+
pushed: false,
|
|
100477
|
+
adopted: true
|
|
100478
|
+
};
|
|
100479
|
+
}
|
|
100480
|
+
}
|
|
100481
|
+
/**
|
|
100135
100482
|
* Initialize the hidden worktree for the tbd-sync branch.
|
|
100136
100483
|
* Follows the decision tree from tbd-design.md §2.3.
|
|
100137
100484
|
*
|
|
@@ -100173,7 +100520,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
100173
100520
|
created: true
|
|
100174
100521
|
};
|
|
100175
100522
|
}
|
|
100176
|
-
|
|
100523
|
+
const hasRemote = await remoteIsConfigured(remote, baseDir);
|
|
100524
|
+
const probe = hasRemote ? await probeRemoteBranch(remote, syncBranch, baseDir) : "absent";
|
|
100525
|
+
if (probe === "check-failed") return {
|
|
100526
|
+
success: false,
|
|
100527
|
+
error: `Could not verify whether ${remote}/${syncBranch} exists (remote check failed); refusing to create a divergent local branch. Check connectivity/auth and retry.`
|
|
100528
|
+
};
|
|
100529
|
+
if (probe === "present") {
|
|
100177
100530
|
await git("-C", baseDir, "fetch", remote, syncBranch);
|
|
100178
100531
|
await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
|
|
100179
100532
|
return {
|
|
@@ -100194,6 +100547,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
100194
100547
|
await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
|
|
100195
100548
|
await git("-C", worktreePath, "add", ".");
|
|
100196
100549
|
await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
|
|
100550
|
+
if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
|
|
100197
100551
|
return {
|
|
100198
100552
|
success: true,
|
|
100199
100553
|
path: worktreePath,
|
|
@@ -100250,22 +100604,30 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
|
|
|
100250
100604
|
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
100251
100605
|
const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
100252
100606
|
let diverged = false;
|
|
100253
|
-
|
|
100254
|
-
|
|
100255
|
-
const localHead = await git(...dirArgs, "rev-parse", syncBranch);
|
|
100256
|
-
|
|
100257
|
-
|
|
100258
|
-
|
|
100607
|
+
let unrelated = false;
|
|
100608
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
100609
|
+
const localHead = (await git(...dirArgs, "rev-parse", syncBranch)).trim();
|
|
100610
|
+
try {
|
|
100611
|
+
const mergeBase = (await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`)).trim();
|
|
100612
|
+
diverged = mergeBase !== localHead && mergeBase !== remoteHead;
|
|
100613
|
+
} catch (err) {
|
|
100614
|
+
if (exitCodeOf(err) === 1) {
|
|
100615
|
+
unrelated = true;
|
|
100616
|
+
diverged = true;
|
|
100617
|
+
}
|
|
100618
|
+
}
|
|
100259
100619
|
}
|
|
100260
100620
|
return {
|
|
100261
100621
|
exists: true,
|
|
100262
100622
|
diverged,
|
|
100623
|
+
unrelated,
|
|
100263
100624
|
head: remoteHead
|
|
100264
100625
|
};
|
|
100265
100626
|
} catch {
|
|
100266
100627
|
return {
|
|
100267
100628
|
exists: false,
|
|
100268
|
-
diverged: false
|
|
100629
|
+
diverged: false,
|
|
100630
|
+
unrelated: false
|
|
100269
100631
|
};
|
|
100270
100632
|
}
|
|
100271
100633
|
}
|
|
@@ -100304,6 +100666,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
100304
100666
|
localBehind
|
|
100305
100667
|
};
|
|
100306
100668
|
}
|
|
100669
|
+
/** Read all issues from a git ref's data-sync tree (no checkout needed). */
|
|
100670
|
+
async function readBranchIssues(baseDir, ref) {
|
|
100671
|
+
let listing;
|
|
100672
|
+
try {
|
|
100673
|
+
listing = await git("-C", baseDir, "ls-tree", "-r", "--name-only", ref, "--", `${DATA_SYNC_DIR}/issues/`);
|
|
100674
|
+
} catch {
|
|
100675
|
+
return [];
|
|
100676
|
+
}
|
|
100677
|
+
const issues = [];
|
|
100678
|
+
for (const path of listing.split("\n").filter((p) => /\/is-.*\.md$/.test(p))) try {
|
|
100679
|
+
const content = await git("-C", baseDir, "show", `${ref}:${path}`);
|
|
100680
|
+
issues.push(parseIssue(content));
|
|
100681
|
+
} catch {}
|
|
100682
|
+
return issues;
|
|
100683
|
+
}
|
|
100684
|
+
/** Read the ID mapping from a git ref's ids.yml (empty if absent). */
|
|
100685
|
+
async function readBranchMapping(baseDir, ref) {
|
|
100686
|
+
try {
|
|
100687
|
+
return parseIdMappingFromYaml(await git("-C", baseDir, "show", `${ref}:${DATA_SYNC_DIR}/mappings/ids.yml`));
|
|
100688
|
+
} catch {
|
|
100689
|
+
return {
|
|
100690
|
+
shortToUlid: /* @__PURE__ */ new Map(),
|
|
100691
|
+
ulidToShort: /* @__PURE__ */ new Map()
|
|
100692
|
+
};
|
|
100693
|
+
}
|
|
100694
|
+
}
|
|
100695
|
+
/** Preserve a losing issue version explicitly under attic/conflicts/. */
|
|
100696
|
+
async function preserveLosingVersion(dataSyncPath, loser) {
|
|
100697
|
+
const conflictsDir = join(dataSyncPath, "attic", "conflicts");
|
|
100698
|
+
await mkdir(conflictsDir, { recursive: true });
|
|
100699
|
+
await writeFile(join(conflictsDir, `${loser.id}__${nowFilenameTimestamp()}.md`), serializeIssue(loser));
|
|
100700
|
+
}
|
|
100701
|
+
/**
|
|
100702
|
+
* Non-destructively rescue an unrelated tbd-sync history (#139).
|
|
100703
|
+
*
|
|
100704
|
+
* Reconciles at the issue-file layer (ULIDs guarantee no collisions), never via
|
|
100705
|
+
* a git history merge. Adopts the remote as the canonical base and replays
|
|
100706
|
+
* local work onto it. The only destructive step (the reset) happens AFTER a
|
|
100707
|
+
* backup branch is created, so the pre-rescue HEAD is always recoverable and
|
|
100708
|
+
* the rescue is restartable.
|
|
100709
|
+
*
|
|
100710
|
+
* MUST be called while holding `withSharedDataSyncLock`. Aborts if the
|
|
100711
|
+
* data-sync worktree is dirty or has a merge in progress.
|
|
100712
|
+
*/
|
|
100713
|
+
async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
100714
|
+
const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
|
|
100715
|
+
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.");
|
|
100716
|
+
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.");
|
|
100717
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
100718
|
+
const localIssues = await listIssues(dataSyncPath);
|
|
100719
|
+
const localMapping = await loadIdMapping(dataSyncPath);
|
|
100720
|
+
const remoteIssues = await readBranchIssues(baseDir, `${remote}/${syncBranch}`);
|
|
100721
|
+
const remoteMapping = await readBranchMapping(baseDir, `${remote}/${syncBranch}`);
|
|
100722
|
+
const localHead = (await git("-C", baseDir, "rev-parse", syncBranch)).trim();
|
|
100723
|
+
const backupBranch = `tbd-backup-${nowFilenameTimestamp()}`;
|
|
100724
|
+
await git("-C", baseDir, "branch", backupBranch, localHead);
|
|
100725
|
+
const buckets = categorizeIssuesByUlid(localIssues, remoteIssues);
|
|
100726
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
100727
|
+
let conflicts = 0;
|
|
100728
|
+
for (const issue of buckets.localOnly) await writeIssue(dataSyncPath, issue);
|
|
100729
|
+
for (const { local, remote: remoteIssue } of buckets.bothDifferent) {
|
|
100730
|
+
const { merged } = mergeIssues(null, local, remoteIssue);
|
|
100731
|
+
await writeIssue(dataSyncPath, merged);
|
|
100732
|
+
for (const side of [local, remoteIssue]) if (!issuesSubstantivelyEqual(side, merged)) {
|
|
100733
|
+
await preserveLosingVersion(dataSyncPath, side);
|
|
100734
|
+
conflicts++;
|
|
100735
|
+
}
|
|
100736
|
+
}
|
|
100737
|
+
const mergedMapping = mergeIdMappings(remoteMapping, localMapping);
|
|
100738
|
+
const allIssues = await listIssues(dataSyncPath);
|
|
100739
|
+
reconcileMappings(allIssues.map((i) => i.id), mergedMapping, remoteMapping);
|
|
100740
|
+
await saveIdMapping(dataSyncPath, mergedMapping);
|
|
100741
|
+
await git("-C", worktreePath, "add", "-A");
|
|
100742
|
+
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)`);
|
|
100743
|
+
return {
|
|
100744
|
+
backupBranch,
|
|
100745
|
+
localOnly: buckets.localOnly.length,
|
|
100746
|
+
merged: buckets.bothDifferent.length,
|
|
100747
|
+
conflicts,
|
|
100748
|
+
totalIssues: allIssues.length
|
|
100749
|
+
};
|
|
100750
|
+
}
|
|
100307
100751
|
/**
|
|
100308
100752
|
* Count issues on a remote sync branch without creating a worktree.
|
|
100309
100753
|
* Used by doctor to show accurate statistics on fresh clones.
|
|
@@ -100651,124 +101095,6 @@ const initCommand = new Command("init").description("Initialize tbd in a git rep
|
|
|
100651
101095
|
await new InitHandler(command).run(options);
|
|
100652
101096
|
});
|
|
100653
101097
|
|
|
100654
|
-
//#endregion
|
|
100655
|
-
//#region src/utils/zod-error-utils.ts
|
|
100656
|
-
/**
|
|
100657
|
-
* Helpers for rendering Zod errors without relying on object inspection.
|
|
100658
|
-
*/
|
|
100659
|
-
/**
|
|
100660
|
-
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
100661
|
-
*/
|
|
100662
|
-
function formatZodError(error) {
|
|
100663
|
-
const messages = error.issues.map((issue) => {
|
|
100664
|
-
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
100665
|
-
});
|
|
100666
|
-
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
100667
|
-
}
|
|
100668
|
-
/**
|
|
100669
|
-
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
100670
|
-
*/
|
|
100671
|
-
function formatUnknownError(error) {
|
|
100672
|
-
if (error instanceof ZodError) return formatZodError(error);
|
|
100673
|
-
if (error instanceof Error) return error.message;
|
|
100674
|
-
return String(error);
|
|
100675
|
-
}
|
|
100676
|
-
|
|
100677
|
-
//#endregion
|
|
100678
|
-
//#region src/file/storage.ts
|
|
100679
|
-
/**
|
|
100680
|
-
* Storage layer for issue files.
|
|
100681
|
-
*
|
|
100682
|
-
* Provides atomic file operations and issue CRUD operations.
|
|
100683
|
-
* All operations work on the data-sync directory selected by the caller. In production
|
|
100684
|
-
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
100685
|
-
*
|
|
100686
|
-
* See: tbd-design.md §3.2 Storage Layer
|
|
100687
|
-
*/
|
|
100688
|
-
/**
|
|
100689
|
-
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
100690
|
-
*/
|
|
100691
|
-
const ISSUE_READ_BATCH_SIZE = 200;
|
|
100692
|
-
/**
|
|
100693
|
-
* Get the path to an issue file.
|
|
100694
|
-
*/
|
|
100695
|
-
function getIssuePath(baseDir, id) {
|
|
100696
|
-
return join(baseDir, "issues", `${id}.md`);
|
|
100697
|
-
}
|
|
100698
|
-
/**
|
|
100699
|
-
* Read an issue from the worktree.
|
|
100700
|
-
* @throws If the issue file doesn't exist or is invalid.
|
|
100701
|
-
*/
|
|
100702
|
-
async function readIssue(baseDir, id) {
|
|
100703
|
-
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
100704
|
-
}
|
|
100705
|
-
/**
|
|
100706
|
-
* Write an issue to the worktree.
|
|
100707
|
-
* Uses atomic write to prevent corruption.
|
|
100708
|
-
*/
|
|
100709
|
-
async function writeIssue(baseDir, issue) {
|
|
100710
|
-
const validIssue = IssueSchema.parse(issue);
|
|
100711
|
-
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
100712
|
-
}
|
|
100713
|
-
/**
|
|
100714
|
-
* List all issues in the worktree.
|
|
100715
|
-
* Returns empty array if issues directory doesn't exist.
|
|
100716
|
-
*
|
|
100717
|
-
* Uses parallel file reading for better performance with many issues.
|
|
100718
|
-
*/
|
|
100719
|
-
async function listIssues(baseDir, options = {}) {
|
|
100720
|
-
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
100721
|
-
const issuesDir = join(baseDir, "issues");
|
|
100722
|
-
let files;
|
|
100723
|
-
try {
|
|
100724
|
-
files = await readdir(issuesDir);
|
|
100725
|
-
} catch {
|
|
100726
|
-
return [];
|
|
100727
|
-
}
|
|
100728
|
-
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
100729
|
-
const issues = [];
|
|
100730
|
-
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
100731
|
-
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
100732
|
-
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
100733
|
-
const filePath = join(issuesDir, file);
|
|
100734
|
-
try {
|
|
100735
|
-
return {
|
|
100736
|
-
file,
|
|
100737
|
-
content: await readFile(filePath, "utf-8")
|
|
100738
|
-
};
|
|
100739
|
-
} catch (error) {
|
|
100740
|
-
return {
|
|
100741
|
-
file,
|
|
100742
|
-
error: formatUnknownError(error)
|
|
100743
|
-
};
|
|
100744
|
-
}
|
|
100745
|
-
}));
|
|
100746
|
-
for (const result of fileContents) {
|
|
100747
|
-
if ("error" in result) {
|
|
100748
|
-
reportInvalidIssueFile({
|
|
100749
|
-
file: result.file,
|
|
100750
|
-
reason: `failed to read file: ${result.error}`
|
|
100751
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
100752
|
-
continue;
|
|
100753
|
-
}
|
|
100754
|
-
try {
|
|
100755
|
-
const issue = parseIssue(result.content);
|
|
100756
|
-
issues.push(issue);
|
|
100757
|
-
} catch (error) {
|
|
100758
|
-
reportInvalidIssueFile({
|
|
100759
|
-
file: result.file,
|
|
100760
|
-
reason: formatUnknownError(error)
|
|
100761
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
100762
|
-
}
|
|
100763
|
-
}
|
|
100764
|
-
}
|
|
100765
|
-
return issues;
|
|
100766
|
-
}
|
|
100767
|
-
function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
|
|
100768
|
-
onInvalidIssue?.(invalidIssue);
|
|
100769
|
-
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
100770
|
-
}
|
|
100771
|
-
|
|
100772
101098
|
//#endregion
|
|
100773
101099
|
//#region src/lib/priority.ts
|
|
100774
101100
|
/**
|
|
@@ -104042,6 +104368,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104042
104368
|
this.output.debug(`Committed ${count} file(s) to sync branch`);
|
|
104043
104369
|
}
|
|
104044
104370
|
await git("fetch", remote, syncBranch);
|
|
104371
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
104045
104372
|
let behindCommits = 0;
|
|
104046
104373
|
try {
|
|
104047
104374
|
const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
|
|
@@ -104146,6 +104473,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104146
104473
|
}
|
|
104147
104474
|
}
|
|
104148
104475
|
} catch (error) {
|
|
104476
|
+
if (error instanceof SyncError) throw error;
|
|
104149
104477
|
this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
|
|
104150
104478
|
}
|
|
104151
104479
|
let aheadCommits = 0;
|
|
@@ -104178,6 +104506,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104178
104506
|
summary.conflicts = conflicts.length;
|
|
104179
104507
|
spinner.stop();
|
|
104180
104508
|
if (pushFailed) {
|
|
104509
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
104181
104510
|
let displayError = pushError;
|
|
104182
104511
|
const httpMatch = /HTTP (\d+)/.exec(pushError);
|
|
104183
104512
|
const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
|
|
@@ -105314,6 +105643,35 @@ function renderDiagnostics(results, colors) {
|
|
|
105314
105643
|
*
|
|
105315
105644
|
* See: tbd-design.md §4.9 Doctor
|
|
105316
105645
|
*/
|
|
105646
|
+
/**
|
|
105647
|
+
* Map remote sync-branch health to a "Remote sync branch" diagnostic.
|
|
105648
|
+
*
|
|
105649
|
+
* Returns null when the remote branch does not exist (the caller then falls
|
|
105650
|
+
* through to local-branch / new-repo handling). Unrelated histories are a hard
|
|
105651
|
+
* ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` — push
|
|
105652
|
+
* can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
|
|
105653
|
+
*/
|
|
105654
|
+
function classifyRemoteSyncHealth(health, remote, syncBranch) {
|
|
105655
|
+
if (!health.exists) return null;
|
|
105656
|
+
if (health.unrelated) return {
|
|
105657
|
+
name: "Remote sync branch",
|
|
105658
|
+
status: "error",
|
|
105659
|
+
fixable: true,
|
|
105660
|
+
message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) — push cannot succeed`,
|
|
105661
|
+
suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
|
|
105662
|
+
};
|
|
105663
|
+
if (health.diverged) return {
|
|
105664
|
+
name: "Remote sync branch",
|
|
105665
|
+
status: "warn",
|
|
105666
|
+
message: `${remote}/${syncBranch} has diverged`,
|
|
105667
|
+
suggestion: "Run: tbd sync to reconcile changes"
|
|
105668
|
+
};
|
|
105669
|
+
return {
|
|
105670
|
+
name: "Remote sync branch",
|
|
105671
|
+
status: "ok",
|
|
105672
|
+
message: `${remote}/${syncBranch}`
|
|
105673
|
+
};
|
|
105674
|
+
}
|
|
105317
105675
|
const CONFIG_DIR = TBD_DIR;
|
|
105318
105676
|
var DoctorHandler = class extends BaseCommand {
|
|
105319
105677
|
dataSyncDir = "";
|
|
@@ -105365,7 +105723,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
105365
105723
|
const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
|
|
105366
105724
|
healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
|
|
105367
105725
|
healthChecks.push(await this.checkLocalSyncBranch());
|
|
105368
|
-
healthChecks.push(await this.checkRemoteSyncBranch());
|
|
105726
|
+
healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
|
|
105369
105727
|
healthChecks.push(await this.checkLocalVsRemoteData());
|
|
105370
105728
|
healthChecks.push(await this.checkCloneScenarios());
|
|
105371
105729
|
healthChecks.push(await this.checkSyncConsistency());
|
|
@@ -106198,23 +106556,27 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
106198
106556
|
* Check remote sync branch health.
|
|
106199
106557
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
|
|
106200
106558
|
*/
|
|
106201
|
-
async checkRemoteSyncBranch() {
|
|
106559
|
+
async checkRemoteSyncBranch(fix) {
|
|
106202
106560
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
106203
106561
|
const remote = this.config?.sync.remote ?? "origin";
|
|
106204
106562
|
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
106205
|
-
if (remoteHealth.
|
|
106206
|
-
|
|
106563
|
+
if (remoteHealth.unrelated && fix && !this.checkDryRun("Rescue unrelated tbd-sync histories")) try {
|
|
106564
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => rescueUnrelatedHistory(this.cwd, remote, syncBranch));
|
|
106565
|
+
return {
|
|
106207
106566
|
name: "Remote sync branch",
|
|
106208
|
-
status: "
|
|
106209
|
-
message:
|
|
106210
|
-
suggestion: "Run: tbd sync to reconcile changes"
|
|
106567
|
+
status: "ok",
|
|
106568
|
+
message: `rescued: adopted ${remote}/${syncBranch} base, reconciled ${result.localOnly} local-only + ${result.merged} merged (backup: ${result.backupBranch})`
|
|
106211
106569
|
};
|
|
106570
|
+
} catch (error) {
|
|
106212
106571
|
return {
|
|
106213
106572
|
name: "Remote sync branch",
|
|
106214
|
-
status: "
|
|
106215
|
-
message:
|
|
106573
|
+
status: "error",
|
|
106574
|
+
message: `rescue failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
106575
|
+
suggestion: "Resolve manually; the pre-rescue state is on the tbd-backup-* branch"
|
|
106216
106576
|
};
|
|
106217
106577
|
}
|
|
106578
|
+
const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
|
|
106579
|
+
if (diag) return diag;
|
|
106218
106580
|
if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
|
|
106219
106581
|
name: "Remote sync branch",
|
|
106220
106582
|
status: "warn",
|
|
@@ -108116,6 +108478,7 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
108116
108478
|
console.log(`${colors.success("✓")} Initialized in this repo`);
|
|
108117
108479
|
if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
|
|
108118
108480
|
else console.log(`${colors.dim("✗")} Hooks not installed (run: tbd setup --auto)`);
|
|
108481
|
+
console.log(colors.dim(" Run `tbd setup --auto` to refresh skills and settings (e.g. after upgrading tbd)."));
|
|
108119
108482
|
console.log("");
|
|
108120
108483
|
console.log(colors.bold("=== PROJECT STATUS ==="));
|
|
108121
108484
|
try {
|
|
@@ -109058,9 +109421,11 @@ async function getShortcutDirectory(quiet = false) {
|
|
|
109058
109421
|
}
|
|
109059
109422
|
/**
|
|
109060
109423
|
* DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
|
|
109424
|
+
* Carries the shared `format=fNN` integration code so the forward-compatibility
|
|
109425
|
+
* guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
|
|
109061
109426
|
* Formatted to match flowmark output.
|
|
109062
109427
|
*/
|
|
109063
|
-
const SKILL_DO_NOT_EDIT_MARKER =
|
|
109428
|
+
const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;
|
|
109064
109429
|
/**
|
|
109065
109430
|
* Build the full generated SKILL.md payload: the bundled skill content with the
|
|
109066
109431
|
* shortcut/guideline directory appended and a DO NOT EDIT marker after the
|
|
@@ -109078,8 +109443,19 @@ async function buildSkillPayload(quiet = false) {
|
|
|
109078
109443
|
}
|
|
109079
109444
|
/**
|
|
109080
109445
|
* Write a generated SKILL.md payload to a target path, creating parent dirs.
|
|
109446
|
+
*
|
|
109447
|
+
* Forward-compatibility guard: if an existing skill carries a newer
|
|
109448
|
+
* `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
|
|
109449
|
+
* the user to upgrade. Guarding each generated surface at its own write point
|
|
109450
|
+
* means an older tbd cannot partial-downgrade a newer committed skill,
|
|
109451
|
+
* regardless of the order in which surfaces are written.
|
|
109081
109452
|
*/
|
|
109082
109453
|
async function writeSkillFile(targetPath, payload) {
|
|
109454
|
+
let existing = null;
|
|
109455
|
+
try {
|
|
109456
|
+
existing = await readFile(targetPath, "utf-8");
|
|
109457
|
+
} catch {}
|
|
109458
|
+
if (existing !== null) assertNotNewerFormat(existing, targetPath);
|
|
109083
109459
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
109084
109460
|
await writeFile(targetPath, payload);
|
|
109085
109461
|
}
|
|
@@ -110124,10 +110500,16 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110124
110500
|
const entries = await readdir(scriptsDir, { withFileTypes: true });
|
|
110125
110501
|
for (const entry of entries) if (entry.isFile()) {
|
|
110126
110502
|
const filename = entry.name;
|
|
110127
|
-
if (LEGACY_TBD_SCRIPTS.includes(filename))
|
|
110128
|
-
|
|
110129
|
-
|
|
110130
|
-
|
|
110503
|
+
if (LEGACY_TBD_SCRIPTS.includes(filename)) {
|
|
110504
|
+
if (this.ctx.dryRun) {
|
|
110505
|
+
scriptsRemoved.push(filename);
|
|
110506
|
+
continue;
|
|
110507
|
+
}
|
|
110508
|
+
try {
|
|
110509
|
+
await rm(join(scriptsDir, filename));
|
|
110510
|
+
scriptsRemoved.push(filename);
|
|
110511
|
+
} catch {}
|
|
110512
|
+
}
|
|
110131
110513
|
}
|
|
110132
110514
|
} catch {}
|
|
110133
110515
|
return scriptsRemoved;
|
|
@@ -110171,7 +110553,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110171
110553
|
modified = true;
|
|
110172
110554
|
}
|
|
110173
110555
|
}
|
|
110174
|
-
if (modified) {
|
|
110556
|
+
if (modified && !this.ctx.dryRun) {
|
|
110175
110557
|
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
110176
110558
|
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
110177
110559
|
}
|
|
@@ -110189,7 +110571,8 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110189
110571
|
const parts = [];
|
|
110190
110572
|
if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
|
|
110191
110573
|
if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
|
|
110192
|
-
|
|
110574
|
+
if (this.ctx.dryRun) this.output.dryRun(`Would clean up legacy ${parts.join(" and ")}`);
|
|
110575
|
+
else console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
|
|
110193
110576
|
}
|
|
110194
110577
|
await this.syncDocs(cwd);
|
|
110195
110578
|
const targeting = this.resolveTargeting();
|