gitea-saas-core#48: pass git commands as arg lists, never shell strings (Windows caret fix) #49

Merged
pbwraith merged 2 commits from issue-48 into main 2026-09-03 10:50:43 +02:00
Owner

Closes #48

Root cause

ReviewWorkspace built every git invocation as a shell string and ran
it through subprocess.run(..., shell=True). On Windows, cmd.exe consumes
the caret in <sha>^{commit} before git ever sees it, so
git rev-parse --verify <sha>^{commit} arrived as <sha>{commit} and
failed with "Needed a single revision". ReviewWorkspace could therefore
never 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 interprets
them; arguments containing ^, ~, &, |, <, >, %, or spaces
reach git verbatim on every platform.

  1. Runner.run now accepts an argument list (executed with
    shell=False) as the portable form, or a string for callers that
    genuinely need shell semantics (python -m pytest, admin-configured
    build_cmd). A stringified list ("['git', ...]") raises TypeError
    instead of being silently re-shelled.
  2. review_workspace.py: all git invocations now pass argument lists —
    the ^{commit} peel, worktree add/remove with raw tmp paths (the old
    shlex.quote shim is gone), merge --no-ff -m, exact-SHA fetch,
    MERGE_HEAD/diff --name-only/status --porcelain/rev-parse HEAD.
  3. reviewer.py: the hygiene gate git diff --check <base> HEAD also
    passes an argument list.

Sibling call sites audited (acceptance 3)

  • reviewer.py test run (python -m pytest -q) and build_cmd — the
    only remaining string commands; deliberately shell-driven (documented in
    Runner.run), no metacharacter payloads.
  • No other module in gitea_saas constructs git command lines (audited
    every runner.run call site).
  • The A...B-style range path: the hygiene gate uses an explicit
    single-SHA base + HEAD (no ... token today); it is now list-passed
    like everything else, so a future three-dot range is equally safe.
  • Windows strategy: since the list form is identical on POSIX and Windows,
    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

  • New regression test test_caret_peel_reaches_git_as_single_list_token:
    asserts the ^{commit} peel reaches the runner as one list element
    and that every git invocation in the workspace is an argument list.
  • Command-record assertions updated to the list form (cwd + explicit
    base/HEAD range still pinned).
  • Full suite: 236 passed, 1 skipped; git diff --check clean;
    compileall clean.
Closes #48 ## Root cause `ReviewWorkspace` built every git invocation as a **shell string** and ran it through `subprocess.run(..., shell=True)`. On Windows, cmd.exe consumes the caret in `<sha>^{commit}` before git ever sees it, so `git rev-parse --verify <sha>^{commit}` arrived as `<sha>{commit}` and failed with "Needed a single revision". `ReviewWorkspace` could therefore never 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 interprets them; arguments containing `^`, `~`, `&`, `|`, `<`, `>`, `%`, or spaces reach git verbatim on every platform. 1. `Runner.run` now accepts an **argument list** (executed with `shell=False`) as the portable form, or a **string** for callers that genuinely need shell semantics (`python -m pytest`, admin-configured `build_cmd`). A *stringified* list (`"['git', ...]"`) raises TypeError instead of being silently re-shelled. 2. `review_workspace.py`: **all** git invocations now pass argument lists — the `^{commit}` peel, `worktree add/remove` with raw tmp paths (the old `shlex.quote` shim is gone), `merge --no-ff -m`, exact-SHA fetch, `MERGE_HEAD`/`diff --name-only`/`status --porcelain`/`rev-parse HEAD`. 3. `reviewer.py`: the hygiene gate `git diff --check <base> HEAD` also passes an argument list. ## Sibling call sites audited (acceptance 3) - `reviewer.py` test run (`python -m pytest -q`) and `build_cmd` — the only remaining string commands; deliberately shell-driven (documented in `Runner.run`), no metacharacter payloads. - No other module in `gitea_saas` constructs git command lines (audited every `runner.run` call site). - The `A...B`-style range path: the hygiene gate uses an explicit single-SHA base + `HEAD` (no `...` token today); it is now list-passed like everything else, so a future three-dot range is equally safe. - Windows strategy: since the list form is identical on POSIX and Windows, 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 - New regression test `test_caret_peel_reaches_git_as_single_list_token`: asserts the `^{commit}` peel reaches the runner as **one** list element and that every git invocation in the workspace is an argument list. - Command-record assertions updated to the list form (cwd + explicit base/HEAD range still pinned). - Full suite: `236 passed, 1 skipped`; `git diff --check` clean; `compileall` clean.
pbwraith added 1 commit 2026-09-01 23:14:30 +02:00
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.
Author
Owner

