Skip to content

Commit bdcf881

Browse files
authored
Pre-commit hook enforcing current copyright year on new files (#832)
## Summary Enforce correct copyright start year on new files via pre-commit hook and update the Agents.md, a reccuring mistake caught in PR reviews (for example new files carry `2025-2026` isntead of just `2026`). ## Detailed description Our pre-commit hooh `insert-license --use-current-year` only guarantees the *current* year is present, so it passes a pasted range like `2025-2026` on a brand-new 2026 file. Adds `tools/fix_new_file_copyright_year.py` hook that rewrites the copyright *start* year of newly added Python (and yaml) files to the current year. --------- Signed-off-by: Clemens Volk <cvolk@nvidia.com>
1 parent b4345fa commit bdcf881

4 files changed

Lines changed: 177 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,9 @@ repos:
7575
language: system
7676
pass_filenames: false
7777
types: [python]
78+
- id: fix-new-file-copyright-year
79+
name: Rewrite new files to use the current copyright year
80+
entry: python3 tools/fix_new_file_copyright_year.py
81+
language: system
82+
types_or: [python, yaml] # match insert-license's .(py|ya?ml)$ scope
83+
exclude: "submodules/"

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Lint and format tooling (`pre-commit` and the hooks it runs — black, flake8, i
4747
- Prefer `assert condition, "message"` over `if not condition: raise ValueError("message")` for internal invariant checks. (Formatting, imports, and typing are enforced by `pre-commit` — see `.pre-commit-config.yaml`.)
4848
- PR bodies follow `.github/pull_request_template.md` — a one-line Summary plus 2–5 detail bullets. Resist the agent default of long, multi-section descriptions.
4949
- Attribute docstrings should be included below the attribute, rather than in the class-level docstring.
50+
- Copyright headers: a newly created file uses the current year alone (e.g. `2026`); a file created earlier and edited this year uses a range (e.g. `2025-2026`). Don't copy a neighbouring file's year — the pre-commit hooks (`insert-license`, `fix-new-file-copyright-year`) set and enforce this, so you generally don't hand-edit it.
5051

5152
## Docstrings style
5253

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Tests for the tools/fix_new_file_copyright_year.py pre-commit hook."""
7+
8+
from __future__ import annotations
9+
10+
import importlib.util
11+
from datetime import date
12+
from pathlib import Path
13+
14+
CURRENT_YEAR = str(date.today().year)
15+
_REPO_ROOT = Path(__file__).resolve().parents[2]
16+
_SCRIPT = _REPO_ROOT / "tools" / "fix_new_file_copyright_year.py"
17+
_LICENSE_TEMPLATE = _REPO_ROOT / ".github" / "LICENSE_HEADER.txt"
18+
19+
20+
def _load_hook():
21+
"""Import tools/fix_new_file_copyright_year.py (a standalone script, not an installed module)."""
22+
spec = importlib.util.spec_from_file_location("fix_new_file_copyright_year", _SCRIPT)
23+
module = importlib.util.module_from_spec(spec)
24+
spec.loader.exec_module(module)
25+
return module
26+
27+
28+
hook = _load_hook()
29+
30+
31+
def _header(years: str) -> str:
32+
"""Return a minimal Python source string with an Arena copyright header for the given year(s)."""
33+
return (
34+
f"# Copyright (c) {years}, The Isaac Lab Arena Project Developers "
35+
"(https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).\n"
36+
"# All rights reserved.\n#\n# SPDX-License-Identifier: Apache-2.0\n\nx = 1\n"
37+
)
38+
39+
40+
def test_header_years_parses_single_and_range() -> None:
41+
assert hook.header_years(_header(CURRENT_YEAR)) == CURRENT_YEAR
42+
assert hook.header_years(_header("2020-2025")) == "2020-2025"
43+
assert hook.header_years("x = 1\n") is None
44+
45+
46+
def test_regex_matches_the_real_license_template() -> None:
47+
# The other tests build the header by hand; this guards against ARENA_RE drifting away from the
48+
# canonical .github/LICENSE_HEADER.txt that insert-license actually writes (e.g. an org rename).
49+
assert hook.header_years(_LICENSE_TEMPLATE.read_text(encoding="utf-8")) is not None
50+
51+
52+
def test_new_file_with_pasted_range_is_rewritten() -> None:
53+
fixed = hook.fix_header_year(_header(f"2020-{CURRENT_YEAR}"), CURRENT_YEAR, is_new=True)
54+
assert fixed == _header(CURRENT_YEAR)
55+
56+
57+
def test_new_file_with_current_year_needs_no_change() -> None:
58+
assert hook.fix_header_year(_header(CURRENT_YEAR), CURRENT_YEAR, is_new=True) is None
59+
60+
61+
def test_existing_file_is_left_alone() -> None:
62+
# A file already in HEAD keeps its start year even when it differs from the current year;
63+
# the end year is the insert-license hook's responsibility, not this one's.
64+
assert hook.fix_header_year(_header(f"2020-{CURRENT_YEAR}"), CURRENT_YEAR, is_new=False) is None
65+
66+
67+
def test_file_without_arena_header_is_ignored() -> None:
68+
assert hook.fix_header_year("x = 1\n", CURRENT_YEAR, is_new=True) is None
69+
70+
71+
def test_yaml_header_is_also_rewritten() -> None:
72+
# The header format and git detection are language-agnostic: YAML files carry the same
73+
# "# Copyright (c) ..." header, so widening the hook to YAML needs no logic change.
74+
yaml = (
75+
f"# Copyright (c) 2020-{CURRENT_YEAR}, The Isaac Lab Arena Project Developers "
76+
"(https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).\n"
77+
"# All rights reserved.\n#\n# SPDX-License-Identifier: Apache-2.0\n\nkey: value\n"
78+
)
79+
fixed = hook.fix_header_year(yaml, CURRENT_YEAR, is_new=True)
80+
assert fixed is not None
81+
assert fixed.startswith(f"# Copyright (c) {CURRENT_YEAR},")
82+
83+
84+
def test_added_paths_keeps_adds_and_drops_renames_and_edits() -> None:
85+
# Lines from `git diff --cached --name-status`: A=added, R<score>=renamed, M=modified.
86+
# Only true additions are "new"; a rename (the bug this guards against) keeps its start year.
87+
status = "A\tfresh.py\nA\tconfig.yaml\nR100\told.py\tnew.py\nM\tseed.py\n"
88+
assert hook.added_paths(status) == {"fresh.py", "config.yaml"}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Auto-fix the start year of the Arena copyright header on newly added Python and YAML files.
7+
8+
A file added in the current commit must carry the current year alone (e.g. ``2026``);
9+
a pasted range such as ``2025-2026`` is rewritten to ``2026``. Existing files and the
10+
end year are left to the ``insert-license`` hook.
11+
"""
12+
13+
import re
14+
import subprocess
15+
import sys
16+
from datetime import date
17+
from pathlib import Path
18+
19+
# Captures the year string of the Arena copyright line, splitting off the literal
20+
# prefix/suffix so a rewrite only touches the year(s).
21+
ARENA_RE = re.compile(r"(Copyright \(c\) )(\d{4}(?:-\d{4})?)(, The Isaac Lab Arena Project Developers)")
22+
23+
24+
def header_years(text: str) -> str | None:
25+
"""Return the Arena copyright header's year string (e.g. '2025' or '2025-2026'), or None if absent."""
26+
m = ARENA_RE.search(text)
27+
return m.group(2) if m else None
28+
29+
30+
def fix_header_year(text: str, current_year: str, is_new: bool) -> str | None:
31+
"""Return text with the Arena copyright start year set to current_year, or None if no change is needed.
32+
33+
Only newly added files are rewritten; an existing file's start year is preserved (its end year is
34+
the insert-license hook's responsibility). A missing header or an already-correct year yields None.
35+
"""
36+
years = header_years(text)
37+
if years is None or not is_new or years == current_year:
38+
return None
39+
return ARENA_RE.sub(rf"\g<1>{current_year}\g<3>", text, count=1)
40+
41+
42+
def added_paths(status_output: str) -> set[str]:
43+
"""Return the paths added from scratch, parsed from ``git diff --cached --name-status`` output.
44+
45+
Each line is ``<status>\\t<path>`` (``R<score>\\t<old>\\t<new>`` for a rename). Only status ``A``
46+
counts as new; a rename is reported as ``R`` and excluded, so a moved file keeps its original
47+
start year instead of having it reset to the current year.
48+
"""
49+
return {line.split("\t")[1] for line in status_output.splitlines() if line.startswith("A\t")}
50+
51+
52+
def staged_status() -> str:
53+
"""Return ``git diff --cached --name-status`` for the staged changes, with renames detected."""
54+
result = subprocess.run(
55+
["git", "diff", "--cached", "--find-renames", "--name-status"],
56+
capture_output=True,
57+
text=True,
58+
)
59+
return result.stdout
60+
61+
62+
def main(argv: list[str]) -> int:
63+
current = str(date.today().year)
64+
new_files = added_paths(staged_status())
65+
exit_code = 0
66+
for path in argv:
67+
file = Path(path)
68+
try:
69+
text = file.read_text(encoding="utf-8")
70+
except OSError:
71+
continue
72+
new_text = fix_header_year(text, current, is_new=path in new_files)
73+
if new_text is None:
74+
continue
75+
file.write_text(new_text, encoding="utf-8")
76+
print(f"{path}: new file copyright year '{header_years(text)}' rewritten to '{current}'")
77+
exit_code = 1
78+
return exit_code
79+
80+
81+
if __name__ == "__main__":
82+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)