Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions difflab/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
29 changes: 20 additions & 9 deletions gate/git-gate.ps1
Original file line number Diff line number Diff line change
@@ -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-status<US>path1<US>path2… (batch status; <US>=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).
Expand Down Expand Up @@ -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'
}
Expand All @@ -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 }
}
33 changes: 23 additions & 10 deletions gate/git-gate.sh
Original file line number Diff line number Diff line change
@@ -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-status<US>path1<US>path2… (batch status; <US>=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".
Expand Down Expand Up @@ -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}"
;;
*)
Expand Down Expand Up @@ -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
77 changes: 77 additions & 0 deletions tests/test_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 <path> --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 <path> --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 <path> --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"
Expand Down
Loading