diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f86c174..064ffa6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "envrelay", - "version": "1.0.2", + "version": "1.0.3", "description": "Move a development environment to a new machine: back up dotfiles, credentials, git repositories, AI coding agent state and installed software into one passphrase-encrypted file, then restore it step by step.", "author": { "name": "FutrixDev", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 301c323..2674f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ jobs: with: python-version: "3.9" - run: python3 -m compileall -q skills/envrelay/scripts + # git_classify.py must run nothing a scanned repository's config names. + - run: python3 tests/git_classify.py -v scripts: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 07f967a..6304e66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,7 +424,7 @@ dependencies = [ [[package]] name = "envrelay" -version = "1.0.2" +version = "1.0.3" dependencies = [ "age", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 399d353..205bd6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "envrelay" -version = "1.0.2" +version = "1.0.3" edition = "2024" rust-version = "1.97.0" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 7f9c17c..11223a2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ No sudo. envrelay.com sends you to the installer in the [latest release](https:/ - puts `envrelay` in `~/.local/bin`, and adds that directory to `PATH` in your shell's rc file if it is not there yet; - puts the skill in `~/.agents/skills/envrelay`, where Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and most other agents look for skills, and links it into `~/.claude/skills` for Claude Code (and into `~/.kiro/skills` and `~/.cline/skills` if you use Kiro or Cline); -- checks for python3 3.9+ and git, which the skill's scripts use. +- checks for python3 3.9+ and git 2.31+, which the skill's scripts use. Then start a new session of your coding agent and say: @@ -62,7 +62,7 @@ Each download of `install.sh` from envrelay.com is recorded: the time, the IP ad ### What it needs - **macOS 11 or later, or Linux**, on x86_64 or arm64. The Linux binaries are static, so any distribution works. On Windows, use WSL. -- **python3 3.9+ and git**, for the skill's scripts. A Mac gets both from Apple's Command Line Tools: if they are missing, the installer opens Apple's installer and you click Install. On Linux, it tells you the package-manager command to run. +- **python3 3.9+ and git 2.31+**, for the skill's scripts. A Mac gets both from Apple's Command Line Tools: if they are missing, the installer opens Apple's installer and you click Install. On Linux, it tells you the package-manager command to run. - **A coding agent that reads Agent Skills**: Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and many more. That is all. The package managers you already use (Homebrew, npm, cargo and so on) matter only when you want the agent to reinstall your software on the new machine, and `age` and `zstd` only for the [escape hatch](#the-escape-hatch). @@ -222,10 +222,11 @@ cargo clippy --all-targets -- -D warnings cargo test cargo build --release sh tests/installer.sh +python3 tests/git_classify.py sh .github/scripts/check-versions.sh ``` -`tests/installer.sh` runs the installer against a release packaged from this checkout, with a throwaway `HOME` per scenario, so it needs the release build first. The installer downloads only from GitHub, so a stand-in for curl, [`tests/stubs/curl`](tests/stubs/curl), serves that release in GitHub's place. `SH=dash sh tests/installer.sh` runs the installer under another shell; CI runs it under sh, dash, bash and zsh. `check-versions.sh` checks that the version is the same everywhere it is written. +`tests/installer.sh` runs the installer against a release packaged from this checkout, with a throwaway `HOME` per scenario, so it needs the release build first. The installer downloads only from GitHub, so a stand-in for curl, [`tests/stubs/curl`](tests/stubs/curl), serves that release in GitHub's place. `SH=dash sh tests/installer.sh` runs the installer under another shell; CI runs it under sh, dash, bash and zsh. `tests/git_classify.py` checks that the repository inventory starts none of the programs a scanned repository's git config names. `check-versions.sh` checks that the version is the same everywhere it is written. ## Releasing diff --git a/README.zh-CN.md b/README.zh-CN.md index 98180b0..81916ba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ curl -fsSL https://envrelay.com/install.sh | sh - 把 `envrelay` 放进 `~/.local/bin`,如果这个目录还不在 `PATH` 里,就在你的 shell 配置文件里加上; - 把 skill 放进 `~/.agents/skills/envrelay`——Codex、Cursor、Gemini CLI、GitHub Copilot、OpenCode 等大多数 agent 都从这里找 skill——再为 Claude Code 链接一份到 `~/.claude/skills`(用 Kiro 或 Cline 的话,也链接到 `~/.kiro/skills`、`~/.cline/skills`); -- 检查 skill 的脚本要用的 python3 3.9+ 和 git 在不在。 +- 检查 skill 的脚本要用的 python3 3.9+ 和 git 2.31+ 在不在。 然后开一个新的 coding agent 会话,对它说: @@ -62,7 +62,7 @@ curl -fsSL https://envrelay.com/install.sh | sh -s -- --dry-run ### 需要什么 - **macOS 11 及以上,或 Linux**,x86_64 或 arm64。Linux 版是静态链接的,任何发行版都能跑。Windows 请用 WSL。 -- **python3 3.9+ 和 git**,skill 的脚本要用。Mac 上这两样都来自苹果的 Command Line Tools:缺的话,安装器会打开苹果的安装程序,你点一下“安装”即可。Linux 上它会告诉你该跑哪条包管理器命令。 +- **python3 3.9+ 和 git 2.31+**,skill 的脚本要用。Mac 上这两样都来自苹果的 Command Line Tools:缺的话,安装器会打开苹果的安装程序,你点一下“安装”即可。Linux 上它会告诉你该跑哪条包管理器命令。 - **一个能读 Agent Skills 的 coding agent**:Claude Code、Codex、Cursor、Gemini CLI、GitHub Copilot、OpenCode 等等。 就这些。你平时用的包管理器(Homebrew、npm、cargo 等)只在你想让 agent 在新机器上把软件装回来时才用得上;`age` 和 `zstd` 只有走[逃生通道](#逃生通道)时才需要。 @@ -222,10 +222,11 @@ cargo clippy --all-targets -- -D warnings cargo test cargo build --release sh tests/installer.sh +python3 tests/git_classify.py sh .github/scripts/check-versions.sh ``` -`tests/installer.sh` 用当前 checkout 打出来的 release 跑安装器,每个场景一个一次性的 `HOME`,所以要先 build release。安装器只从 GitHub 下载,所以由一个替身 curl([`tests/stubs/curl`](tests/stubs/curl))代替 GitHub 提供这个 release。`SH=dash sh tests/installer.sh` 换一个 shell 跑安装器;CI 在 sh、dash、bash、zsh 下各跑一遍。`check-versions.sh` 检查所有写了版本号的地方是否一致。 +`tests/installer.sh` 用当前 checkout 打出来的 release 跑安装器,每个场景一个一次性的 `HOME`,所以要先 build release。安装器只从 GitHub 下载,所以由一个替身 curl([`tests/stubs/curl`](tests/stubs/curl))代替 GitHub 提供这个 release。`SH=dash sh tests/installer.sh` 换一个 shell 跑安装器;CI 在 sh、dash、bash、zsh 下各跑一遍。`tests/git_classify.py` 检查仓库清点不会启动被扫描仓库的 git 配置里写的任何程序。`check-versions.sh` 检查所有写了版本号的地方是否一致。 ## 发版 diff --git a/docs/decisions/028-git-inventory-runs-nothing-a-repository-configures.md b/docs/decisions/028-git-inventory-runs-nothing-a-repository-configures.md new file mode 100644 index 0000000..0cea299 --- /dev/null +++ b/docs/decisions/028-git-inventory-runs-nothing-a-repository-configures.md @@ -0,0 +1,77 @@ +# ADR-028: The git inventory runs nothing a repository's config names + +Date: 2026-09-26 +Status: accepted +Amends: ADR-022 (deterministic mechanics in scripts) + +## Context + +`git_classify.py` walks the home directory and runs read-only git commands in +every repository it finds: `status`, `remote`, `stash list`, `rev-parse`, +`symbolic-ref`, `rev-list`. Read-only is not the same as running nothing. A +repository's own `.git/config` can name programs that git starts during those +reads: + +- `core.fsmonitor`, which `status` asks which files changed; +- a filter driver's `clean` or `process` command, which `status` pipes a file + through when its timestamp changed but its size did not; +- `gpg.program`, which `stash list` calls for every signed stash once + `log.showSignature` is on; +- the same settings in a checked-out submodule, where `status` runs a second + `git status`. + +A repository that came from someone else — a cloned exercise, an unpacked +archive, a directory another tool wrote — is data the scan finds, not something +the user chose to trust. The skill runs the scan before the user has looked at +any of them. + +ClawHub re-scanned v1.0.2 on 2026-09-25 at 15:43 UTC and moved its audit from +Pass to Review. Its one substantive finding (A.I.G T09, high) is this: the +inventory can run code a scanned repository configures. + +## Decision + +- **Every git command in `git_classify.py` runs with the repository's helpers + switched off**: `core.fsmonitor` empty (not `false`, which git before 2.36 + runs as a command), `core.hooksPath` set to `/dev/null`, and + `log.showSignature` off. +- **Filter drivers that only the repository defines are switched off for + `status`**: `clean`, `smudge` and `process` empty, `required` false. The + script lists the repository's config with its scopes, and those of its + checked-out submodules, and switches off each driver defined outside the + system, global and command scopes. A driver the user installed — git lfs + puts itself in the global config — keeps running. +- **The settings travel in `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/ + `GIT_CONFIG_VALUE_n`, not `-c`.** `-c` splits at the first `=`, and a + driver's name may contain one. The environment also reaches the `git status` + that runs inside each submodule. +- **The script needs git 2.31 or newer**, the first with `GIT_CONFIG_COUNT`. + With an older git every repository is `unknown`, with the version in the + detail; no git command runs in the repository first. The skill's + `compatibility` field says so, and `install.sh`, which checks for what the + scripts need, names an older git and the version it found. +- **`tests/git_classify.py` proves each case both ways**: the script leaves the + program unrun, and plain git making the same read runs it. CI runs it on + Linux under Python 3.9 and on macOS. +- This ships as v1.0.3. + +## Consequences + +- **A file a repository-only filter would have cleaned can count as + uncommitted.** That errs towards carrying the repository whole, the safe + direction: at worst a clean repository gets `files` instead of `clone`. +- **Users with git older than 2.31 lose the inventory.** macOS's command line + tools shipped 2.24 and 2.30 in the Xcode 11 and 12 years; Debian 11 has + 2.30. The installer tells them at install time, not at the first backup. +- **The `git` on `PATH` is trusted.** It is the user's own environment, not + something the scanned repository controls. + +## Alternatives rejected + +- **Only switch off `core.fsmonitor`.** It is the obvious one, but filters and + signature checks run from the same commands. +- **Switch off every filter driver, the user's too.** Simpler, but every git + lfs repository would read as fully uncommitted and be carried as files. +- **Stop running `git status` and read the index directly.** It would take a + reimplementation of git's change detection, and still have to decide what a + filter would have done. diff --git a/docs/publishing.md b/docs/publishing.md index 9da78ef..becc8b7 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -274,11 +274,11 @@ To publish: ``` ```bash - clawhub skill publish ./skills/envrelay --owner futrixdev --name EnvRelay --version 1.0.0 --changelog "First release." --categories operations,development --topics backup,restore,migration,dotfiles,developer-environment --source-repo FutrixDev/envrelay-skill --source-commit "$(git rev-parse HEAD)" --source-ref v1.0.0 --source-path skills/envrelay --dry-run + clawhub skill publish ./skills/envrelay --owner futrixdev --name "EnvRelay: Backup, Restore & Migrate Dev Environments" --version 1.0.0 --changelog "First release." --categories operations,development,productivity --topics backup,migration,dotfiles,developer-environment,new-machine-setup --source-repo FutrixDev/envrelay-skill --source-commit "$(git rev-parse HEAD)" --source-ref v1.0.0 --source-path skills/envrelay --dry-run ``` ```bash - clawhub skill publish ./skills/envrelay --owner futrixdev --name EnvRelay --version 1.0.0 --changelog "First release." --categories operations,development --topics backup,restore,migration,dotfiles,developer-environment --source-repo FutrixDev/envrelay-skill --source-commit "$(git rev-parse HEAD)" --source-ref v1.0.0 --source-path skills/envrelay + clawhub skill publish ./skills/envrelay --owner futrixdev --name "EnvRelay: Backup, Restore & Migrate Dev Environments" --version 1.0.0 --changelog "First release." --categories operations,development,productivity --topics backup,migration,dotfiles,developer-environment,new-machine-setup --source-repo FutrixDev/envrelay-skill --source-commit "$(git rev-parse HEAD)" --source-ref v1.0.0 --source-path skills/envrelay ``` For a later release, change the tag (in `git checkout` and `--source-ref`), @@ -288,6 +288,17 @@ To publish: "Version not found" and `latest` stayed at 1.0.1. v1.0.2 was audited and public within seven minutes of the upload. + ClawHub's search ranks by the name, categories and topics, which only a + new version can change. A query ranks a skill highest when every word is a + word of its slug or name, then when every word begins a word of the name, + then when every word begins a category or topic, and only then when every + word begins a word of the summary (SKILL.md's `description`). Matching is + by prefix, not by stem: "migrating" does not find "migrate". Within a rank, + closeness in meaning comes first, then installs. So the name carries + backup, restore and migrate, and the topics carry what people type that the + name does not. ClawHub takes at most five topics, and at most three + categories from its own list. + 4. Check the listing, then the version's security audit. They are separate verdicts: moderation decides whether the listing is public, and it can be public (`clean`) while the audit on its page says Review, which asks users @@ -302,10 +313,15 @@ To publish: ``` The audit is `version.security`. v1.0.0 and v1.0.1 read `suspicious`, - shown as Review, for the download override that ADR-027 removed; v1.0.2 - reads `clean`, shown as Pass. The latest version's audit, with any - findings, is on - . + shown as Review, for the download override that ADR-027 removed. v1.0.2 + first read `clean`, shown as Pass; a re-scan on 2026-09-25 at 15:43 UTC + moved it to Review, for the git config that ADR-028 switches off. A + verdict can change after publishing, so check it again before calling a + release clean. The latest version's audit, with any findings, is on + . Most of its + static findings are the skill's subject, not a flaw in it: it names + credential and agent-state paths because it backs them up, and `install.sh` + downloads a binary because that is how it installs one. ```bash openclaw skills verify @futrixdev/envrelay diff --git a/plugin.json b/plugin.json index 74dda70..fc05274 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "envrelay", - "version": "1.0.2", + "version": "1.0.3", "description": "Move a development environment to a new machine: back up dotfiles, credentials, git repositories, AI coding agent state and installed software into one passphrase-encrypted file, then restore it step by step.", "author": { "name": "FutrixDev", diff --git a/skills/envrelay/SKILL.md b/skills/envrelay/SKILL.md index c45ded4..626dba6 100644 --- a/skills/envrelay/SKILL.md +++ b/skills/envrelay/SKILL.md @@ -1,10 +1,10 @@ --- name: envrelay -description: Use when backing up, restoring, or migrating a development environment between machines - dotfiles, config, SSH/cloud credentials, git repositories, AI coding agent state (Claude Code, Codex, Cursor and the rest), and installed software - or when working with an .envrelay backup file. Covers what to carry, what to leave behind and rebuild, how to record it in a manifest, and how to replay it on the new machine (macOS and Linux). -compatibility: Needs macOS or Linux with a terminal the user can type into, python3 3.9 or newer, git, and the envrelay binary, which the skill's own installer adds once the user agrees. +description: Use when backing up or restoring a development environment, or when asked to migrate one to a new machine - a new Mac, laptop or Linux box. Carries dotfiles, config, SSH keys and cloud credentials, git repositories, AI coding agent state (Claude Code, Codex, Cursor and the rest), and installed software; also use when working with an .envrelay backup file. Covers what to carry, what to leave behind and rebuild, how to record it in a manifest, and how to replay it on the new machine (macOS and Linux). Once the user agrees, it installs the envrelay binary, which encrypts the backup, into ~/.local/bin. +compatibility: Needs macOS or Linux with a terminal the user can type into, python3 3.9 or newer, git 2.31 or newer, and the envrelay binary, which the skill's own installer adds once the user agrees. allowed-tools: Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/*) metadata: - version: "1.0.2" + version: "1.0.3" clawdis: requires: bins: @@ -60,7 +60,7 @@ yours. | Script | Does | Never does | |---|---|---| | `stage_copy.py SRC DEST [--exclude N…] [--hash] [--hash-prefix P]` | Copies one entry into staging: permissions and symlinks (dangling included) preserved, excludes applied, unreadable and special files *skipped and recorded* instead of sinking the copy, per-file SHA-256 with `--hash`, keys prefixed with `--hash-prefix`. Cleans up its own partial copy on failure | Follow symlinks, read file contents, write outside DEST | -| `git_classify.py PATH… \| --scan ROOT` | Finds repositories and reports facts: the five-state classification, remotes, branch, head, uncommitted/unpushed/stash counts | Choose a strategy, run any mutating git command | +| `git_classify.py PATH… \| --scan ROOT` | Finds repositories and reports facts: the five-state classification, remotes, branch, head, uncommitted/unpushed/stash counts | Choose a strategy, run any mutating git command, let a repository's config start a program | | `sw_inventory.py [--apps] [--diff MANIFEST]` | One-shot batch enumeration of every present package manager, GUI apps by bundle id, and the manifest-vs-machine diff | Install, uninstall, resolve names against registries | | `restore_ledger.py LEDGER CMD…` | Per-item restore state: `init` seeds one `pending` line per manifest entry, then `set`, `list`, `report`. Survives an interrupted restore so you resume instead of re-deriving | Change anything outside its own ledger file | | `agent_inventory.py [--agents …] [--no-sizes] [--recent-days N] [--diff MANIFEST]` | One pass over every AI coding agent on the machine: which are installed and at what version, their config/extension/session/credential paths with sizes, their extensions by name, their MCP servers with **key names only**, and what no rule accounts for | Read a session transcript, read a credential file, print any secret value | diff --git a/skills/envrelay/install.sh b/skills/envrelay/install.sh index c7d2416..47b0593 100755 --- a/skills/envrelay/install.sh +++ b/skills/envrelay/install.sh @@ -16,7 +16,7 @@ # CLI, OpenCode, Copilot and most other agents read, and symlinks it into # ~/.claude/skills (and ~/.kiro/skills, ~/.cline/skills when present); # 4. adds the bin directory to PATH in your shell's rc file if it is missing; -# 5. checks for python3 3.9+ and git, which the skill's scripts use. +# 5. checks for python3 3.9+ and git 2.31+, which the skill's scripts use. # # It never uses sudo, never touches a backup, and never replaces a skill # directory or link it did not create. Why it exists, and why it is not one of @@ -395,7 +395,12 @@ check_tools() { elif ! PYTHONDONTWRITEBYTECODE=1 python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' /dev/null 2>&1; then missing="python3 3.9 or newer (this one is $(python3 --version 2>&1 /dev/null | awk '{ split($3, v, "."); ok = v[1] + 0 > 2 || (v[1] + 0 == 2 && v[2] + 0 >= 31) } END { exit !ok }'; then + # git_classify.py hands git its settings in GIT_CONFIG_COUNT (ADR-028). + missing="${missing:+$missing and }git 2.31 or newer (this one is $(git --version 2>&1 = 2 + } + gitlinks = { + entry.partition("\t")[2] + for entry in git(repo, "ls-files", "--stage", "-z", timeout=timeout).split("\0") + if entry.startswith("160000 ") + } + for gitlink in sorted(gitlinks): + sub = os.path.join(repo, gitlink) + if os.path.realpath(sub) in seen or not os.path.lexists(os.path.join(sub, ".git")): + continue + try: + drivers |= repo_filter_drivers(sub, timeout, seen) + except GitError as error: + raise GitError(f"submodule {gitlink}: {error}") + return drivers + + def classify(path, timeout): report = {"path": path} if not os.path.lexists(os.path.join(path, ".git")): @@ -69,8 +144,14 @@ def classify(path, timeout): return report try: + require_git_2_31(timeout) git(path, "rev-parse", "--git-dir", timeout=timeout) - status = git(path, "status", "--porcelain", timeout=timeout) + filters_off = tuple( + (f"filter.{driver}.{key}", value) + for driver in sorted(repo_filter_drivers(path, timeout)) + for key, value in FILTER_OFF + ) + status = git(path, "status", "--porcelain", timeout=timeout, config=REPO_HELPERS_OFF + filters_off) except GitUnavailable as error: report["state"] = "unknown" report["detail"] = str(error) diff --git a/tests/git_classify.py b/tests/git_classify.py new file mode 100755 index 0000000..87ec9fd --- /dev/null +++ b/tests/git_classify.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""git_classify.py runs nothing that a repository's own config names. + +Each test builds a repository whose config names a program for git to run +during git_classify.py's reads, checks that git_classify.py leaves it unrun, +then makes the same read with plain git to show the program was there to run. + + python3 tests/git_classify.py [-v] +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "skills", "envrelay", "scripts", "git_classify.py" +) + + +class RepositoryHelpers(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = os.path.realpath(tmp.name) + self.marks = os.path.join(self.root, "marks") + self.global_config = os.path.join(self.root, "gitconfig") + with open(self.global_config, "w") as f: + f.write('[init]\n\tdefaultBranch = main\n[protocol "file"]\n\tallow = always\n') + self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env.update( + HOME=self.root, + GIT_CONFIG_GLOBAL=self.global_config, + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="test", + GIT_AUTHOR_EMAIL="test@example.com", + GIT_COMMITTER_NAME="test", + GIT_COMMITTER_EMAIL="test@example.com", + ) + # Each records its name in self.marks when it runs. The filter passes + # the content through, as a clean filter must. + self.filter = self.program("filter", "cat") + self.hook = self.program("hook", "exit 1") + self.mtime = 1_600_000_000 + + def program(self, name, tail): + os.makedirs(os.path.join(self.root, "programs"), exist_ok=True) + path = os.path.join(self.root, "programs", name) + with open(path, "w") as f: + f.write(f'#!/bin/sh\necho {name} >> "{self.marks}"\n{tail}\n') + os.chmod(path, 0o755) + return path + + def run_quiet(self, *argv): + return subprocess.run( + argv, env=self.env, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=True + ).stdout + + def git(self, repo, *argv): + return self.run_quiet("git", "-C", repo, *argv) + + def repo(self, name, files): + path = os.path.join(self.root, name) + self.run_quiet("git", "init", "-q", path) + for file, content in files.items(): + with open(os.path.join(path, file), "w") as f: + f.write(content) + self.git(path, "add", ".") + self.git(path, "commit", "-qm", "files") + return path + + def touch(self, path): + """Changes the mtime but not the size, so git status reads the file again.""" + self.mtime += 60 + os.utime(path, (self.mtime, self.mtime)) + + def ran(self): + try: + with open(self.marks) as f: + names = f.read().split() + except FileNotFoundError: + return [] + os.remove(self.marks) + return names + + def classify(self, path): + out = self.run_quiet(sys.executable, SCRIPT, path) + (report,) = json.loads(out)["repos"] + return report + + def assert_not_run(self, repo, plain, touch=None): + """git_classify.py runs nothing; plain git, making the same read, does.""" + if touch: + self.touch(touch) + report = self.classify(repo) + self.assertEqual(self.ran(), [], "git_classify.py ran a program the repository named") + if touch: + self.touch(touch) + self.git(repo, *plain) + self.assertNotEqual(self.ran(), [], "plain git did not run it either, so this proves nothing") + return report + + def test_fsmonitor(self): + repo = self.repo("fsmonitor", {"a": "a\n"}) + self.git(repo, "config", "core.fsmonitor", self.hook) + report = self.assert_not_run(repo, ["status", "--porcelain"]) + self.assertEqual(report["state"], "complete-repository") + + def test_filter_driver(self): + repo = self.repo("filter", {".gitattributes": "* filter=own\n", "a": "a\n"}) + self.git(repo, "config", "filter.own.clean", self.filter) + self.git(repo, "config", "filter.own.required", "true") + report = self.assert_not_run(repo, ["status", "--porcelain"], touch=os.path.join(repo, "a")) + self.assertEqual((report["state"], report["uncommitted"]), ("complete-repository", 0)) + + def test_filter_driver_named_with_equals(self): + # git -c would split "filter.a=b.clean=" at the first "=". + repo = self.repo("equals", {".gitattributes": "* filter=a=b\n", "a": "a\n"}) + self.git(repo, "config", "filter.a=b.clean", self.filter) + report = self.assert_not_run(repo, ["status", "--porcelain"], touch=os.path.join(repo, "a")) + self.assertEqual(report["state"], "complete-repository") + + def test_filter_driver_in_a_submodule(self): + source = self.repo("source", {".gitattributes": "* filter=own\n", "a": "a\n"}) + repo = self.repo("outer", {"b": "b\n"}) + self.git(repo, "submodule", "add", "-q", source, "sub") + self.git(repo, "commit", "-qm", "sub") + sub = os.path.join(repo, "sub") + self.git(sub, "config", "filter.own.clean", self.filter) + self.git(sub, "config", "filter.own.required", "true") + report = self.assert_not_run(repo, ["status", "--porcelain"], touch=os.path.join(sub, "a")) + self.assertEqual((report["state"], report["uncommitted"]), ("complete-repository", 0)) + + def test_signed_stash(self): + repo = self.repo("stash", {"a": "a\n"}) + tree = self.git(repo, "rev-parse", "HEAD^{tree}").strip() + head = self.git(repo, "rev-parse", "HEAD").strip() + commit = ( + f"tree {tree}\nparent {head}\n" + "author test 1600000000 +0000\n" + "committer test 1600000000 +0000\n" + "gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEz\n -----END PGP SIGNATURE-----\n" + "\nWIP on main\n" + ) + stash = subprocess.run( + ["git", "-C", repo, "hash-object", "-t", "commit", "-w", "--stdin"], + env=self.env, input=commit, capture_output=True, text=True, check=True, + ).stdout.strip() + self.git(repo, "update-ref", "--create-reflog", "-m", "WIP on main", "refs/stash", stash) + self.git(repo, "config", "log.showSignature", "true") + self.git(repo, "config", "gpg.program", self.hook) + report = self.assert_not_run(repo, ["stash", "list"]) + self.assertEqual((report["state"], report["stashes"]), ("complete-repository", 1)) + + def test_filter_driver_from_the_users_config_still_runs(self): + # The git lfs case: the user's own driver, used by the repository. + with open(self.global_config, "a") as f: + f.write(f'[filter "users"]\n\tclean = {self.filter}\n') + repo = self.repo("users", {".gitattributes": "* filter=users\n", "a": "a\n"}) + self.ran() + self.touch(os.path.join(repo, "a")) + report = self.classify(repo) + self.assertIn("filter", self.ran()) + self.assertEqual((report["state"], report["uncommitted"]), ("complete-repository", 0)) + + def test_git_older_than_2_31(self): + # Such a git has no GIT_CONFIG_COUNT, so none of the settings would + # reach it; this one also has no --show-scope, which arrived in 2.26. + repo = self.repo("old", {"a": "a\n"}) + self.git(repo, "config", "core.fsmonitor", self.hook) + bin_dir = os.path.join(self.root, "bin") + os.mkdir(bin_dir) + with open(os.path.join(bin_dir, "git"), "w") as f: + f.write( + "#!/bin/sh\n" + 'case "$*" in *version*) echo "git version 2.24.3 (Apple Git-128)"; exit 0;; esac\n' + "unset GIT_CONFIG_COUNT\n" + f'exec "{shutil.which("git")}" "$@"\n' + ) + os.chmod(os.path.join(bin_dir, "git"), 0o755) + self.env["PATH"] = bin_dir + os.pathsep + self.env["PATH"] + report = self.classify(repo) + self.assertEqual(self.ran(), [], "git_classify.py ran a program the repository named") + self.assertEqual(report["state"], "unknown") + self.assertEqual(report["detail"], "git version 2.24.3 (Apple Git-128): git 2.31 or newer is needed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer.sh b/tests/installer.sh index 482fafc..5446823 100755 --- a/tests/installer.sh +++ b/tests/installer.sh @@ -254,6 +254,15 @@ check "shows where the skill would go" said "would install the skill in $skill" check "shows the link it would make" said "would link $link" check "shows the PATH change" said "would add $h/.local/bin to PATH in $h/.zshrc" +scenario old-git +mkdir "$work/old-git.bin" +printf '#!/bin/sh\necho "git version 2.30.1 (Apple Git-130)"\n' >"$work/old-git.bin/git" +chmod +x "$work/old-git.bin/git" +test_path=$work/old-git.bin:$test_path +run_installer +check "exits 0" status_is 0 +check "names the git the scripts need" said "need git 2.31 or newer (this one is git version 2.30.1 (Apple Git-130))" + scenario uninstall mkdir -p "$h/.kiro" "$h/.cline/skills" "$h/elsewhere" ln -s "$h/elsewhere" "$h/.cline/skills/envrelay"