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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
23 changes: 23 additions & 0 deletions .github/workflows/repository-hygiene.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Repository Hygiene

on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev

jobs:
hygiene:
name: Check tracked generated files
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Run repository hygiene check
run: python3 scripts/check_workspace_hygiene.py
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
# git commit # Hooks run automatically on commit

repos:
- repo: local
hooks:
- id: deeptutor-repo-hygiene
name: DeepTutor repository hygiene
entry: python3 scripts/check_repo_hygiene.py
language: system
pass_filenames: false
always_run: true

# ============================================
# General file checks
# ============================================
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,36 @@ detect-secrets scan > .secrets.baseline

| Task | Command |
|---|---|
| Check clean workspace + tracked hygiene | `python3 scripts/check_workspace_hygiene.py` |
| Check repository hygiene | `python3 scripts/check_repo_hygiene.py` |
| Check all files | `pre-commit run --all-files` |
| Check quietly | `pre-commit run --all-files -q` |
| Update tools | `pre-commit autoupdate` |
| Emergency skip | `git commit --no-verify -m "message"` *(not recommended)* |

### Generated Files and Worktrees

Keep build outputs out of Git. `web/.next*`, `node_modules`, test reports, and
bytecode caches are regeneratable and must remain untracked. If a build output
is already tracked, remove it from the index with `git rm --cached` rather than
deleting the local file needed by an application run.

Fresh checkouts can enable the dependency-free safety hook with:

```bash
git config core.hooksPath scripts/hooks
```

The hook also blocks accidental direct commits on `main`. Release maintainers who
deliberately need a local `main` commit may opt in once with
`git config deeptutor.allowMainCommit true`, then remove the setting immediately
afterward.

Use a separate Git worktree for each feature (`git worktree add ../DeepTutor-<task>
-b <branch> dev`) and keep the primary checkout clean. This lets builds, tests,
and long-running agents operate independently without rewriting one another's
outputs. Before removing a worktree, commit or explicitly preserve its changes;
do not use `git reset --hard` or `git clean` as a routine cleanup shortcut.
---

## Code Quality & Security
Expand Down
43 changes: 43 additions & 0 deletions scripts/check_branch_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Reject accidental direct commits on the release branch."""

from __future__ import annotations

import subprocess
import sys


def current_branch() -> str | None:
result = subprocess.run(
["git", "branch", "--show-current"],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip() or None


def allows_main_commit() -> bool:
result = subprocess.run(
["git", "config", "--bool", "deeptutor.allowMainCommit"],
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip().lower() == "true"


def main() -> int:
if current_branch() == "main" and not allows_main_commit():
print(
"Direct commits to main are forbidden. Develop on dev or a topic branch, "
"then integrate through review. For an explicit release exception, set "
"deeptutor.allowMainCommit=true and unset it afterward.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
69 changes: 69 additions & 0 deletions scripts/check_repo_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Reject commonly regenerated files that have accidentally been tracked."""

from __future__ import annotations

from pathlib import PurePosixPath
import subprocess
import sys

FORBIDDEN_PARTS = {
".DS_Store",
".next",
".next-deeptutor",
".turbo",
"__pycache__",
"htmlcov",
"node_modules",
"playwright-report",
"test-results",
}
FORBIDDEN_SUFFIXES = (".pyc", ".pyo")


def tracked_paths() -> list[str]:
result = subprocess.run(
["git", "ls-files", "-z"],
check=True,
capture_output=True,
)
return [path for path in result.stdout.decode("utf-8").split("\0") if path]


def violation(path: str) -> str | None:
pure_path = PurePosixPath(path)
if pure_path.parts[0:1] == ("web",) and pure_path.parts[1:2] in (
("out",),
("dist",),
):
return "frontend build output"
if any(part in FORBIDDEN_PARTS for part in pure_path.parts):
return "generated output"
if pure_path.suffix in FORBIDDEN_SUFFIXES:
return "compiled bytecode"
if path != path.strip() or any(
ord(character) < 32 or ord(character) == 127 for character in path
):
return "unusual filesystem whitespace"
return None