[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

  • Runner.run accepts an argument list (shell=False) as the portable form; strings keep shell semantics for deliberately shell-driven commands (pytest, admin build_cmd). Stringified-list commands raise TypeError instead of being re-shelled.
  • Every git invocation in review_workspace.py is now an argument list — including the ^{commit} peel, worktree add/remove with raw paths (shlex.quote shim removed), merge -m, exact-SHA fetch, MERGE_HEAD/status checks.
  • reviewer.py hygiene gate git diff --check <base> HEAD is now a list too.

Sibling call-site audit (acceptance 3)

  • Only remaining string commands: python -m pytest -q (tests) and build_cmd (admin config) — deliberately shell-driven, no metacharacter payloads. No other module in gitea_saas builds git command lines.

Test evidence

  • New regression test test_caret_peel_reaches_git_as_single_list_token asserts the ^{commit} suffix reaches the runner as ONE list token (acceptance 2).
  • Full suite locally: 236 passed, 1 skipped (was 235 passed + 1 new), git diff --check clean, compileall clean.
  • PR verified post-push: state=open, merged=false, head=issue-48, base=main, mergeable=true, 4 changed files.
  • Windows: the list form removes the shell from the command path entirely, so cmd.exe can no longer eat ^; the same commands now run identically on POSIX and Windows (fix matches the issue's demonstrated cmd /c failure).
[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 - Runner.run accepts an argument list (shell=False) as the portable form; strings keep shell semantics for deliberately shell-driven commands (pytest, admin build_cmd). Stringified-list commands raise TypeError instead of being re-shelled. - Every git invocation in review_workspace.py is now an argument list — including the `^{commit}` peel, worktree add/remove with raw paths (shlex.quote shim removed), merge -m, exact-SHA fetch, MERGE_HEAD/status checks. - reviewer.py hygiene gate `git diff --check <base> HEAD` is now a list too. Sibling call-site audit (acceptance 3) - Only remaining string commands: `python -m pytest -q` (tests) and `build_cmd` (admin config) — deliberately shell-driven, no metacharacter payloads. No other module in gitea_saas builds git command lines. Test evidence - New regression test `test_caret_peel_reaches_git_as_single_list_token` asserts the `^{commit}` suffix reaches the runner as ONE list token (acceptance 2). - Full suite locally: `236 passed, 1 skipped` (was 235 passed + 1 new), `git diff --check` clean, `compileall` clean. - PR verified post-push: state=open, merged=false, head=issue-48, base=main, mergeable=true, 4 changed files. - Windows: the list form removes the shell from the command path entirely, so cmd.exe can no longer eat `^`; the same commands now run identically on POSIX and Windows (fix matches the issue's demonstrated `cmd /c` failure).
Author
Owner

Bounced — 9 of 10 fixed. One left, and I traced it for you.

Real progress, measured against the merge result:

tree pytest
origin/main @ 71cc263 10 failed, 225 passed
main + this PR 1 failed, 235 passed

All nine test_review_workspace.py failures are gone — the arg-list conversion
works and the caret bug is dead. The one survivor is the test I flagged in #48
as probably sharing the cause:

FAILED tests/test_reviewer.py::test_hygiene_diff_check_uses_explicit_base_head_range
AssertionError: assert '\fake\ws' == '/fake/ws'

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:

  1. review_workspace.py:88 — self.dir = Path(dir)
  2. the reviewer hands that Path to the runner
  3. tests/test_reviewer.py:98 — FakeRunner.run records str(repo_dir)
  4. on Windows str(Path("/fake/ws")) is ake\ws
  5. line 348 asserts the literal "/fake/ws"

The fix is in the test, not the source

Passing a Path is correct, and ake\ws is a valid Windows spelling of that
directory — as a real subprocess cwd it works. Do not "fix" this by making
the production code stop using Path or by forcing forward slashes
; that
would trade a correct implementation for a passing string comparison.

Compare the paths as paths:

assert Path(ws_dir) == Path("/fake/ws")

grep -c "fake/ws" tests/test_reviewer.py reports 2 — check the other one
has 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 separator
genuinely matters, and it is the reason I am asking rather than just accepting
a green suite.

Once this is green, main is green and four PRs (!43 !44 !46 !47) unblock
behind it — all four are already verified as additive with zero new failures.

## Bounced — 9 of 10 fixed. One left, and I traced it for you. Real progress, measured against the merge result: | tree | pytest | |---|---| | `origin/main` @ `71cc263` | **10 failed**, 225 passed | | `main` + this PR | **1 failed**, 235 passed | All nine `test_review_workspace.py` failures are gone — the arg-list conversion works and the caret bug is dead. The one survivor is the test I flagged in #48 as probably sharing the cause: ``` FAILED tests/test_reviewer.py::test_hygiene_diff_check_uses_explicit_base_head_range AssertionError: assert '\fake\ws' == '/fake/ws' ``` ## 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: 1. `review_workspace.py:88` — `self.dir = Path(dir)` 2. the reviewer hands that `Path` to the runner 3. `tests/test_reviewer.py:98` — `FakeRunner.run` records `str(repo_dir)` 4. on Windows `str(Path("/fake/ws"))` is ` ake\ws` 5. line 348 asserts the literal `"/fake/ws"` ## The fix is in the test, not the source Passing a `Path` is correct, and ` ake\ws` is a valid Windows spelling of that directory — as a real subprocess `cwd` it works. **Do not "fix" this by making the production code stop using `Path` or by forcing forward slashes**; that would trade a correct implementation for a passing string comparison. Compare the paths as paths: ```python assert Path(ws_dir) == Path("/fake/ws") ``` `grep -c "fake/ws" tests/test_reviewer.py` reports **2** — check the other one has 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 separator genuinely matters, and it is the reason I am asking rather than just accepting a green suite. Once this is green, `main` is green and four PRs (!43 !44 !46 !47) unblock behind it — all four are already verified as additive with zero new failures.
pbwraith added the
bounced
label 2026-09-02 00:22:55 +02:00
pbwraith added 1 commit 2026-09-02 00:51:03 +02:00
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.
pbwraith removed the
bounced
label 2026-09-02 00:51:36 +02:00
Author
Owner

[worker-bounce-fix attempt 1]

Same head branch issue-48 (3d3e592 -> 6326199), base main unchanged (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.py stores self.dir = Path(dir); the reviewer hands that Path to the runner; FakeRunner.run records str(repo_dir); on Windows str(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 asserts Path(ws_dir) == Path("/fake/ws"). This is a test-only change: production still passes ws.dir as the cwd argument (never calls os.path, never rewrites separators), and Path was already imported in the test module. grep -c "fake/ws" tests/test_reviewer.py = 2: the other occurrence (line 65, the FakeWorkspace(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.py passes ws.dir only as the cwd argument to Runner.run — for the pytest string, the optional build_cmd string, 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.run itself does cwd=str(repo_dir) inside subprocess.run; the directory never appears inside a command's argument string, so the separator spelling genuinely cannot matter anywhere. The only string containing self.dir is the MergeWorkspace.__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:

  • full suite: 236 passed, 1 skipped (incl. new test_caret_peel_reaches_git_as_single_list_token from the original fix, still passing)
  • git diff --check: clean
  • python -m py_compile gitea_saas/review_workspace.py gitea_saas/reviewer.py tests/test_reviewer.py tests/test_review_workspace.py: clean

PR re-fetched post-push: state=open, merged=false, head=issue-48 @ 6326199 (expected head), base main, 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.

[worker-bounce-fix attempt 1] Same head branch `issue-48` (3d3e592 -> 6326199), base `main` unchanged (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.py` stores `self.dir = Path(dir)`; the reviewer hands that Path to the runner; `FakeRunner.run` records `str(repo_dir)`; on Windows `str(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 asserts `Path(ws_dir) == Path("/fake/ws")`. This is a **test-only change**: production still passes `ws.dir` as the `cwd` argument (never calls `os.path`, never rewrites separators), and `Path` was already imported in the test module. `grep -c "fake/ws" tests/test_reviewer.py` = 2: the other occurrence (line 65, the `FakeWorkspace(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.py` passes `ws.dir` **only** as the `cwd` argument to `Runner.run` — for the pytest string, the optional `build_cmd` string, 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.run` itself does `cwd=str(repo_dir)` inside `subprocess.run`; the directory never appears inside a command's argument string, so the separator spelling genuinely cannot matter anywhere. The only string containing `self.dir` is the `MergeWorkspace.__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: - full suite: 236 passed, 1 skipped (incl. new `test_caret_peel_reaches_git_as_single_list_token` from the original fix, still passing) - `git diff --check`: clean - `python -m py_compile gitea_saas/review_workspace.py gitea_saas/reviewer.py tests/test_reviewer.py tests/test_review_workspace.py`: clean PR re-fetched post-push: state=open, merged=false, head=`issue-48` @ 6326199 (expected head), base `main`, 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.
Author
Owner

Merged — main is green again

tree pytest
origin/main before 10 failed, 225 passed
after the first attempt 1 failed, 235 passed
this revision 236 passed, 1 skipped, 0 failed

The 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 stripping Path() 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.

## Merged — `main` is green again | tree | pytest | |---|---| | `origin/main` before | **10 failed**, 225 passed | | after the first attempt | 1 failed, 235 passed | | **this revision** | **236 passed, 1 skipped, 0 failed** | The 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 stripping `Path()` 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.
pbwraith merged commit 729536d3eb into main 2026-09-03 10:50:43 +02:00
Sign in to join this conversation.
No reviewers
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: pbwraith/gitea-saas-core#49
No description provided.