diff --git a/.agents/skills/unidesk-trans/SKILL.md b/.agents/skills/unidesk-trans/SKILL.md index eaa4f83f..f1539367 100644 --- a/.agents/skills/unidesk-trans/SKILL.md +++ b/.agents/skills/unidesk-trans/SKILL.md @@ -20,6 +20,10 @@ trans D601:win/c/test cat README.md trans D601:win/c/test rg -i needle . trans D601:win/c/test git diff --check trans D601:win/c/test git commit -m 'fix: update docs' +trans D601:win/c/test git exact-commit status +trans D601:win/c/test git exact-commit plan --path src/main.c +trans D601:win/c/test git exact-commit run --path src/main.c --message 'fix: exact change' --confirm +trans D601:win/c/test git exact-commit gc-auto --confirm trans gh:/owner/repo/issue/ cat ``` @@ -57,6 +61,12 @@ Host workspace、k3s、Windows、GitHub issue/PR route 见 [references/routes.md - Windows workspace 中依赖 Windows PATH、解释器或工具链的命令优先走 `:win// cmd|ps`;不得因同一 WSL provider 的 host/WSL plane 缺少 `python`、编译器或其他命令,就判断 Windows plane 也未安装。Windows Python 例如 `trans G14-WSL:win/d/Work/CONSTAR_workspace cmd python --version`。 - `/mnt/` workspace 的命令返回 `command not found` 时,CLI 会输出对应 `:win//... cmd` 的 `UNIDESK_SSH_HINT`;该提示只建议核对 Windows plane,不改变原命令退出码。 - Windows `git` helper 拒绝包含 `%` format 等需 shell review 的复杂参数时,按错误提示改用 `trans :win/... ps ''`,不要反复改写 direct `git` argv 绕过校验。 +- Windows 固定脏 worktree 的精确提交规则: + - 使用 `git exact-commit plan` 明确列出一个或多个 `--path`; + - `run` 必须提供 `--message` 和 `--confirm`; + - helper 使用临时 index 和 HEAD old-value CAS,只更新选中路径的 index,并验证未选中路径的 staged/unstaged 状态不变; + - `status` 只读披露 `git count-objects -v`; + - `gc-auto` 只调用 Git 自身的 `git gc --auto`,保留 reflog 和 recent-object 规则,禁止直接 `git prune`。 - 从本地 Bash 调用一行 Windows `ps` 时,PowerShell 表达式外层使用单引号,保护 `$`、`$_` 和 `$_.Property` 不被本地 shell 提前展开;复杂或多行 PowerShell 改用 `ps <<'PS'` heredoc。 - 单行 `ps` 源码若因本地 shell 展开而以缺失左值的 `=` 开始,CLI 在远端执行前返回 `ssh-windows-powershell-local-expansion`,并给出 `$p`、`$_` 的单引号示例和 quoted `PS` heredoc。 - 扩展 Windows helper 时保持 operation-scoped PowerShell payload;不要把多操作大脚本塞进 single `EncodedCommand`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0a60dfb9..32ae3309 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -502,6 +502,13 @@ PATCH 通过 `trans ` 执行多行脚本时,优先使用结构化 helper,例如 `trans G14 py < script.py`、`trans G14 sh <<'SH'` 或 `trans G14:k3s sh <<'SH'`。不要在远端命令字符串里再嵌套 heredoc、复杂引号或 `ssh 'python3 - <:win/... git exact-commit status` 只读披露对象数量与安全停止边界; + - `plan --path ` 使用临时 index 生成待提交 tree,不更新 ref; + - `run --path --message --confirm` 使用 HEAD old-value CAS 更新当前分支,只刷新选中路径的 index,并验证未选中路径的 staged/unstaged 状态不变; + - 多文件必须重复传入 `--path`,禁止把当前 index 当作隐式提交范围; + - `gc-auto --confirm` 只运行 `git gc --auto`,不得直接 prune active、recent 或受 reflog 保护的 unreachable objects。 + ## Remote Main Server Passthrough `--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b5e9cd01..b14dda08 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -202,6 +202,9 @@ export function sshHelp(): unknown { "trans D601:win/c/test git status --short --branch", "trans D601:win/c/test git diff --check", "trans D601:win/c/test git commit -m 'fix: update docs'", + "trans D601:win/c/test git exact-commit status", + "trans D601:win/c/test git exact-commit plan --path src/main.c", + "trans D601:win/c/test git exact-commit run --path src/main.c --message 'fix: exact change' --confirm", "trans D601:win skills [--scope agents|codex|all] [--limit N]", "trans NC01:k3s", "trans NC01:k3s kubectl get pods -n hwlab-dev", diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index e955bd0d..0f26b875 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -82,6 +82,22 @@ describe("ssh windows fs read-only operations", () => { expect(commitInvocation.parsed.requiresStdin).toBe(false); }); + test("builds controlled exact-commit and object status helpers", () => { + const status = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "status"]); + const plan = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "src/main.c"]); + const run = parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact", "--confirm"]); + const decoded = decodedWindowsPowerShellCommand(run.parsed.remoteCommand); + + expect(status.parsed.invocationKind).toBe("helper"); + expect(plan.parsed.invocationKind).toBe("helper"); + expect(decoded).toContain("GIT_INDEX_FILE"); + expect(decoded).toContain("update-ref"); + expect(decoded).toContain("unselected worktree/index fingerprint"); + expect(decoded).toContain("old-value CAS"); + expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact"])).toThrow("requires --confirm"); + expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "../secret"])).toThrow("must stay inside"); + }); + test("builds bounded UTF-8 scripts for cat, ls, and rg", () => { const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]); const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index cff9a9bd..27eaa0b7 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -596,6 +596,13 @@ function windowsUnsupportedOperationMessage(route: ParsedSshRoute, operation: st function parseWindowsGitInvocation(route: ParsedSshRoute, gitArgs: string[]): ParsedSshArgs { const subcommand = gitArgs[0] ?? ""; if (subcommand.length === 0) throw new Error(`ssh ${route.raw} git requires a git subcommand, for example: ${sshDisplayEntrypoint()} ${route.raw} git status --short --branch`); + if (subcommand === "exact-commit") { + return { + remoteCommand: buildWindowsPowerShellInvocation(windowsGitExactCommitScript(route, gitArgs.slice(1))), + requiresStdin: false, + invocationKind: "helper", + }; + } if (subcommand === "commit") validateWindowsGitCommitArgs(route, gitArgs); const commandLine = ["git", ...gitArgs].map((value) => windowsCmdArgument(value, route)).join(" "); return { @@ -605,6 +612,106 @@ function parseWindowsGitInvocation(route: ParsedSshRoute, gitArgs: string[]): Pa }; } +function windowsGitExactCommitScript(route: ParsedSshRoute, args: string[]): string { + const operation = args[0] ?? ""; + if (!new Set(["status", "plan", "run", "gc-auto"]).has(operation)) { + throw new Error("ssh win git exact-commit requires status, plan, run, or gc-auto"); + } + const paths: string[] = []; + let message = ""; + let confirm = false; + for (let index = 1; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "--path") { + const value = args[index + 1]; + if (!value) throw new Error("git exact-commit --path requires a repository-relative path"); + if (/^(?:[A-Za-z]:|[\\/])|(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value)) throw new Error("git exact-commit --path must stay inside the repository"); + paths.push(value.replace(/\\/gu, "/")); + index += 1; + continue; + } + if (arg === "--message") { + const value = args[index + 1]; + if (!value) throw new Error("git exact-commit --message requires a value"); + message = value; + index += 1; + continue; + } + if (arg === "--confirm") { + confirm = true; + continue; + } + throw new Error(`unsupported git exact-commit option: ${arg}`); + } + if ((operation === "plan" || operation === "run") && paths.length === 0) throw new Error(`git exact-commit ${operation} requires at least one --path`); + if (operation === "run" && message.length === 0) throw new Error("git exact-commit run requires --message"); + if ((operation === "run" || operation === "gc-auto") && !confirm) throw new Error(`git exact-commit ${operation} requires --confirm`); + const cwd = powerShellSingleQuote(route.workspace ?? "."); + const pathList = paths.map((value) => powerShellSingleQuote(value)).join(","); + return `$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +[Console]::InputEncoding=[System.Text.UTF8Encoding]::new() +[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new() +$OutputEncoding=[System.Text.UTF8Encoding]::new() +Set-Location -LiteralPath ${cwd} +$operation=${powerShellSingleQuote(operation)} +$paths=@(${pathList}) +$message=${powerShellSingleQuote(message)} +function Invoke-Git([string[]]$GitArgs) { + $output = @(& git @GitArgs 2>&1) + if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) } + return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim() +} +function Object-Status { + $values=@{} + (Invoke-Git @('count-objects','-v')).Split([Environment]::NewLine) | ForEach-Object { $pair=$_.Split(':',2); if($pair.Count -eq 2){$values[$pair[0].Trim()]=[int64]$pair[1].Trim()} } + return $values +} +$before=Object-Status +$head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}') +$branch=Invoke-Git @('symbolic-ref','-q','HEAD') +if($operation -eq 'status') { + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress + exit 0 +} +if($operation -eq 'gc-auto') { + $gcOutput=Invoke-Git @('gc','--auto') + $after=Object-Status + [ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress + exit 0 +} +$gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir') +$indexPath=Invoke-Git @('rev-parse','--path-format=absolute','--git-path','index') +$unrelatedPathspec=@('.')+@($paths | ForEach-Object { ':(exclude)'+$_ }) +$unrelatedBefore=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec) +$tempIndex=Join-Path $gitDir ('unidesk-exact-index-'+[Guid]::NewGuid().ToString('N')) +try { + $env:GIT_INDEX_FILE=$tempIndex + [void](Invoke-Git @('read-tree',$head)) + [void](Invoke-Git (@('add','--')+$paths)) + $tree=Invoke-Git @('write-tree') + $parentTree=Invoke-Git @('rev-parse',($head+'^{tree}')) + if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'} + if($operation -eq 'plan') { + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress + exit 0 + } + $commit=($message | & git commit-tree $tree -p $head).Trim() + if($LASTEXITCODE -ne 0 -or !$commit){throw 'git commit-tree failed'} + [void](Invoke-Git @('update-ref','-m',('unidesk exact-commit: '+$message),$branch,$commit,$head)) + Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue + [void](Invoke-Git (@('reset',$commit,'--')+$paths)) + $unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec) + if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'} + $after=Object-Status + [ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress +} finally { + Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue +}`; +} + function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void { const hasNonInteractiveMessage = gitArgs.slice(1).some((arg) => ( arg === "--dry-run" ||