🐛 fix(tsl): preserve manual files during playbook sync
Limit tsl-playbook publishing to generated bundle paths so existing branch-local files are not removed. Add tests for preserving non-generated files and for creating a clean orphan target branch.
This commit is contained in:
@@ -4,6 +4,9 @@ set -euo pipefail
|
||||
# Build the minimal TSL playbook bundle and publish its *expanded* contents to
|
||||
# the tsl-playbook branch. The branch tree mirrors the bundle root directly
|
||||
# (AGENTS.md, docs/, skills/) — there is no wrapping tsl-playbook/ directory.
|
||||
#
|
||||
# Only the paths produced by the bundle are managed. Any other file that lives
|
||||
# on the branch (e.g. a hand-written README.md) is preserved across syncs.
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$(pwd)}"
|
||||
TARGET_BRANCH="${TARGET_BRANCH:-tsl-playbook}"
|
||||
@@ -30,23 +33,34 @@ trap cleanup EXIT
|
||||
bundle="$build_dir/tsl-playbook"
|
||||
python3 "$BUILD_SCRIPT" --output "$bundle"
|
||||
|
||||
# Check out (or create) the target branch. It is a content-only branch that
|
||||
# shares no history with main, so an orphan branch keeps it clean.
|
||||
# These are the only paths this workflow owns on the branch. Everything else is
|
||||
# left alone, including hand-written files such as README.md.
|
||||
generated_paths=(AGENTS.md docs skills)
|
||||
for path in "${generated_paths[@]}"; do
|
||||
if [ ! -e "$bundle/$path" ]; then
|
||||
echo "ERROR: bundle is missing expected path: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check out (or create) the target branch.
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$TARGET_BRANCH"; then
|
||||
git fetch origin "$TARGET_BRANCH"
|
||||
git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
|
||||
else
|
||||
# A brand-new orphan branch inherits main's index. Clear the index without
|
||||
# deleting source files from the working tree; only generated_paths are staged.
|
||||
git checkout --orphan "$TARGET_BRANCH"
|
||||
git rm -rf --cached --quiet . >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Replace the entire tracked tree with the bundle contents. `git rm` clears
|
||||
# tracked files; then copy the expanded bundle to the repo root.
|
||||
git rm -rf --quiet . >/dev/null 2>&1 || true
|
||||
# Remove only generated paths before copying the freshly built bundle.
|
||||
rm -rf "${generated_paths[@]}"
|
||||
|
||||
# Copy bundle contents (including dotfiles) to the repo root.
|
||||
cp -R "$bundle"/. "$REPO_DIR"/
|
||||
|
||||
git add -A
|
||||
git add -A "${generated_paths[@]}"
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No tsl-playbook changes to publish."
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -7,6 +9,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "build_tsl_playbook.py"
|
||||
SYNC_SCRIPT = ROOT / ".gitea" / "ci" / "sync_tsl_playbook.sh"
|
||||
|
||||
|
||||
class BuildTslPlaybookTests(unittest.TestCase):
|
||||
@@ -58,10 +61,171 @@ class BuildTslPlaybookTests(unittest.TestCase):
|
||||
output_skill = count_files(output / "skills" / "tsl-api-reference")
|
||||
self.assertEqual(output_skill, source_skill)
|
||||
|
||||
def test_sync_script_does_not_remove_entire_target_branch(self):
|
||||
text = SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
self.assertNotRegex(text, r"git rm -rf --quiet\s+\.")
|
||||
self.assertIn("generated_paths=(AGENTS.md docs skills)", text)
|
||||
self.assertIn('git add -A "${generated_paths[@]}"', text)
|
||||
|
||||
def test_sync_preserves_files_outside_generated_paths(self):
|
||||
if shutil.which("bash") is None:
|
||||
self.skipTest("bash is required to run sync_tsl_playbook.sh")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
repo = create_source_repo(Path(tmp_dir))
|
||||
|
||||
git(repo, "checkout", "--orphan", "tsl-playbook")
|
||||
git(repo, "rm", "-rf", ".")
|
||||
(repo / "README.md").write_text(
|
||||
"manual branch note\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
git(repo, "add", "README.md")
|
||||
git(repo, "commit", "-m", "manual target branch note")
|
||||
git(repo, "push", "-u", "origin", "tsl-playbook")
|
||||
|
||||
git(repo, "checkout", "main")
|
||||
run_sync(repo)
|
||||
|
||||
readme = run(
|
||||
["git", "show", "HEAD:README.md"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(readme.returncode, 0, msg=readme.stderr)
|
||||
self.assertEqual(readme.stdout, "manual branch note\n")
|
||||
|
||||
for path in (
|
||||
"AGENTS.md",
|
||||
"docs/tsl/index.md",
|
||||
"skills/tsl-api-reference/SKILL.md",
|
||||
):
|
||||
git(repo, "cat-file", "-e", f"HEAD:{path}")
|
||||
|
||||
def test_sync_creates_new_branch_without_source_files(self):
|
||||
if shutil.which("bash") is None:
|
||||
self.skipTest("bash is required to run sync_tsl_playbook.sh")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
repo = create_source_repo(Path(tmp_dir))
|
||||
|
||||
run_sync(repo)
|
||||
|
||||
for path in (
|
||||
"AGENTS.md",
|
||||
"docs/tsl/index.md",
|
||||
"skills/tsl-api-reference/SKILL.md",
|
||||
):
|
||||
git(repo, "cat-file", "-e", f"HEAD:{path}")
|
||||
|
||||
for path in (
|
||||
".gitea/ci/sync_tsl_playbook.sh",
|
||||
"scripts/build_tsl_playbook.py",
|
||||
"rulesets/tsl/index.md",
|
||||
):
|
||||
result = run(
|
||||
["git", "cat-file", "-e", f"HEAD:{path}"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0, msg=f"{path} leaked")
|
||||
|
||||
|
||||
def create_source_repo(tmp: Path) -> Path:
|
||||
repo = tmp / "repo"
|
||||
remote = tmp / "remote.git"
|
||||
|
||||
run(["git", "init", "--bare", str(remote)])
|
||||
run(["git", "init", str(repo)])
|
||||
git(repo, "checkout", "-b", "main")
|
||||
git(repo, "config", "user.name", "test")
|
||||
git(repo, "config", "user.email", "test@example.invalid")
|
||||
|
||||
copy_required_sources(repo)
|
||||
git(repo, "add", ".")
|
||||
git(repo, "commit", "-m", "initial sources")
|
||||
git(repo, "remote", "add", "origin", "../remote.git")
|
||||
git(repo, "push", "-u", "origin", "main")
|
||||
return repo
|
||||
|
||||
|
||||
def run_sync(repo: Path) -> None:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"REPO_DIR": str(repo),
|
||||
"TARGET_BRANCH": "tsl-playbook",
|
||||
"COMMIT_AUTHOR_NAME": "test",
|
||||
"COMMIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", ".gitea/ci/sync_tsl_playbook.sh"],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr + result.stdout)
|
||||
|
||||
|
||||
def copy_required_sources(repo: Path) -> None:
|
||||
(repo / ".gitea" / "ci").mkdir(parents=True)
|
||||
shutil.copy2(SYNC_SCRIPT, repo / ".gitea" / "ci" / "sync_tsl_playbook.sh")
|
||||
|
||||
(repo / "scripts").mkdir()
|
||||
shutil.copy2(SCRIPT, repo / "scripts" / "build_tsl_playbook.py")
|
||||
|
||||
(repo / "docs" / "tsl").mkdir(parents=True)
|
||||
(repo / "docs" / "tsl" / "index.md").write_text(
|
||||
"# TSL Index\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
skill = repo / "skills" / "tsl-api-reference"
|
||||
(skill / "scripts").mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: tsl-api-reference\n---\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
(skill / "scripts" / "lookup.py").write_text(
|
||||
"print('lookup')\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
(repo / "rulesets" / "tsl").mkdir(parents=True)
|
||||
(repo / "rulesets" / "tsl" / "index.md").write_text(
|
||||
"# TSL Agent Instructions\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
|
||||
|
||||
def count_files(path: Path) -> int:
|
||||
return sum(1 for item in path.rglob("*") if item.is_file())
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return run(["git", *args], cwd=repo)
|
||||
|
||||
|
||||
def run(
|
||||
args: list[str],
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"command failed: {' '.join(args)}\n{result.stderr}{result.stdout}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user