From 56edc62edb1455cdb9e7f86e0406f7e6ce287d19 Mon Sep 17 00:00:00 2001 From: Aleksandar Trifunovich Date: Tue, 14 Jul 2026 05:14:13 +0000 Subject: [PATCH 1/2] fix: hide file dialog by default to prevent blank space on diff page The element injected into each diff file section had height: 92vh but no explicit display: none for browsers without full support. In those browsers the hidden dialog rendered as a 92vh-tall block, creating a massive empty black region below the diff content. Add display: none to .file-dialog and an explicit dialog[open] override to restore visibility when showModal() is called. --- difflab/templates/base.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/difflab/templates/base.html b/difflab/templates/base.html index adcdc4a..a5bb4b7 100644 --- a/difflab/templates/base.html +++ b/difflab/templates/base.html @@ -273,6 +273,7 @@ .status-row:focus-visible .copy-path-button, .copy-path-button.is-copied { opacity: 1; } .file-dialog { + display: none; width: min(96vw, 1100px); height: 92vh; max-width: none; @@ -283,6 +284,7 @@ color: var(--fg); padding: 0; } + dialog[open].file-dialog { display: block; } .file-dialog::backdrop { background: rgba(0, 0, 0, .55); } .dialog-bar { display: flex; From c11c41f7610e2b0b9df3cfe3ae5ef206944ad139 Mon Sep 17 00:00:00 2001 From: Aleksandar Trifunovich Date: Tue, 14 Jul 2026 05:28:06 +0000 Subject: [PATCH 2/2] fix: allow optional HEAD ref through the git-gate The diff view switched to `git diff HEAD` / `git diff --numstat HEAD` so staged new and deleted files show up, but the forced-command SSH gate only permitted the ref-less diff forms. On gated (remote) machines every diff request was refused with "difflab: command not permitted", which the app surfaces as "Remote gate refused command". Status and batch-status were unaffected, so only the diff view broke. Accept an optional trailing HEAD on both diff shapes in git-gate.sh and git-gate.ps1 and pass it through to git unchanged. The ref-less forms still work for older app builds, and any other ref is still rejected. Add gate tests covering diff, diff HEAD, numstat HEAD, the legacy ref-less form, arbitrary-ref rejection, and end-to-end pass-through proving a staged file appears under `diff HEAD` but not plain `diff`. --- CHANGELOG.md | 11 +++++++ gate/git-gate.ps1 | 29 +++++++++++------ gate/git-gate.sh | 33 ++++++++++++++------ tests/test_gate.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eec7fc..c83ca4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Git-gate rejected diff after the `diff HEAD` change** — the app started sending + `git … diff HEAD` / `diff --numstat HEAD` (to include staged files) but the + forced-command gate (`gate/git-gate.sh`, `gate/git-gate.ps1`) only permitted the + ref-less forms, so gated (remote/homelab) machines surfaced "Remote gate refused + command" on the diff view. The gate now accepts an optional trailing `HEAD` and + passes it through to git; the ref-less forms still work for older app builds, and + arbitrary refs remain rejected. **Deploying this requires reinstalling the gate + script on each enrolled machine.** + ## [0.2.0] - 2026-07-12 ### Added diff --git a/gate/git-gate.ps1 b/gate/git-gate.ps1 index e599a14..a794e52 100644 --- a/gate/git-gate.ps1 +++ b/gate/git-gate.ps1 @@ -1,9 +1,10 @@ # difflab git-gate — forced-command filter for the container SSH key (Windows/PowerShell). # -# Allows ONLY these four command shapes: +# Allows ONLY these command shapes (the trailing HEAD ref is optional so the +# gate accepts app versions from before and after the diff-HEAD change): # difflab-batch-statuspath1path2… (batch status; =0x1F) -# git -C "path" --no-pager diff -# git -C "path" --no-pager diff --numstat +# git -C "path" --no-pager diff [HEAD] +# git -C "path" --no-pager diff --numstat [HEAD] # git -C "path" status --short # # Any other command is rejected with "difflab: command not permitted" (on stderr, exit 1). @@ -55,18 +56,22 @@ foreach ($c in [char[]]"``;&|<>") { if ($original.IndexOf($c) -ge 0) { Reject 'metachar' } } -# Match the three allowed forms. +# Match the allowed forms. # The app always double-quotes paths and normalises backslashes to forward slashes. # Order: numstat before plain diff so the longer pattern is tried first. -if ($original -match '^git\s+-C\s+"([^"]*)"\s+--no-pager\s+diff\s+--numstat\s*$') { +# The trailing HEAD ref is optional and, when present, is passed through to git. +if ($original -match '^git\s+-C\s+"([^"]*)"\s+--no-pager\s+diff\s+--numstat(\s+HEAD)?\s*$') { $path = $Matches[1] $op = 'numstat' -} elseif ($original -match '^git\s+-C\s+"([^"]*)"\s+--no-pager\s+diff\s*$') { + $ref = if ($Matches[2]) { 'HEAD' } else { '' } +} elseif ($original -match '^git\s+-C\s+"([^"]*)"\s+--no-pager\s+diff(\s+HEAD)?\s*$') { $path = $Matches[1] $op = 'diff' + $ref = if ($Matches[2]) { 'HEAD' } else { '' } } elseif ($original -match '^git\s+-C\s+"([^"]*)"\s+status\s+--short\s*$') { $path = $Matches[1] $op = 'status' + $ref = '' } else { Reject 'no-match' } @@ -80,7 +85,13 @@ foreach ($c in [char[]]'`;&|<>"') { } switch ($op) { - 'diff' { & git -C $path --no-pager diff; exit $LASTEXITCODE } - 'numstat' { & git -C $path --no-pager diff --numstat; exit $LASTEXITCODE } - 'status' { & git -C $path status --short; exit $LASTEXITCODE } + 'diff' { + if ($ref) { & git -C $path --no-pager diff HEAD } else { & git -C $path --no-pager diff } + exit $LASTEXITCODE + } + 'numstat' { + if ($ref) { & git -C $path --no-pager diff --numstat HEAD } else { & git -C $path --no-pager diff --numstat } + exit $LASTEXITCODE + } + 'status' { & git -C $path status --short; exit $LASTEXITCODE } } diff --git a/gate/git-gate.sh b/gate/git-gate.sh index 667e4ec..22fe22c 100644 --- a/gate/git-gate.sh +++ b/gate/git-gate.sh @@ -1,10 +1,11 @@ #!/bin/sh # difflab git-gate — forced-command filter for the container SSH key. # -# Allows ONLY these four command shapes: +# Allows ONLY these command shapes (the trailing HEAD ref is optional so the +# gate accepts app versions from before and after the diff-HEAD change): # difflab-batch-statuspath1path2… (batch status; =0x1F) -# git -C --no-pager diff -# git -C --no-pager diff --numstat +# git -C --no-pager diff [HEAD] +# git -C --no-pager diff --numstat [HEAD] # git -C status --short # # Any other command is rejected with "difflab: command not permitted". @@ -64,18 +65,28 @@ esac rest="${cmd#git -C }" # Identify operation by matching the known tail suffixes. -# Order: numstat before diff so the longer suffix wins. +# Order: longest suffix first (numstat before diff, HEAD variants before the +# bare ones) so the most specific pattern wins. `ref` captures the optional +# trailing HEAD so it can be passed through to git unchanged. case "$rest" in + *' --no-pager diff --numstat HEAD') + op="numstat"; ref="HEAD" + qpath="${rest% --no-pager diff --numstat HEAD}" + ;; *' --no-pager diff --numstat') - op="numstat" + op="numstat"; ref="" qpath="${rest% --no-pager diff --numstat}" ;; + *' --no-pager diff HEAD') + op="diff"; ref="HEAD" + qpath="${rest% --no-pager diff HEAD}" + ;; *' --no-pager diff') - op="diff" + op="diff"; ref="" qpath="${rest% --no-pager diff}" ;; *' status --short') - op="status" + op="status"; ref="" qpath="${rest% status --short}" ;; *) @@ -120,9 +131,11 @@ esac # Path must not start with '-' (would be interpreted as a flag) case "$path" in -*) reject "flag-path" ;; esac -# exec replaces this shell — no further shell interpretation of $path +# exec replaces this shell — no further shell interpretation of $path. +# $ref is a controlled literal ("HEAD" or empty); unquoted so an empty ref +# contributes no argument. case "$op" in - diff) exec git -C "$path" --no-pager diff ;; - numstat) exec git -C "$path" --no-pager diff --numstat ;; + diff) exec git -C "$path" --no-pager diff $ref ;; + numstat) exec git -C "$path" --no-pager diff --numstat $ref ;; status) exec git -C "$path" status --short ;; esac diff --git a/tests/test_gate.py b/tests/test_gate.py index a80e5ab..c647eb2 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -25,6 +25,16 @@ def gate_script(): return str(here) +def _init_repo_with_commit(path): + """Init a git repo with one commit so HEAD resolves (git diff HEAD needs it).""" + env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"} + subprocess.run(["git", "init", str(path)], capture_output=True) + (Path(path) / "seed.txt").write_text("seed\n") + subprocess.run(["git", "-C", str(path), "add", "seed.txt"], capture_output=True, env=env) + subprocess.run(["git", "-C", str(path), "commit", "-m", "seed"], capture_output=True, env=env) + + @pytest.fixture def stub_git(tmp_path): """Create a stub git script that echoes canned status output.""" @@ -69,6 +79,73 @@ def test_gate_legacy_status_still_works(gate_script, tmp_path): assert result.returncode == 0 +def test_gate_diff_head_accepted(gate_script, tmp_path): + """git -C --no-pager diff HEAD passes through (post diff-HEAD change).""" + repo = tmp_path / "repo" + _init_repo_with_commit(repo) + result = _run_gate(gate_script, f"git -C {repo} --no-pager diff HEAD") + assert result.returncode == 0, result.stderr + assert "not permitted" not in result.stderr + + +def test_gate_numstat_head_accepted(gate_script, tmp_path): + """git -C --no-pager diff --numstat HEAD passes through.""" + repo = tmp_path / "repo" + _init_repo_with_commit(repo) + result = _run_gate(gate_script, f"git -C {repo} --no-pager diff --numstat HEAD") + assert result.returncode == 0, result.stderr + assert "not permitted" not in result.stderr + + +def test_gate_legacy_diff_without_head_still_accepted(gate_script, tmp_path): + """git -C --no-pager diff (no HEAD) still passes for older app builds.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], capture_output=True) + result = _run_gate(gate_script, f"git -C {repo} --no-pager diff") + assert result.returncode == 0, result.stderr + assert "not permitted" not in result.stderr + + +def test_gate_diff_head_passes_ref_through_to_git(gate_script, tmp_path): + """The HEAD ref must reach git: a staged-only change shows under `diff HEAD` + but not under plain `diff`. This guards the staged-files feature end to end.""" + repo = tmp_path / "repo" + repo.mkdir() + + def git(*args): + return subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, text=True, + env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}, + ) + + git("init") + (repo / "seed.txt").write_text("seed\n") + git("add", "seed.txt") + git("commit", "-m", "seed") + # Stage a brand-new file (staged, not committed) — invisible to plain `git diff`. + (repo / "staged.txt").write_text("new staged content\n") + git("add", "staged.txt") + + plain = _run_gate(gate_script, f"git -C {repo} --no-pager diff") + head = _run_gate(gate_script, f"git -C {repo} --no-pager diff HEAD") + assert plain.returncode == 0 and head.returncode == 0 + assert "staged.txt" not in plain.stdout + assert "staged.txt" in head.stdout + + +def test_gate_rejects_diff_with_arbitrary_ref(gate_script, tmp_path): + """Only HEAD is allowed as the diff ref — arbitrary refs are refused.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], capture_output=True) + result = _run_gate(gate_script, f"git -C {repo} --no-pager diff origin/main") + assert result.returncode != 0 + assert "not permitted" in result.stderr + + def test_gate_batch_two_paths_parseable(gate_script, tmp_path): """Batch with 2 paths emits parseable REPO/RC records.""" repo_a = tmp_path / "a"