def main() -> int:
violations = [
f"{path}: {reason}" for path in tracked_paths() if (reason := violation(path)) is not None
]
if violations:
print("Tracked generated or anomalous files found:", file=sys.stderr)
print("\n".join(violations), file=sys.stderr)
print(
"Remove them from the index with `git rm --cached`; keep local files when "
"they are useful build output.",
file=sys.stderr,
)
return 1
print("Repository hygiene check passed.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions scripts/check_workspace_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Require a clean checkout in addition to clean tracked repository content."""

from __future__ import annotations

import subprocess
import sys


def run_git(arguments: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *arguments],
check=False,
capture_output=True,
text=True,
)


def dirty_entries() -> list[str]:
result = run_git(["status", "--porcelain=v1", "--untracked-files=all"])
if result.returncode != 0:
raise SystemExit(result.stderr.strip() or "Unable to inspect Git status.")
return [line for line in result.stdout.splitlines() if line.strip()]


def tracked_hygiene() -> int:
result = subprocess.run(
[sys.executable, "scripts/check_repo_hygiene.py"],
check=False,
)
return result.returncode


def main() -> int:
entries = dirty_entries()
if entries:
print(
"Dirty checkout found; move work to a task worktree before proceeding:",
file=sys.stderr,
)
print("\n".join(entries), file=sys.stderr)
return 1
return tracked_hygiene()


if __name__ == "__main__":
raise SystemExit(main())
8 changes: 8 additions & 0 deletions scripts/hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh

set -eu

# Keep this hook dependency-free so a fresh checkout can reject generated
# files before contributors install the full pre-commit environment.
python3 scripts/check_repo_hygiene.py
python3 scripts/check_branch_policy.py
48 changes: 48 additions & 0 deletions tests/scripts/test_branch_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace


def _load_branch_policy_module():
module_path = Path(__file__).resolve().parents[2] / "scripts" / "check_branch_policy.py"
module_name = "branch_policy_under_test"
sys.modules.pop(module_name, None)
spec = importlib.util.spec_from_file_location(module_name, module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module


def _completed(returncode: int = 0, stdout: str = "") -> object:
return SimpleNamespace(returncode=returncode, stdout=stdout)


def test_allows_normal_branches(monkeypatch, capsys) -> None:
module = _load_branch_policy_module()
monkeypatch.setattr(module, "current_branch", lambda: "dev")

assert module.main() == 0
assert not capsys.readouterr().err


def test_rejects_main_without_explicit_exception(monkeypatch, capsys) -> None:
module = _load_branch_policy_module()
monkeypatch.setattr(module, "current_branch", lambda: "main")
monkeypatch.setattr(module, "allows_main_commit", lambda: False)

assert module.main() == 1
assert "Direct commits to main are forbidden" in capsys.readouterr().err


def test_allows_explicit_main_exception(monkeypatch, capsys) -> None:
module = _load_branch_policy_module()
monkeypatch.setattr(module, "current_branch", lambda: "main")
monkeypatch.setattr(module, "allows_main_commit", lambda: True)

assert module.main() == 0
assert not capsys.readouterr().err
32 changes: 32 additions & 0 deletions tests/scripts/test_pre_commit_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from pathlib import Path
import os
import subprocess


def test_hook_stops_when_repo_hygiene_fails(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
calls = tmp_path / "calls"
fake_python = tmp_path / "python3"
fake_python.write_text(
"#!/bin/sh\n"
f'printf "%s\\n" "$1" >> "{calls}"\n'
'test "$1" != "scripts/check_repo_hygiene.py"\n',
encoding="utf-8",
)
fake_python.chmod(0o755)

env = os.environ.copy()
env["PATH"] = f"{tmp_path}{os.pathsep}{env['PATH']}"
result = subprocess.run(
["sh", "scripts/hooks/pre-commit"],
cwd=repo_root,
env=env,
check=False,
)

assert result.returncode != 0
assert calls.read_text(encoding="utf-8").splitlines() == [
"scripts/check_repo_hygiene.py"
]
Loading
Loading