diff --git a/.worktreecopy b/.worktreecopy new file mode 100644 index 00000000..11ec4c04 --- /dev/null +++ b/.worktreecopy @@ -0,0 +1,9 @@ +# Local runtime hints copied by `bun scripts/cli.ts dev-env worktree add`. +# Keep this file to path patterns only; never store secret values here. +.env/trans.ymal +.env/*.local.yaml +.env/*.local.yml +.env/*.local.json +config/*.local.yaml +config/*.local.yml +config/*.local.json diff --git a/docs/reference/dev-environment.md b/docs/reference/dev-environment.md index c2048724..4d18807c 100644 --- a/docs/reference/dev-environment.md +++ b/docs/reference/dev-environment.md @@ -25,7 +25,7 @@ trans D601:/home/ubuntu/workspace/unidesk-dev git remote -v 若路径、分支或 remote 不符合预期,先修正 fixed workspace,再继续。`/home/ubuntu/cq-deploy`、Code Queue pod 内 `/root/unidesk`/`/app`、D601 上的 `/root/unidesk` 和 `/tmp/unidesk-*` 只作为部署副本、运行副本或临时实验面;运行面热修可以直接作用在 pod/容器,但必须随后把持久化修复提交到 Git remote,并在 fixed workspace 中复验。 -固定 workspace 只作为 source truth 预检、fetch、worktree 管理和最终同步入口。实际开发、文档修改、测试补丁和 PR 准备应在固定 repo 下的独立 worktree 中完成,例如 `/home/ubuntu/workspace/unidesk-dev/.worktree/`;该 worktree 必须从最新 `origin/master` 创建,使用任务专属分支或 detached worktree 隔离当前改动,结束前用 `git status` 确认只包含本任务文件。不要把 `/home/ubuntu/workspace/unidesk-dev` 根目录当作并行任务 scratch 区,也不要复用其他任务遗留 worktree。 +固定 workspace 只作为 source truth 预检、fetch、受控 worktree 创建和最终同步入口。实际开发、文档修改、测试补丁和 PR 准备应在固定 repo 下的独立 worktree 中完成,例如 `/home/ubuntu/workspace/unidesk-dev/.worktree/`;该 worktree 必须通过 `bun scripts/cli.ts dev-env worktree add .worktree/ --branch --from origin/master` 从最新 `origin/master` 创建,让 `.worktreecopy` 中声明的本地配置白名单自动复制到新 worktree。使用任务专属分支或 detached worktree 隔离当前改动,结束前用 `git status` 确认只包含本任务文件。不要把 `/home/ubuntu/workspace/unidesk-dev` 根目录当作并行任务 scratch 区,也不要复用其他任务遗留 worktree。 Master server 不作为 UniDesk 重型验证机。仓库级 check、Playwright/browser smoke、镜像构建、Rust/Go 编译和 Code Queue runner 实测必须放到 D601、CI runner 或其他获批执行面;master server 只做轻量源码编辑、Git 操作、状态观察和受控调度。唯一例外是 backend-core 主 server 上线:当用户或 issue 明确要求把当前 backend-core 修复上线到主 server 时,可以用 `CARGO_BUILD_JOBS=1`、`--jobs 1` 或 CLI 内置等价限流执行 backend-core 专属编译,并必须用异步 job/status/health 证据回写 issue。 @@ -115,7 +115,7 @@ The persistent dev environment follows the shared Git-backed deployment hygiene Use this sequence for backend-core Rust and frontend dev work: -1. Preflight the fixed workspace, then develop in a task-scoped `.worktree/` created from the latest `origin/master`; keep unrelated parallel changes separated with `git status`/`git diff`. +1. Preflight the fixed workspace, then create the task-scoped worktree with `bun scripts/cli.ts dev-env worktree add .worktree/ --branch --from origin/master`; keep unrelated parallel changes separated with `git status`/`git diff`. 2. Run local non-Rust checks on the master server, for example `bun scripts/cli.ts check --files --scripts-typecheck --compose --logs`. 3. Commit the code from the task worktree and merge/push it to `origin/master` through the chosen lightweight PR or direct integration path; `deploy apply --env dev` cannot deploy unpushed local changes. 4. Update `deploy.json` `environments.dev.services` so `backend-core` and `frontend` point at the pushed commit, then commit and push that manifest update. diff --git a/docs/reference/devops-hygiene.md b/docs/reference/devops-hygiene.md index 40ecbb3b..e24d1309 100644 --- a/docs/reference/devops-hygiene.md +++ b/docs/reference/devops-hygiene.md @@ -50,13 +50,13 @@ If a manual repair is needed to unblock the platform, the durable fix must be co “分布式敏捷”是 UniDesk 对 distributed agile field repair 的固定流程名;通用 P1/P2/P3/P4 阶段、禁止行为和证据边界由 `$dad-dev` skill 维护,本参考不再重复展开。UniDesk 项目内只保留下面的特有约束:必须使用结构化 `trans`/UniDesk CLI 进入真实 provider、pod、host bridge 或 service port;运行面热补只能证明方向或临时恢复,不能成为隐藏部署真相;持久化完成必须回到 Git/PR/CI/CD 后原入口复测。 -固定主 repo 是 source truth anchor,不是源码/运行面 scratch 区。会产生源码、配置、issue closeout、部署脚本、验收产物或高风险 dad-dev / post-task 交付的工作,执行前必须先从目标 fixed repo 的最新 remote/base 创建任务专属 `.worktree/`,后续编辑、验证、提交、push 和受控 CLI 写操作都在该 worktree 内完成。fixed repo 只用于 `git fetch`、`git status`、快进同步、读取规则和 `git worktree add`;其中已有的并行未提交修改默认保持不动,不纳入当前任务,也不要用 reset、checkout 或删除来“清理”。 +固定主 repo 是 source truth anchor,不是源码/运行面 scratch 区。会产生源码、配置、issue closeout、部署脚本、验收产物或高风险 dad-dev / post-task 交付的工作,执行前必须先从目标 fixed repo 的最新 remote/base 创建任务专属 `.worktree/`,后续编辑、验证、提交、push 和受控 CLI 写操作都在该 worktree 内完成。UniDesk 任务 worktree 必须通过 `bun scripts/cli.ts dev-env worktree add .worktree/ --branch [--from origin/master]` 创建,让 `.worktreecopy` 白名单内的本地配置自动复制到新 worktree;fixed repo 只用于 `git fetch`、`git status`、快进同步、读取规则和这个受控创建入口。其中已有的并行未提交修改默认保持不动,不纳入当前任务,也不要用 reset、checkout 或删除来“清理”。 开始使用任何固定主/目标 worktree 时,如果发现它落后 remote/base,必须先完成 source-truth 同步再继续分析或创建任务 worktree:若工作区有脏改,先用 `git stash push -u` 保存当前脏改(包括 untracked),再执行 `git pull --ff-only ` 快进,随后 `git stash apply` 并按快进后的语义合并冲突或重复内容;若工作区 clean,则直接 `git pull --ff-only`。这个 stash 是保护并行修改以便固定主 repo 回到最新 source truth,不是清理、丢弃或隐式接管并行任务;apply 后只提交当前任务明确相关的文件,其他并行修改继续保留原状。不得在落后的固定 worktree 上继续源码分析、创建任务分支、修改代码或给 issue 写基于旧源码的结论。 固定 worktree 对齐 remote 是硬前置,不是建议项。`git status` clean、运行面 healthy、CI/CD snapshot 对齐或历史 PipelineRun 通过,都不能替代固定工作区自身的 remote/base 对齐检查;只要 HEAD 与声明 remote/base 不一致、`remoteUpToDate=false`、ahead/behind/diverged 或受控 source-workspace 状态返回 not-ready,就必须先修复固定工作区或改用从最新 remote/base 创建的干净 anchor。禁止从未对齐的固定 worktree 创建任务 worktree、开测试 PR、运行 repo 内验证或写 closeout 结论。 -所有源码、配置、部署脚本和运行面修复都必须在从最新 remote/base 创建的独立 `.worktree/` 中完成;固定主 worktree 不直接承载代码修改。PR 合并或等价集成进入 remote base 后,应及时回到对应固定主 worktree 执行 `git fetch` 与 `git pull --ff-only`,让下一轮 source-truth 预检、web-probe 复核、issue closeout 和新任务 worktree 都从已合并的最新源码开始。若固定主 worktree 因并行脏改不能安全快进,保留脏改并改用干净的最新 remote/base worktree 继续,不能 reset、checkout 或删除他人修改。 +所有源码、配置、部署脚本和运行面修复都必须在从最新 remote/base 创建的独立 `.worktree/` 中完成;固定主 worktree 不直接承载代码修改。`.worktreecopy` 只能声明可复制的本地文件路径模式,支持空行、注释、glob 和 `!pattern` 排除/重包含语义,不得写入 secret 值;复制结果默认不覆盖目标文件,只有显式 `--overwrite-local` 才允许覆盖。PR 合并或等价集成进入 remote base 后,应及时回到对应固定主 worktree 执行 `git fetch` 与 `git pull --ff-only`,让下一轮 source-truth 预检、web-probe 复核、issue closeout 和新任务 worktree 都从已合并的最新源码开始。若固定主 worktree 因并行脏改不能安全快进,保留脏改并改用干净的最新 remote/base worktree 继续,不能 reset、checkout 或删除他人修改。 任务 worktree 清理前必须做语义合并核查。最低要求是:worktree clean;相关提交已经是 `origin/master` 祖先,或 `git log --left-right --cherry-pick ...origin/master` 没有 left-only 未吸收 patch;必要时再核对关键文件 diff、PR merge commit、issue closeout 和运行面验证是否对应最新 `master`。只有确认当前任务语义已经进入 `master` 或被更新实现等价替代后,才允许 `git worktree remove `;不得只因为分支落后、PR 已关闭、文件看起来相似或本地空间紧张就删除。若发现未提交文件、未推送提交、left-only patch 或语义不确定,先把应保留内容提交/合并/推送到 `master`,或记录阻塞并保留 worktree。 diff --git a/scripts/cli.ts b/scripts/cli.ts index a13ef752..107f2f88 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -326,6 +326,11 @@ async function main(): Promise { if (top === "dev-env") { const result = runDevEnvCommand(args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; + if (isRenderedCliResult(result)) { + emitText(result.renderedText, result.command || commandName); + if (!ok) process.exitCode = 1; + return; + } emitJson(commandName, result, ok); if (!ok) process.exitCode = 1; return; diff --git a/scripts/src/dev-env.ts b/scripts/src/dev-env.ts index d61c1511..6f9086a2 100644 --- a/scripts/src/dev-env.ts +++ b/scripts/src/dev-env.ts @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { runCommand } from "./command"; import { repoRoot, rootPath } from "./config"; import { k3sGuardShellLines, defaultNativeKubeconfig } from "./k3s-target-guard"; @@ -13,6 +14,20 @@ const defaultPrewarmImages = [ "postgres:16-alpine", "rancher/mirrored-library-busybox:1.36.1", ]; +const defaultWorktreeCopyFile = ".worktreecopy"; +const defaultWorktreeFromRef = "origin/master"; +const maxWorktreeCopyBytes = 1024 * 1024; +const refusedWorktreeCopyPrefixes = [ + ".git/", + ".worktree/", + "node_modules/", + "dist/", + "build/", + "coverage/", + ".state/", + "tmp/", + ".tmp/", +]; const foundationRequiredKinds = new Set([ "Namespace/unidesk-dev", "Secret/unidesk-dev-runtime-secrets", @@ -71,6 +86,29 @@ interface PrewarmImagesOptions { dryRun: boolean; } +interface WorktreeAddOptions { + targetPath: string; + branch: string | null; + fromRef: string; + copyLocal: boolean; + overwriteLocal: boolean; + dryRun: boolean; + json: boolean; +} + +interface WorktreeCopyPattern { + raw: string; + pattern: string; + include: boolean; +} + +interface WorktreeCopyRow { + path: string; + status: "copied" | "skipped" | "refused"; + reason: string; + bytes: number | null; +} + function isHelpArg(arg: string | undefined): boolean { return arg === "help" || arg === "--help" || arg === "-h"; } @@ -148,6 +186,53 @@ function parsePrewarmImagesOptions(args: string[]): PrewarmImagesOptions { return options; } +function parseWorktreeAddOptions(args: string[]): WorktreeAddOptions { + const positional: string[] = []; + const options: WorktreeAddOptions = { + targetPath: "", + branch: null, + fromRef: defaultWorktreeFromRef, + copyLocal: true, + overwriteLocal: false, + dryRun: false, + json: false, + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--branch" || arg === "-b") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error("--branch requires a branch name"); + rejectUnsafeToken(value, "--branch"); + options.branch = value; + index += 1; + } else if (arg === "--from") { + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error("--from requires a git ref"); + rejectUnsafeToken(value, "--from"); + options.fromRef = value; + index += 1; + } else if (arg === "--copy-local") { + options.copyLocal = true; + } else if (arg === "--no-copy-local") { + options.copyLocal = false; + } else if (arg === "--overwrite-local") { + options.overwriteLocal = true; + } else if (arg === "--dry-run") { + options.dryRun = true; + } else if (arg === "--json") { + options.json = true; + } else if (!isHelpArg(arg)) { + if (arg.startsWith("-")) throw new Error(`unknown dev-env worktree add option: ${arg}`); + positional.push(arg); + } + } + if (positional.length !== 1) throw new Error("dev-env worktree add usage: bun scripts/cli.ts dev-env worktree add .worktree/ --branch [--from origin/master]"); + options.targetPath = positional[0] ?? ""; + rejectUnsafeToken(options.targetPath, "worktree path"); + if (options.overwriteLocal) options.copyLocal = true; + return options; +} + function scalarAfter(text: string, key: string): string | null { const match = text.match(new RegExp(`^\\s*${key}:\\s*"?([^"\\n#]+)"?\\s*(?:#.*)?$`, "mu")); return match?.[1]?.trim() ?? null; @@ -216,6 +301,174 @@ function validateDatabaseUrl(url: string): { ok: boolean; url: string; reason: s return { ok: true, url, reason: null }; } +function normalizedRelativePath(path: string): string { + return path.split(sep).join("/"); +} + +function resolveWorktreeTarget(targetPath: string): { absolutePath: string; displayPath: string } { + const absolutePath = isAbsolute(targetPath) ? resolve(targetPath) : resolve(repoRoot, targetPath); + const repoRelative = relative(repoRoot, absolutePath); + if (repoRelative.length === 0) throw new Error("worktree path must not be the repository root"); + if (repoRelative.startsWith("..") || isAbsolute(repoRelative)) throw new Error("worktree path must stay under the UniDesk repository root"); + const displayPath = normalizedRelativePath(repoRelative); + if (displayPath === ".git" || displayPath.startsWith(".git/")) throw new Error("worktree path must not be inside .git"); + if (displayPath.split("/").some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("worktree path must be normalized"); + return { absolutePath, displayPath }; +} + +function parseWorktreeCopyPatterns(text: string): WorktreeCopyPattern[] { + return text.split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const include = !line.startsWith("!"); + const pattern = include ? line : line.slice(1); + if (pattern.length === 0 || pattern.startsWith("/") || pattern.includes("\\") || pattern.split("/").some((part) => part === "..")) { + throw new Error(`${defaultWorktreeCopyFile} contains unsupported pattern: ${line}`); + } + return { raw: line, pattern, include }; + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&"); +} + +function globToRegExp(pattern: string): RegExp { + let source = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index] ?? ""; + const next = pattern[index + 1] ?? ""; + if (char === "*" && next === "*") { + source += ".*"; + index += 1; + } else if (char === "*") { + source += "[^/]*"; + } else if (char === "?") { + source += "[^/]"; + } else { + source += escapeRegExp(char); + } + } + return new RegExp(`${source}$`, "u"); +} + +function patternPrefix(pattern: string): string { + const wildcardIndex = pattern.search(/[*?]/u); + const prefix = wildcardIndex < 0 ? pattern : pattern.slice(0, wildcardIndex); + const slashIndex = prefix.lastIndexOf("/"); + return slashIndex < 0 ? "" : prefix.slice(0, slashIndex); +} + +function walkFiles(root: string, relativeRoot: string, out: string[]): void { + if (!existsSync(root)) return; + const stat = statSync(root); + if (stat.isFile()) { + out.push(relativeRoot); + return; + } + if (!stat.isDirectory()) return; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === "node_modules") continue; + const childRelative = relativeRoot.length === 0 ? entry.name : `${relativeRoot}/${entry.name}`; + const childPath = resolve(root, entry.name); + if (entry.isDirectory()) walkFiles(childPath, childRelative, out); + else if (entry.isFile()) out.push(childRelative); + } +} + +function candidateWorktreeCopyFiles(patterns: WorktreeCopyPattern[]): Set { + const candidates = new Set(); + for (const pattern of patterns.filter((entry) => entry.include)) { + if (!pattern.pattern.includes("*") && !pattern.pattern.includes("?")) { + candidates.add(pattern.pattern); + continue; + } + const prefix = patternPrefix(pattern.pattern); + const files: string[] = []; + walkFiles(resolve(repoRoot, prefix), prefix, files); + const matcher = globToRegExp(pattern.pattern); + for (const file of files) { + if (matcher.test(file)) candidates.add(file); + } + } + return candidates; +} + +function includedByPatterns(path: string, patterns: WorktreeCopyPattern[]): boolean { + let included = false; + for (const pattern of patterns) { + if (globToRegExp(pattern.pattern).test(path)) included = pattern.include; + } + return included; +} + +function copyRefusalReason(path: string, bytes: number): string | null { + const withSlash = path.endsWith("/") ? path : `${path}/`; + if (refusedWorktreeCopyPrefixes.some((prefix) => path === prefix.slice(0, -1) || withSlash.startsWith(prefix))) return "refused-runtime-or-build-path"; + if (path.toLowerCase().includes("dump")) return "refused-dump-path"; + if (bytes > maxWorktreeCopyBytes) return `refused-large-file>${maxWorktreeCopyBytes}`; + return null; +} + +function copyWorktreeLocalFiles(targetAbsolutePath: string, overwriteLocal: boolean, dryRun = false): { configPath: string; patterns: string[]; rows: WorktreeCopyRow[] } { + const configPath = resolve(repoRoot, defaultWorktreeCopyFile); + if (!existsSync(configPath)) { + return { + configPath: defaultWorktreeCopyFile, + patterns: [], + rows: [{ path: defaultWorktreeCopyFile, status: "skipped", reason: "copy-config-missing", bytes: null }], + }; + } + const patterns = parseWorktreeCopyPatterns(readFileSync(configPath, "utf8")); + const rows: WorktreeCopyRow[] = []; + for (const candidate of Array.from(candidateWorktreeCopyFiles(patterns)).sort()) { + if (!includedByPatterns(candidate, patterns)) continue; + const sourcePath = resolve(repoRoot, candidate); + const sourceRelative = normalizedRelativePath(relative(repoRoot, sourcePath)); + if (sourceRelative !== candidate || sourceRelative.startsWith("..")) { + rows.push({ path: candidate, status: "refused", reason: "refused-outside-repo", bytes: null }); + continue; + } + if (!existsSync(sourcePath)) { + rows.push({ path: candidate, status: "skipped", reason: "source-missing", bytes: null }); + continue; + } + const stat = statSync(sourcePath); + if (!stat.isFile()) { + rows.push({ path: candidate, status: "skipped", reason: "not-a-file", bytes: null }); + continue; + } + const refusalReason = copyRefusalReason(candidate, stat.size); + if (refusalReason !== null) { + rows.push({ path: candidate, status: "refused", reason: refusalReason, bytes: stat.size }); + continue; + } + const targetPath = resolve(targetAbsolutePath, candidate); + const targetRelative = normalizedRelativePath(relative(targetAbsolutePath, targetPath)); + if (targetRelative !== candidate || targetRelative.startsWith("..")) { + rows.push({ path: candidate, status: "refused", reason: "refused-target-outside-worktree", bytes: stat.size }); + continue; + } + if (existsSync(targetPath) && !overwriteLocal) { + rows.push({ path: candidate, status: "skipped", reason: "target-exists", bytes: stat.size }); + continue; + } + if (dryRun) { + rows.push({ path: candidate, status: "skipped", reason: existsSync(targetPath) ? "would-overwrite" : "would-copy", bytes: stat.size }); + } else { + mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 }); + copyFileSync(sourcePath, targetPath); + rows.push({ path: candidate, status: "copied", reason: overwriteLocal ? "overwritten" : "created", bytes: stat.size }); + } + } + return { + configPath: defaultWorktreeCopyFile, + patterns: patterns.map((pattern) => pattern.raw), + rows, + }; +} + function kubectlDryRun(manifestPath: string): unknown { const kubeconfig = defaultNativeKubeconfig; const guardScript = k3sGuardShellLines(kubeconfig).join("\n"); @@ -321,6 +574,90 @@ function prewarmImagesScript(options: PrewarmImagesOptions): string { ].join("\n"); } +function worktreeAddCommand(options: WorktreeAddOptions, targetAbsolutePath: string): string[] { + const command = ["git", "worktree", "add"]; + if (options.branch !== null) command.push("-b", options.branch); + command.push(targetAbsolutePath, options.fromRef); + return command; +} + +function runWorktreeAdd(options: WorktreeAddOptions): unknown { + const target = resolveWorktreeTarget(options.targetPath); + if (existsSync(target.absolutePath) && !options.dryRun) throw new Error(`worktree target already exists: ${target.displayPath}`); + const command = worktreeAddCommand(options, target.absolutePath); + const result = { + ok: true, + dryRun: options.dryRun, + path: target.displayPath, + branch: options.branch, + fromRef: options.fromRef, + command, + copyLocal: options.copyLocal + ? copyWorktreeLocalFiles(target.absolutePath, options.overwriteLocal, true) + : { configPath: defaultWorktreeCopyFile, patterns: [], rows: [{ path: defaultWorktreeCopyFile, status: "skipped" as const, reason: "copy-local-disabled", bytes: null }] }, + }; + if (!options.dryRun) { + const git = runCommand(command, repoRoot, { timeoutMs: 120_000 }); + if (git.exitCode !== 0) { + throw new Error(`git worktree add failed: ${JSON.stringify({ + command: git.command, + exitCode: git.exitCode, + signal: git.signal, + timedOut: git.timedOut, + stdoutTail: git.stdout.slice(-2000), + stderrTail: git.stderr.slice(-2000), + })}`); + } + if (options.copyLocal) result.copyLocal = copyWorktreeLocalFiles(target.absolutePath, options.overwriteLocal); + } + if (options.json) return result; + return { + ok: true, + command: "dev-env worktree add", + contentType: "text/plain", + renderedText: renderWorktreeAdd(result), + }; +} + +function renderWorktreeAdd(result: { + ok: boolean; + dryRun: boolean; + path: string; + branch: string | null; + fromRef: string; + command: string[]; + copyLocal: { configPath: string; patterns: string[]; rows: WorktreeCopyRow[] }; +}): string { + const rows = result.copyLocal.rows; + const counts = { + copied: rows.filter((row) => row.status === "copied").length, + skipped: rows.filter((row) => row.status === "skipped").length, + refused: rows.filter((row) => row.status === "refused").length, + }; + const pathWidth = Math.min(60, Math.max(4, ...rows.map((row) => row.path.length))); + const statusWidth = Math.max(6, ...rows.map((row) => row.status.length)); + const reasonWidth = Math.min(38, Math.max(6, ...rows.map((row) => row.reason.length))); + const truncate = (value: string, width: number): string => value.length <= width ? value : `${value.slice(0, Math.max(0, width - 1))}~`; + const lines = [ + `worktree ${result.dryRun ? "dry-run" : "created"}: ${result.path}`, + `from: ${result.fromRef}${result.branch === null ? "" : ` branch: ${result.branch}`}`, + `copy config: ${result.copyLocal.configPath} copied=${counts.copied} skipped=${counts.skipped} refused=${counts.refused}`, + "", + `${"STATUS".padEnd(statusWidth)} ${"PATH".padEnd(pathWidth)} ${"BYTES".padStart(8)} REASON`, + ]; + for (const row of rows) { + lines.push([ + row.status.toUpperCase().padEnd(statusWidth), + truncate(row.path, pathWidth).padEnd(pathWidth), + (row.bytes === null ? "-" : String(row.bytes)).padStart(8), + truncate(row.reason, reasonWidth), + ].join(" ")); + } + lines.push(""); + lines.push(`drill down: bun scripts/cli.ts dev-env worktree add ${result.path} --from ${result.fromRef}${result.branch === null ? "" : ` --branch ${result.branch}`} --json`); + return lines.join("\n"); +} + function devEnvHelp(): Record { return { ok: true, @@ -328,6 +665,7 @@ function devEnvHelp(): Record { usage: [ "bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]", "bun scripts/cli.ts dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]", + "bun scripts/cli.ts dev-env worktree add .worktree/ --branch [--from origin/master] [--copy-local] [--overwrite-local] [--json]", ], defaultManifest, defaultPrewarmImages, @@ -338,6 +676,7 @@ function devEnvHelp(): Record { "dev DATABASE_URL values must target postgres-dev/unidesk_dev and not production routes", "--kubectl-dry-run optionally asks kubectl to client-dry-run the manifest without applying it", "prewarm-images imports dev foundation images from Docker into native k3s containerd on D601", + "worktree add creates UniDesk task worktrees and copies local files allowed by .worktreecopy", ], }; } @@ -345,6 +684,12 @@ function devEnvHelp(): Record { export function runDevEnvCommand(args: string[]): unknown { const action = args[0]; if (action === undefined || isHelpArg(action)) return devEnvHelp(); + if (action === "worktree") { + const subaction = args[1]; + if (subaction === undefined || isHelpArg(subaction)) return devEnvHelp(); + if (subaction !== "add") throw new Error("dev-env worktree usage: bun scripts/cli.ts dev-env worktree add .worktree/ --branch [--from origin/master]"); + return runWorktreeAdd(parseWorktreeAddOptions(args.slice(2))); + } if (action === "prewarm-images") { const options = parsePrewarmImagesOptions(args.slice(1)); const script = prewarmImagesScript(options); @@ -373,7 +718,7 @@ export function runDevEnvCommand(args: string[]): unknown { statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, }; } - if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run] OR dev-env prewarm-images"); + if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run] OR dev-env prewarm-images OR dev-env worktree add"); const options = parseValidateOptions(args.slice(1)); const manifestPath = rootPath(options.manifestPath); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 160d74eb..35ca0350 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -61,7 +61,7 @@ export function rootHelp(): unknown { { command: "cicd status --node ", description: "Show one node's CI/CD closeout summary across current Pipelines-as-Code consumers, including PipelineRun, Argo and runtime readiness." }, { command: "cicd gitea-actions-poc plan|status", description: "Archived read-only CI/CD migration evidence for GH-1548/GH-1549; never use as a delivery or closeout path." }, { command: "cicd branch-follower status|events|logs", description: "Archived migration diagnostics only; PaC-migrated NC01 lane must use cicd status and platform-infra pipelines-as-code closeout/status/history." }, - { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, + { command: "dev-env validate|prewarm-images|worktree add", description: "Validate D601 guardrails, prewarm dev images, or create UniDesk task worktrees with .worktreecopy local config copying." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, issue/comment apply_patch body patching, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." }, @@ -580,13 +580,14 @@ function e2eHelp(): unknown { function devEnvHelp(): unknown { return { - command: "dev-env validate|prewarm-images", + command: "dev-env validate|prewarm-images|worktree add", output: "json", usage: [ "bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]", "bun scripts/cli.ts dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--dry-run]", + "bun scripts/cli.ts dev-env worktree add .worktree/ --branch [--from origin/master] [--copy-local] [--overwrite-local] [--json]", ], - description: "Validate D601 unidesk-dev guardrails or prewarm foundation images into native k3s containerd.", + description: "Validate D601 unidesk-dev guardrails, prewarm foundation images, or create task worktrees with allowlisted local config copying.", }; }