gitea-saas-core#48: pass git commands as arg lists, never shell strings (Windows caret fix) #49
No reviewers
Labels
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gitea-saas-core#49
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-48"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #48
Root cause
ReviewWorkspacebuilt every git invocation as a shell string and ranit through
subprocess.run(..., shell=True). On Windows, cmd.exe consumesthe caret in
<sha>^{commit}before git ever sees it, sogit rev-parse --verify <sha>^{commit}arrived as<sha>{commit}andfailed with "Needed a single revision".
ReviewWorkspacecould thereforenever construct a synthetic merge on the Windows review box — the core
mechanism of the isolated-workspace review gate (#30) was non-functional on
the platform it runs on. 9 workspace tests + the reviewer hygiene test
failed on Windows for that reason.
Fix
Pass git arguments as lists (
shell=False) so no shell ever interpretsthem; arguments containing
^,~,&,|,<,>,%, or spacesreach git verbatim on every platform.
Runner.runnow accepts an argument list (executed withshell=False) as the portable form, or a string for callers thatgenuinely need shell semantics (
python -m pytest, admin-configuredbuild_cmd). A stringified list ("['git', ...]") raises TypeErrorinstead of being silently re-shelled.
review_workspace.py: all git invocations now pass argument lists —the
^{commit}peel,worktree add/removewith raw tmp paths (the oldshlex.quoteshim is gone),merge --no-ff -m, exact-SHA fetch,MERGE_HEAD/diff --name-only/status --porcelain/rev-parse HEAD.reviewer.py: the hygiene gategit diff --check <base> HEADalsopasses an argument list.
Sibling call sites audited (acceptance 3)
reviewer.pytest run (python -m pytest -q) andbuild_cmd— theonly remaining string commands; deliberately shell-driven (documented in
Runner.run), no metacharacter payloads.gitea_saasconstructs git command lines (auditedevery
runner.runcall site).A...B-style range path: the hygiene gate uses an explicitsingle-SHA base +
HEAD(no...token today); it is now list-passedlike everything else, so a future three-dot range is equally safe.
the hermetic suite now exercises the exact production command shape;
ran locally on Linux (green) — the Windows review box runs the same
commands with no shell in between.
Tests
test_caret_peel_reaches_git_as_single_list_token:asserts the
^{commit}peel reaches the runner as one list elementand that every git invocation in the workspace is an argument list.
base/HEAD range still pinned).
236 passed, 1 skipped;git diff --checkclean;compileallclean.The review workspace built every git invocation as a shell string. On Windows, cmd.exe consumes the caret in `<sha>^{commit}` before git sees it, so ReviewWorkspace could not verify SHAs and could never build a synthetic merge on the review box (platform the product runs on). - Runner.run: accept an argument list (executed with shell=False) as the portable form; string commands (pytest, admin build_cmd) keep shell semantics. Reject stringified-list commands instead of silently re-shelling them. - review_workspace.py: ALL git invocations now pass argument lists, including the `^{commit}` peel, worktree add/remove with raw tmp paths (spaces/$ no longer need shlex quoting), merge -m, fetch by exact SHAs, MERGE_HEAD/diff/status/rev-parse checks. - reviewer.py: the hygiene `git diff --check <base> HEAD` gate passes an argument list too. Sibling call sites audited: reviewer.py's test run ("python -m pytest -q") and build_cmd are the only remaining string commands and are deliberately shell-driven; no other module in gitea_saas constructs git command lines. Tests: new test_caret_peel_reaches_git_as_single_list_token asserts the peel reaches git as ONE list token; cwd/command-record assertions updated to list form. Full suite: 236 passed, 1 skipped; git diff --check clean.[worker] gitea-saas-core#48 implemented on branch
issue-48— PR #49 up for manual review.Commit:
3d3e592(fix(#48): pass git commands as arg lists, never shell strings)4 files changed: gitea_saas/review_workspace.py (+29/-20), gitea_saas/reviewer.py (+24/-3), tests/test_review_workspace.py (+65/-10), tests/test_reviewer.py (+4/-3).
What changed
^{commit}peel, worktree add/remove with raw paths (shlex.quote shim removed), merge -m, exact-SHA fetch, MERGE_HEAD/status checks.git diff --check <base> HEADis now a list too.Sibling call-site audit (acceptance 3)
python -m pytest -q(tests) andbuild_cmd(admin config) — deliberately shell-driven, no metacharacter payloads. No other module in gitea_saas builds git command lines.Test evidence
test_caret_peel_reaches_git_as_single_list_tokenasserts the^{commit}suffix reaches the runner as ONE list token (acceptance 2).236 passed, 1 skipped(was 235 passed + 1 new),git diff --checkclean,compileallclean.^; the same commands now run identically on POSIX and Windows (fix matches the issue's demonstratedcmd /cfailure).Bounced — 9 of 10 fixed. One left, and I traced it for you.
Real progress, measured against the merge result:
origin/main@71cc263main+ this PRAll nine
test_review_workspace.pyfailures are gone — the arg-list conversionworks and the caret bug is dead. The one survivor is the test I flagged in #48
as probably sharing the cause:
Where it comes from
It is not a shell bug and it is not a regression from this PR — it was one of
the original 10. The chain:
review_workspace.py:88—self.dir = Path(dir)Pathto the runnertests/test_reviewer.py:98—FakeRunner.runrecordsstr(repo_dir)str(Path("/fake/ws"))isake\ws"/fake/ws"The fix is in the test, not the source
Passing a
Pathis correct, andake\wsis a valid Windows spelling of thatdirectory — as a real subprocess
cwdit works. Do not "fix" this by makingthe production code stop using
Pathor by forcing forward slashes; thatwould trade a correct implementation for a passing string comparison.
Compare the paths as paths:
grep -c "fake/ws" tests/test_reviewer.pyreports 2 — check the other onehas the same exposure while you are in there.
One thing to confirm in your report
Is there anywhere the workspace directory is string-concatenated into a
command rather than passed as
cwd? That is the case where the separatorgenuinely matters, and it is the reason I am asking rather than just accepting
a green suite.
Once this is green,
mainis green and four PRs (!43 !44 !46 !47) unblockbehind it — all four are already verified as additive with zero new failures.
Review 11154: the test asserted the workspace directory as the literal string "/fake/ws", but on Windows str(Path('/fake/ws')) is '\\fake\\ws' — a valid spelling of the same directory when passed as a subprocess cwd. Compare the recorded cwd as paths (Path(ws_dir) == Path('/fake/ws')) so the assertion holds on every platform. Test-only change: passing a Path as cwd is correct and remains untouched; no production code change. Confirmed for the review's open question: the workspace directory is never string-concatenated into a command — reviewer.py passes ws.dir only as the cwd argument to Runner.run (for pytest, build_cmd, and the ["git","diff","--check", base, "HEAD"] hygiene list). The only string containing self.dir is the MergeWorkspace __repr__ debug aid. Tests: full suite 236 passed, 1 skipped; git diff --check clean.[worker-bounce-fix attempt 1]
Same head branch
issue-48(3d3e592->6326199), basemainunchanged (mergeable=true, no rebase needed). One commit, one file.Finding — tests/test_reviewer.py::test_hygiene_diff_check_uses_explicit_base_head_range (review 11154)
Resolved exactly as prescribed: compare the recorded cwd as paths instead of strings. The reviewer's chain was confirmed:
review_workspace.pystoresself.dir = Path(dir); the reviewer hands that Path to the runner;FakeRunner.runrecordsstr(repo_dir); on Windowsstr(Path("/fake/ws"))is'\\fake\\ws'— a valid spelling of the same directory — so the literal string assertion failed on the Windows review box. The test now assertsPath(ws_dir) == Path("/fake/ws"). This is a test-only change: production still passesws.diras thecwdargument (never callsos.path, never rewrites separators), andPathwas already imported in the test module.grep -c "fake/ws" tests/test_reviewer.py= 2: the other occurrence (line 65, theFakeWorkspace(directory="/fake/ws")default) is the source value, not an assertion — it has no comparison exposure; the Path-equality assertion covers the assert site.Reviewer's open question — is the workspace directory ever string-concatenated into a command?
No. Audited every call site:
reviewer.pypassesws.dironly as thecwdargument toRunner.run— for the pytest string, the optionalbuild_cmdstring, and the hygiene["git","diff","--check", ws.base_sha, "HEAD"]argument list (which also keeps sha/HEAD as untrusted data reaching git verbatim, shell=False).Runner.runitself doescwd=str(repo_dir)insidesubprocess.run; the directory never appears inside a command's argument string, so the separator spelling genuinely cannot matter anywhere. The only string containingself.diris theMergeWorkspace.__repr__debug aid (not a command).Also confirmed against the merge result / baseline:
origin/main@71cc263+ this head:python -m pytest -q tests-> 236 passed, 1 skipped (0 failed). The 10 red tests from issue #48 (#48's root-cause site, the 9 review_workspace failures, and this literal-string assertion) are all green.Evidence:
test_caret_peel_reaches_git_as_single_list_tokenfrom the original fix, still passing)git diff --check: cleanpython -m py_compile gitea_saas/review_workspace.py gitea_saas/reviewer.py tests/test_reviewer.py tests/test_review_workspace.py: cleanPR re-fetched post-push: state=open, merged=false, head=
issue-48@6326199(expected head), basemain, mergeable=true, non-empty diff (4 files). This was NOT a rebase — an additive commit on the same branch (no force, no branch delete/recreate, no replacement PR). The linked issue stays in-review.Merged —
mainis green againorigin/mainbeforeThe bounce finding is fixed. The last survivor was the path-separator assertion (
'\fake\ws' == '/fake/ws'), and it was resolved the right way — comparing paths as paths rather than strippingPath()from the production code to make a string comparison pass.This clears the blocker on !43, !44, !46 and !47, all of which were held (not bounced) behind a red baseline. Re-gating those now.