fix: control exact commits in dirty Windows worktrees
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
Reference in New Issue
Block a user