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
4 changes: 0 additions & 4 deletions credentials.properties

This file was deleted.

34 changes: 33 additions & 1 deletion skills/update-copyright/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ name: update-copyright
description: >
Updates source file copyright headers from the IntelliJ IDEA copyright
profile, replacing `today.year` with the current year. Automatically applies
to changed source files when source files are modified in a change set.
to changed source files when source files are modified in a change set. In
repositories that consume the shared `config` submodule, files distributed
by `config` (such as `buildSrc/`) are skipped — their headers are owned by
`config`, not by the consumer's copyright profile.
---

# Copyright Update
Expand All @@ -21,3 +24,32 @@ description: >
2. Repo-wide refresh → run with `--dry-run` first, then without.
3. Relay stdout (notice source, file count, changed paths) to the user.
4. Never add a copyright header to a file that does not already have one.

## Files distributed by the `config` repository

In a repository that consumes the shared `config` submodule (its `.gitmodules`
declares a submodule with `path = config`), the script skips the files that
`config`'s `migrate` script distributes into the repo: `./config/pull`
overwrites them on every pull, so their headers are owned by `config`, not by
the consumer's copyright profile. The skip applies to explicit paths (including
the automatic follow-up run after edits) and to repo-wide runs alike:

- `buildSrc/` — except `buildSrc/src/main/kotlin/module.gradle.kts`, which
`migrate` preserves across pulls, so the consumer owns it; its
`*-module.gradle.kts` siblings (e.g. `jvm-module.gradle.kts`) *are*
distributed and stay skipped.
- The root files `gradle.properties`, `.codecov.yml`, and `lychee.toml`.
- `.github/workflows/<name>` when the `config` submodule carries a workflow of
the same basename in its `.github/workflows/` or `.github-workflows/`
directory. Repo-specific workflows — including `config:replaces` variants,
which use a different name — keep being stamped. When the submodule is not
checked out, the comparison is impossible and all workflow files are treated
as consumer-owned (stamped).

The `config` and `agents` source repositories declare no `config` submodule,
so the rule is inert there and their own `buildSrc/` etc. keep being stamped —
correct, since those files are project-owned at the source. Do **not** skip a
path merely because a same-named file exists somewhere under the `config/`
submodule: `config` carries files it does not distribute (e.g. its own
`README.md`). The set mirrors what `config`'s `migrate` script copies; if that
script changes what it distributes, update the script's exclusions to match.
89 changes: 86 additions & 3 deletions skills/update-copyright/scripts/update_copyright.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET

Expand Down Expand Up @@ -80,6 +81,14 @@
"gradlew",
"gradlew.bat",
}
# Root files that `config`'s `migrate` script copies into consumer repos.
CONFIG_DISTRIBUTED_ROOT_FILES = {
".codecov.yml",
"gradle.properties",
"lychee.toml",
}
# The one `buildSrc` file `migrate` preserves across pulls; the consumer owns it.
CONSUMER_OWNED_MODULE_GRADLE = Path("buildSrc/src/main/kotlin/module.gradle.kts")


def parse_args() -> argparse.Namespace:
Expand Down Expand Up @@ -175,13 +184,86 @@ def style_for(path: Path) -> str | None:
return None


def is_excluded(path: Path) -> bool:
@dataclass(frozen=True)
class ConfigDistribution:
"""Files the shared `config` repository distributes into a consumer repo.

A consumer repo declares the `config` submodule in `.gitmodules`;
`./config/pull` (the submodule's `migrate` script) then copies these files
into the repo and overwrites them on every pull. Their headers are owned
by `config`, so re-stamping them from the consumer's copyright profile is
wrong. The `config` and `agents` source repositories declare no such
submodule; there the same paths are project-owned and stay in scope.
"""

workflows: frozenset[str]
"""Basenames of the workflows distributed into `.github/workflows/`.

Empty when the `config` submodule is not checked out: the comparison is
impossible, and workflow files are treated as consumer-owned (stamped).
"""

def covers(self, path: Path) -> bool:
parts = path.parts
if parts[0] == "buildSrc":
return path != CONSUMER_OWNED_MODULE_GRADLE
if len(parts) == 1 and parts[0] in CONFIG_DISTRIBUTED_ROOT_FILES:
return True
return (
len(parts) == 3
and parts[:2] == (".github", "workflows")
and parts[2] in self.workflows
)


