大改版,可以针对不同项目引擎进行初始化
This commit is contained in:
@@ -9,6 +9,8 @@ hooks/
|
||||
├── session-init.sh # sessionStart(Bash)
|
||||
├── check-changelog.ps1 # stop / changelog guard(PowerShell)
|
||||
├── check-changelog.sh # stop / changelog guard(Bash)
|
||||
├── changelog-scan.json # cursor-init 按项目类型生成的活动扫描策略
|
||||
├── tests/ # PowerShell / Bash 共用回归测试
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -51,11 +53,12 @@ macOS/Linux 不需要 Windows dispatcher。将 `.cursor/hooks.json` 改为 Bash
|
||||
}
|
||||
```
|
||||
|
||||
同时把本机 `.cursor/local-env.json` 的 `shell` 设为 `bash`(或实际使用的 `zsh`)。Bash 脚本不依赖 `jq`;优先用 `python3`(Windows Git Bash 可回退 `python`)安全解析/生成 JSON,解释器缺失或解析失败时 fail-open 返回 `{}`。
|
||||
同时把本机 `.cursor/local-env.json` 的 `shell` 设为 `bash`(或实际使用的 `zsh`)。Bash 脚本不依赖 `jq`;优先用 `python3`(Windows Git Bash 可回退 `python`)读取扫描配置并生成 JSON,解释器缺失或解析失败时 fail-open 返回 `{}`。
|
||||
|
||||
## 行为
|
||||
|
||||
- `session-init` 清除上一会话的 `.cursor/changelog/.changelog-ack`,并刷新 gitignored 的 `.cursor/changelog/.session-start`。非 `agent` 模式通过 hook `env` 设置 `CURSOR_SKIP_CHANGELOG=1`。
|
||||
- Cursor 的 `stop` 输入不包含可靠的工具编辑历史,因此 `check-changelog` 不解析不存在的“编辑证据”。它只在未 ack、未被 session 环境跳过,且 Godot 源码/配置同时晚于本会话起点与当前 changelog 时返回 `followup_message`,避免旧会话残留 mtime 误报。
|
||||
- 扫描从仓库根开始,覆盖 `project.godot`、`.gd`、`.tscn`、`.tres`、`.res`、`.gdshader`、`.cfg`、`.json`、`.cs`、`.csproj` 及常用工具脚本;跳过 `.git`、`.cursor`、`.godot`、依赖目录、Python 缓存/虚拟环境和常见构建输出目录。
|
||||
- Cursor 的 `stop` 输入不包含可靠的工具编辑历史,因此 `check-changelog` 使用 Git 工作区状态与文件时间共同判断。只有 `changelog-scan.json` 命中的项目源码/配置同时存在未提交内容差异,且晚于本会话起点与当前 changelog 时,才返回 `followup_message`。
|
||||
- `.session-start` 缺失、Git 不可用、状态读取失败或工作区无内容差异时均 fail-open 返回 `{}`。不得只因文件 mtime 较新就要求写开发日志。
|
||||
- 扫描文件名、扩展名与排除目录由 common + 所选项目类型 manifest 合并生成;Hook 代码不得重新硬编码 Godot 或 Roblox 列表。
|
||||
- 所有正常与失败路径都在 stdout 输出一个合法 JSON 对象;检查失败默认不阻断。
|
||||
|
||||
11
.cursor/hooks/changelog-scan.json
Normal file
11
.cursor/hooks/changelog-scan.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"project_type": "bootstrap",
|
||||
"display_name": "初始化基线",
|
||||
"included_names": [],
|
||||
"included_extensions": [],
|
||||
"excluded_directories": [
|
||||
".git",
|
||||
".cursor"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Changelog sync guard: compare Godot source/config mtimes against this session and changelog.
|
||||
# Changelog sync guard: require Git changes newer than this session and changelog.
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
[Console]::InputEncoding = $utf8
|
||||
[Console]::OutputEncoding = $utf8
|
||||
@@ -20,8 +20,15 @@ $repoRoot = Split-Path -Parent $cursorDir
|
||||
$changelog = Join-Path $cursorDir 'changelog\changelog-headlines.md'
|
||||
$ackFile = Join-Path $cursorDir 'changelog\.changelog-ack'
|
||||
$sessionMarker = Join-Path $cursorDir 'changelog\.session-start'
|
||||
$scanConfigPath = Join-Path $PSScriptRoot 'changelog-scan.json'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $changelog -PathType Leaf)) {
|
||||
if (-not (Test-Path -LiteralPath $changelog -PathType Leaf) -or
|
||||
-not (Test-Path -LiteralPath $scanConfigPath -PathType Leaf)) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $sessionMarker -PathType Leaf)) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
@@ -33,51 +40,50 @@ if ((Test-Path -LiteralPath $ackFile -PathType Leaf) -and
|
||||
exit 0
|
||||
}
|
||||
|
||||
$comparisonMtime = $changelogMtime
|
||||
if ((Test-Path -LiteralPath $sessionMarker -PathType Leaf)) {
|
||||
$sessionMtime = (Get-Item -LiteralPath $sessionMarker).LastWriteTimeUtc
|
||||
if ($sessionMtime -gt $comparisonMtime) {
|
||||
$comparisonMtime = $sessionMtime
|
||||
}
|
||||
$gitStatus = @(& git -C $repoRoot status --porcelain=v1 --untracked-files=all -- 2>$null)
|
||||
if ($LASTEXITCODE -ne 0 -or $gitStatus.Count -eq 0) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
$includedNames = @('project.godot')
|
||||
$includedExtensions = @(
|
||||
'.gd',
|
||||
'.tscn',
|
||||
'.tres',
|
||||
'.res',
|
||||
'.gdshader',
|
||||
'.cfg',
|
||||
'.json',
|
||||
'.cs',
|
||||
'.csproj',
|
||||
'.py',
|
||||
'.ps1',
|
||||
'.sh'
|
||||
)
|
||||
$excludedDirectoryNames = @(
|
||||
'.git',
|
||||
'.cursor',
|
||||
'.godot',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'venv',
|
||||
'.venv',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'bin',
|
||||
'obj',
|
||||
'export',
|
||||
'exports',
|
||||
'coverage',
|
||||
'tmp',
|
||||
'temp'
|
||||
)
|
||||
try {
|
||||
$scanConfig = Get-Content -LiteralPath $scanConfigPath -Raw -Encoding UTF8 |
|
||||
ConvertFrom-Json
|
||||
if ($scanConfig.schema_version -ne 1) {
|
||||
throw "Unsupported changelog scan schema."
|
||||
}
|
||||
$includedNames = @(
|
||||
$scanConfig.included_names |
|
||||
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
|
||||
)
|
||||
$includedExtensions = @(
|
||||
$scanConfig.included_extensions |
|
||||
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
|
||||
)
|
||||
$excludedDirectoryNames = @(
|
||||
$scanConfig.excluded_directories |
|
||||
Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }
|
||||
)
|
||||
} catch {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($includedNames.Count -eq 0 -and $includedExtensions.Count -eq 0) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
$comparisonMtime = $changelogMtime
|
||||
$sessionMtime = (Get-Item -LiteralPath $sessionMarker).LastWriteTimeUtc
|
||||
if ($sessionMtime -gt $comparisonMtime) {
|
||||
$comparisonMtime = $sessionMtime
|
||||
}
|
||||
|
||||
$newerFile = $null
|
||||
$newerRelativePath = $null
|
||||
$rootItem = Get-Item -LiteralPath $repoRoot
|
||||
$rootPrefix = $repoRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$directories = New-Object 'System.Collections.Generic.Stack[System.IO.DirectoryInfo]'
|
||||
$directories.Push($rootItem)
|
||||
|
||||
@@ -101,20 +107,29 @@ while ($directories.Count -gt 0 -and -not $newerFile) {
|
||||
$isIncluded = ($includedNames -contains $entry.Name) -or
|
||||
($includedExtensions -contains $entry.Extension)
|
||||
if ($isIncluded -and $entry.LastWriteTimeUtc -gt $comparisonMtime) {
|
||||
$newerFile = $entry
|
||||
break
|
||||
$relativePath = if ($entry.FullName.StartsWith(
|
||||
$rootPrefix,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
$entry.FullName.Substring($rootPrefix.Length)
|
||||
} else {
|
||||
$entry.Name
|
||||
}
|
||||
$gitPath = $relativePath -replace '\\', '/'
|
||||
$pathStatus = @(
|
||||
& git -C $repoRoot status --porcelain=v1 --untracked-files=all -- $gitPath 2>$null
|
||||
)
|
||||
if ($LASTEXITCODE -eq 0 -and $pathStatus.Count -gt 0) {
|
||||
$newerFile = $entry
|
||||
$newerRelativePath = $relativePath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newerFile) {
|
||||
$rootPrefix = $repoRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$relativePath = if ($newerFile.FullName.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$newerFile.FullName.Substring($rootPrefix.Length)
|
||||
} else {
|
||||
$newerFile.Name
|
||||
}
|
||||
$message = "[Hook] Godot source/config is newer than the changelog (e.g. $relativePath). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
|
||||
$message = "[Hook] Project source/config has an uncommitted change newer than this session and the changelog (e.g. $newerRelativePath). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
|
||||
$result = [ordered]@{ followup_message = $message }
|
||||
[Console]::Out.WriteLine(($result | ConvertTo-Json -Compress -Depth 3))
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Changelog sync guard:比较 Godot 源码/配置与本会话起点、changelog 的 mtime。
|
||||
# Changelog sync guard:按活动扫描配置提示本会话内尚未提交的项目改动。
|
||||
|
||||
emit_empty() {
|
||||
printf '{}\n'
|
||||
@@ -18,8 +18,14 @@ repo_root=$(dirname -- "$cursor_dir")
|
||||
changelog="$cursor_dir/changelog/changelog-headlines.md"
|
||||
ack_file="$cursor_dir/changelog/.changelog-ack"
|
||||
session_marker="$cursor_dir/changelog/.session-start"
|
||||
scan_config="$script_dir/changelog-scan.json"
|
||||
|
||||
if [ ! -f "$changelog" ]; then
|
||||
if [ ! -f "$changelog" ] || [ ! -f "$scan_config" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$session_marker" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
@@ -38,66 +44,6 @@ if [ -f "$ack_file" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
comparison_file=$changelog
|
||||
if [ -f "$session_marker" ]; then
|
||||
changelog_mtime=$(get_mtime "$changelog")
|
||||
session_mtime=$(get_mtime "$session_marker")
|
||||
if [ -n "$changelog_mtime" ] && [ -n "$session_mtime" ] &&
|
||||
[ "$session_mtime" -gt "$changelog_mtime" ] 2>/dev/null; then
|
||||
comparison_file=$session_marker
|
||||
fi
|
||||
fi
|
||||
|
||||
newer_file=''
|
||||
while IFS= read -r candidate; do
|
||||
newer_file=$candidate
|
||||
break
|
||||
done < <(
|
||||
find "$repo_root" \
|
||||
\( -type d \( \
|
||||
-name '.git' -o \
|
||||
-name '.cursor' -o \
|
||||
-name '.godot' -o \
|
||||
-name 'node_modules' -o \
|
||||
-name '__pycache__' -o \
|
||||
-name 'venv' -o \
|
||||
-name '.venv' -o \
|
||||
-name 'dist' -o \
|
||||
-name 'build' -o \
|
||||
-name 'out' -o \
|
||||
-name 'bin' -o \
|
||||
-name 'obj' -o \
|
||||
-name 'export' -o \
|
||||
-name 'exports' -o \
|
||||
-name 'coverage' -o \
|
||||
-name 'tmp' -o \
|
||||
-name 'temp' \
|
||||
\) -prune \) -o \
|
||||
\( -type f \( \
|
||||
-iname 'project.godot' -o \
|
||||
-iname '*.gd' -o \
|
||||
-iname '*.tscn' -o \
|
||||
-iname '*.tres' -o \
|
||||
-iname '*.res' -o \
|
||||
-iname '*.gdshader' -o \
|
||||
-iname '*.cfg' -o \
|
||||
-iname '*.json' -o \
|
||||
-iname '*.cs' -o \
|
||||
-iname '*.csproj' -o \
|
||||
-iname '*.py' -o \
|
||||
-iname '*.ps1' -o \
|
||||
-iname '*.sh' \
|
||||
\) -newer "$comparison_file" -print \) 2>/dev/null
|
||||
)
|
||||
|
||||
if [ -z "$newer_file" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
relative_file=${newer_file#"$repo_root"/}
|
||||
message="[Hook] Godot source/config is newer than the changelog (e.g. $relative_file). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
|
||||
|
||||
python_cmd=''
|
||||
for candidate in python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 &&
|
||||
@@ -107,24 +53,111 @@ for candidate in python3 python; do
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$python_cmd" ]; then
|
||||
result=$(
|
||||
printf '%s' "$message" | "$python_cmd" -c '
|
||||
if [ -z "$python_cmd" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
result=$(
|
||||
"$python_cmd" - "$repo_root" "$changelog" "$session_marker" "$scan_config" <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
print(json.dumps(
|
||||
{"followup_message": sys.stdin.read()},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
))
|
||||
' 2>/dev/null
|
||||
) || result='{}'
|
||||
if [ -n "$result" ]; then
|
||||
printf '%s\n' "$result"
|
||||
else
|
||||
emit_empty
|
||||
fi
|
||||
repo_root = Path(sys.argv[1]).resolve()
|
||||
changelog = Path(sys.argv[2])
|
||||
session_marker = Path(sys.argv[3])
|
||||
scan_config = Path(sys.argv[4])
|
||||
|
||||
def emit(value):
|
||||
print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
try:
|
||||
config = json.loads(scan_config.read_text(encoding="utf-8"))
|
||||
if config.get("schema_version") != 1:
|
||||
raise ValueError("unsupported schema")
|
||||
names = {
|
||||
value.casefold()
|
||||
for value in config.get("included_names", [])
|
||||
if isinstance(value, str) and value
|
||||
}
|
||||
extensions = {
|
||||
value.casefold()
|
||||
for value in config.get("included_extensions", [])
|
||||
if isinstance(value, str) and value
|
||||
}
|
||||
excluded = {
|
||||
value.casefold()
|
||||
for value in config.get("excluded_directories", [])
|
||||
if isinstance(value, str) and value
|
||||
}
|
||||
if not names and not extensions:
|
||||
emit({})
|
||||
raise SystemExit(0)
|
||||
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(repo_root), "status", "--porcelain=v1",
|
||||
"--untracked-files=all", "--"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if status.returncode != 0 or not status.stdout.strip():
|
||||
emit({})
|
||||
raise SystemExit(0)
|
||||
|
||||
comparison_mtime = max(
|
||||
changelog.stat().st_mtime,
|
||||
session_marker.stat().st_mtime,
|
||||
)
|
||||
for current_root, directory_names, file_names in os.walk(repo_root):
|
||||
directory_names[:] = [
|
||||
name for name in directory_names if name.casefold() not in excluded
|
||||
]
|
||||
current = Path(current_root)
|
||||
for file_name in file_names:
|
||||
candidate = current / file_name
|
||||
if (
|
||||
file_name.casefold() not in names
|
||||
and candidate.suffix.casefold() not in extensions
|
||||
):
|
||||
continue
|
||||
try:
|
||||
if candidate.stat().st_mtime <= comparison_mtime:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
relative = candidate.relative_to(repo_root).as_posix()
|
||||
path_status = subprocess.run(
|
||||
["git", "-C", str(repo_root), "status", "--porcelain=v1",
|
||||
"--untracked-files=all", "--", relative],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if path_status.returncode == 0 and path_status.stdout.strip():
|
||||
message = (
|
||||
"[Hook] Project source/config has an uncommitted change newer "
|
||||
f"than this session and the changelog (e.g. {relative}). "
|
||||
"Run dev-changelog Skill operation A now: add a fragment under "
|
||||
".cursor/changelog/entries/ and rebuild the changelog views."
|
||||
)
|
||||
emit({"followup_message": message})
|
||||
raise SystemExit(0)
|
||||
emit({})
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
emit({})
|
||||
PY
|
||||
) || result='{}'
|
||||
|
||||
if [ -n "$result" ]; then
|
||||
printf '%s\n' "$result"
|
||||
else
|
||||
emit_empty
|
||||
fi
|
||||
|
||||
297
.cursor/hooks/tests/test_check_changelog.py
Normal file
297
.cursor/hooks/tests/test_check_changelog.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
HOOKS_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CheckChangelogHookTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.implementations: list[tuple[str, list[str], str]] = []
|
||||
|
||||
powershell = shutil.which("powershell") or shutil.which("pwsh")
|
||||
if powershell:
|
||||
cls.implementations.append(
|
||||
(
|
||||
"powershell",
|
||||
[
|
||||
powershell,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
],
|
||||
"check-changelog.ps1",
|
||||
)
|
||||
)
|
||||
|
||||
bash_candidates: list[Path] = []
|
||||
bash_from_path = shutil.which("bash")
|
||||
if bash_from_path:
|
||||
bash_candidates.append(Path(bash_from_path))
|
||||
git_from_path = shutil.which("git")
|
||||
if git_from_path:
|
||||
bash_candidates.append(Path(git_from_path).resolve().parent.parent / "bin" / "bash.exe")
|
||||
|
||||
bash: Path | None = None
|
||||
for candidate in dict.fromkeys(bash_candidates):
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
bash_probe = subprocess.run(
|
||||
[str(candidate), "--version"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
if bash_probe.returncode == 0:
|
||||
bash = candidate
|
||||
break
|
||||
if bash:
|
||||
cls.implementations.append(("bash", [str(bash)], "check-changelog.sh"))
|
||||
|
||||
if not cls.implementations:
|
||||
raise unittest.SkipTest("PowerShell 和 Bash 均不可用")
|
||||
|
||||
def _create_fixture(self, script_name: str) -> tuple[tempfile.TemporaryDirectory[str], Path]:
|
||||
temporary_directory = tempfile.TemporaryDirectory()
|
||||
root = Path(temporary_directory.name)
|
||||
hooks_dir = root / ".cursor" / "hooks"
|
||||
changelog_dir = root / ".cursor" / "changelog"
|
||||
hooks_dir.mkdir(parents=True)
|
||||
changelog_dir.mkdir(parents=True)
|
||||
|
||||
shutil.copy2(HOOKS_DIR / script_name, hooks_dir / script_name)
|
||||
(hooks_dir / "changelog-scan.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"project_type": "test",
|
||||
"display_name": "Test",
|
||||
"included_names": [],
|
||||
"included_extensions": [".json"],
|
||||
"excluded_directories": [".git", ".cursor"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(changelog_dir / "changelog-headlines.md").write_text(
|
||||
"# Dev Changelog — Headlines\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(root / ".gitignore").write_text(
|
||||
".cursor/changelog/.session-start\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(root / "config.json").write_text('{"value": 1}\n', encoding="utf-8", newline="\n")
|
||||
(root / "player.gd").write_text("extends Node\n", encoding="utf-8", newline="\n")
|
||||
(root / "player.luau").write_text(
|
||||
"--!strict\nreturn {}\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
self._git(root, "init", "-q")
|
||||
self._git(root, "config", "user.email", "hook-test@example.invalid")
|
||||
self._git(root, "config", "user.name", "Hook Test")
|
||||
self._git(root, "add", ".")
|
||||
self._git(root, "commit", "-q", "-m", "fixture")
|
||||
return temporary_directory, root
|
||||
|
||||
def _git(self, root: Path, *arguments: str) -> None:
|
||||
subprocess.run(
|
||||
["git", "-C", str(root), *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _run_hook(self, command_prefix: list[str], root: Path, script_name: str) -> dict[str, str]:
|
||||
completed = subprocess.run(
|
||||
[*command_prefix, str(root / ".cursor" / "hooks" / script_name)],
|
||||
input="{}",
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=30,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
def _write_scan_config(self, root: Path, extensions: list[str]) -> None:
|
||||
path = root / ".cursor" / "hooks" / "changelog-scan.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"project_type": "test",
|
||||
"display_name": "Test",
|
||||
"included_names": [],
|
||||
"included_extensions": extensions,
|
||||
"excluded_directories": [".git", ".cursor"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
def _set_times(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
source_after_session: bool,
|
||||
source_name: str = "config.json",
|
||||
) -> None:
|
||||
baseline = int(time.time()) - 20
|
||||
changelog = root / ".cursor" / "changelog" / "changelog-headlines.md"
|
||||
session_marker = root / ".cursor" / "changelog" / ".session-start"
|
||||
source = root / source_name
|
||||
|
||||
session_marker.touch()
|
||||
os.utime(changelog, (baseline, baseline))
|
||||
os.utime(session_marker, (baseline + 5, baseline + 5))
|
||||
source_time = baseline + 10 if source_after_session else baseline + 2
|
||||
os.utime(source, (source_time, source_time))
|
||||
|
||||
def test_missing_session_marker_returns_empty(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
source = root / "config.json"
|
||||
future = int(time.time()) + 5
|
||||
os.utime(source, (future, future))
|
||||
|
||||
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
|
||||
|
||||
def test_clean_worktree_with_newer_mtime_returns_empty(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self._set_times(root, source_after_session=True)
|
||||
|
||||
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
|
||||
|
||||
def test_uncommitted_change_newer_than_session_returns_followup(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
(root / "config.json").write_text(
|
||||
'{"value": 2}\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._set_times(root, source_after_session=True)
|
||||
|
||||
result = self._run_hook(command_prefix, root, script_name)
|
||||
|
||||
self.assertIn("followup_message", result)
|
||||
self.assertIn("config.json", result["followup_message"])
|
||||
|
||||
def test_change_older_than_session_returns_empty(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
(root / "config.json").write_text(
|
||||
'{"value": 2}\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._set_times(root, source_after_session=False)
|
||||
|
||||
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
|
||||
|
||||
def test_empty_profile_never_returns_followup(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self._write_scan_config(root, [])
|
||||
(root / "config.json").write_text(
|
||||
'{"value": 2}\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._set_times(root, source_after_session=True)
|
||||
|
||||
self.assertEqual(self._run_hook(command_prefix, root, script_name), {})
|
||||
|
||||
def test_godot_profile_matches_gd_but_not_unlisted_json(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self._write_scan_config(root, [".gd"])
|
||||
(root / "config.json").write_text(
|
||||
'{"value": 2}\n',
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
(root / "player.gd").write_text(
|
||||
"extends Node2D\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._set_times(
|
||||
root,
|
||||
source_after_session=True,
|
||||
source_name="player.gd",
|
||||
)
|
||||
|
||||
result = self._run_hook(command_prefix, root, script_name)
|
||||
|
||||
self.assertIn("followup_message", result)
|
||||
self.assertIn("player.gd", result["followup_message"])
|
||||
|
||||
def test_roblox_profile_matches_luau(self) -> None:
|
||||
for name, command_prefix, script_name in self.implementations:
|
||||
with self.subTest(implementation=name):
|
||||
temporary_directory, root = self._create_fixture(script_name)
|
||||
self.addCleanup(temporary_directory.cleanup)
|
||||
self._write_scan_config(root, [".luau"])
|
||||
(root / "player.luau").write_text(
|
||||
"--!strict\nreturn { value = 2 }\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._set_times(
|
||||
root,
|
||||
source_after_session=True,
|
||||
source_name="player.luau",
|
||||
)
|
||||
|
||||
result = self._run_hook(command_prefix, root, script_name)
|
||||
|
||||
self.assertIn("followup_message", result)
|
||||
self.assertIn("player.luau", result["followup_message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user