def declares_config_submodule(root: Path) -> bool:
try:
text = (root / ".gitmodules").read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return False
in_submodule = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("["):
in_submodule = stripped[1:].lstrip().lower().startswith("submodule")
continue
if not in_submodule:
continue
key, sep, value = stripped.partition("=")
if sep and key.strip().lower() == "path":
if value.strip().strip('"') == "config":
return True
return False


def distributed_workflow_names(root: Path) -> frozenset[str]:
names: set[str] = set()
for directory in (
root / "config" / ".github" / "workflows",
root / "config" / ".github-workflows",
):
if directory.is_dir():
names.update(
entry.name for entry in directory.iterdir() if entry.is_file()
)
return frozenset(names)


def config_distribution(root: Path) -> ConfigDistribution | None:
if not declares_config_submodule(root):
return None
return ConfigDistribution(workflows=distributed_workflow_names(root))


def is_excluded(path: Path, distribution: ConfigDistribution | None = None) -> bool:
if path.name in EXCLUDED_FILES:
return True
parts = path.parts
if len(parts) >= 2 and parts[0] == "gradle" and parts[1] == "wrapper":
return True
return any(part in EXCLUDED_DIRS for part in parts)
if any(part in EXCLUDED_DIRS for part in parts):
return True
return distribution is not None and distribution.covers(path)


def tracked_files(root: Path) -> list[Path]:
Expand Down Expand Up @@ -230,11 +312,12 @@ def expand_requested_paths(root: Path, requested: list[str]) -> list[Path]:
else:
paths.append(path.relative_to(root))

distribution = config_distribution(root)
unique = sorted(set(paths), key=lambda p: p.as_posix())
return [
path
for path in unique
if style_for(path) is not None and not is_excluded(path)
if style_for(path) is not None and not is_excluded(path, distribution)
]


Expand Down
180 changes: 180 additions & 0 deletions skills/update-copyright/tests/test_update_copyright.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@

SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_copyright.py"

# Headers matching the test profile: stale files WOULD be re-stamped unless
# excluded, so an unchanged stale file proves exclusion, not a no-op.
STALE_BLOCK = (
"/*\n"
" * Copyright 2024 ACME\n"
" * All rights reserved\n"
" */\n"
"\n"
)
FRESH_BLOCK = STALE_BLOCK.replace("2024", "2026")
STALE_HASH = "# Copyright 2024 ACME\n# All rights reserved\n\n"
FRESH_HASH = STALE_HASH.replace("2024", "2026")


class UpdateCopyrightTest(unittest.TestCase):
def test_default_run_leaves_plain_source_without_header_unchanged(self) -> None:
Expand Down Expand Up @@ -89,6 +102,159 @@ def test_default_run_skips_tracked_files_deleted_from_working_tree(self) -> None
self.assertIn("Would update 0 file(s).", result.stdout)
self.assertEqual(result.stderr, "")

def test_consumer_repo_skips_build_src_except_module_gradle_kts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_profile(root)
self.declare_config_submodule(root)
kotlin_dir = root / "buildSrc" / "src" / "main" / "kotlin"
distributed = kotlin_dir / "jvm-module.gradle.kts"
owned = kotlin_dir / "module.gradle.kts"
self.write_file(distributed, STALE_BLOCK + "plugins { }\n")
self.write_file(owned, STALE_BLOCK + "plugins { }\n")

result = self.run_script(
root,
"--year",
"2026",
"buildSrc/src/main/kotlin/jvm-module.gradle.kts",
"buildSrc/src/main/kotlin/module.gradle.kts",
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
distributed.read_text(encoding="utf-8"),
STALE_BLOCK + "plugins { }\n",
)
self.assertEqual(
owned.read_text(encoding="utf-8"),
FRESH_BLOCK + "plugins { }\n",
)

def test_repo_without_config_submodule_updates_build_src(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_profile(root)
source = root / "buildSrc" / "src" / "main" / "kotlin" / "jvm-module.gradle.kts"
Comment thread
alexander-yevsyukov marked this conversation as resolved.
self.write_file(source, STALE_BLOCK + "plugins { }\n")

result = self.run_script(
root,
"--year",
"2026",
"buildSrc/src/main/kotlin/jvm-module.gradle.kts",
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
source.read_text(encoding="utf-8"),
FRESH_BLOCK + "plugins { }\n",
)

def test_consumer_repo_skips_distributed_workflows_only(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_profile(root)
self.declare_config_submodule(root)
# The submodule distributes workflows from both of its directories.
self.write_file(
root / "config" / ".github" / "workflows" / "build-on-ubuntu.yml",
"name: Build\n",
)
self.write_file(
root / "config" / ".github-workflows" / "publish.yml",
"name: Publish\n",
)
workflows = root / ".github" / "workflows"
for name in ("build-on-ubuntu.yml", "publish.yml", "custom-build.yml"):
self.write_file(workflows / name, STALE_HASH + "name: CI\n")

result = self.run_script(
root,
"--year",
"2026",
".github/workflows/build-on-ubuntu.yml",
".github/workflows/publish.yml",
".github/workflows/custom-build.yml",
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
(workflows / "build-on-ubuntu.yml").read_text(encoding="utf-8"),
STALE_HASH + "name: CI\n",
)
self.assertEqual(
(workflows / "publish.yml").read_text(encoding="utf-8"),
STALE_HASH + "name: CI\n",
)
self.assertEqual(
(workflows / "custom-build.yml").read_text(encoding="utf-8"),
FRESH_HASH + "name: CI\n",
)

def test_consumer_repo_stamps_workflows_when_config_not_checked_out(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_profile(root)
self.declare_config_submodule(root)
workflow = root / ".github" / "workflows" / "build-on-ubuntu.yml"
self.write_file(workflow, STALE_HASH + "name: CI\n")

result = self.run_script(
root, "--year", "2026", ".github/workflows/build-on-ubuntu.yml"
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 1 file(s).", result.stdout)
self.assertEqual(
workflow.read_text(encoding="utf-8"),
FRESH_HASH + "name: CI\n",
)

def test_consumer_repo_default_run_skips_distributed_files(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
self.write_profile(root)
self.declare_config_submodule(root)
kotlin_dir = root / "buildSrc" / "src" / "main" / "kotlin"
self.write_file(
kotlin_dir / "jvm-module.gradle.kts", STALE_BLOCK + "plugins { }\n"
)
self.write_file(
kotlin_dir / "module.gradle.kts", STALE_BLOCK + "plugins { }\n"
)
self.write_file(root / "gradle.properties", STALE_HASH + "key=value\n")
self.write_file(root / "Foo.java", STALE_BLOCK + "class Foo {}\n")

subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "add", "-A"], cwd=root, check=True)

result = self.run_script(root, "--year", "2026")

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Updated 2 file(s).", result.stdout)
self.assertIn("Foo.java", result.stdout)
self.assertIn("buildSrc/src/main/kotlin/module.gradle.kts", result.stdout)
self.assertEqual(
(kotlin_dir / "jvm-module.gradle.kts").read_text(encoding="utf-8"),
STALE_BLOCK + "plugins { }\n",
)
self.assertEqual(
(root / "gradle.properties").read_text(encoding="utf-8"),
STALE_HASH + "key=value\n",
)
self.assertEqual(
(kotlin_dir / "module.gradle.kts").read_text(encoding="utf-8"),
FRESH_BLOCK + "plugins { }\n",
)
self.assertEqual(
(root / "Foo.java").read_text(encoding="utf-8"),
FRESH_BLOCK + "class Foo {}\n",
)

@staticmethod
def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
Expand All @@ -105,6 +271,20 @@ def run_script(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
text=True,
)

@staticmethod
def write_file(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")

@staticmethod
def declare_config_submodule(root: Path) -> None:
(root / ".gitmodules").write_text(
'[submodule "config"]\n'
"\tpath = config\n"
"\turl = https://github.com/SpineEventEngine/config.git\n",
encoding="utf-8",
)

@staticmethod
def write_profile(root: Path) -> None:
copyright_dir = root / ".idea" / "copyright"
Expand Down