fix(init): the skill it writes ignores itself, as a generated directory
A repository that ignores .claude wholesale, as Crafter.Build does, never sees in git the prepush skill init writes, and the question was whether it should: it should not. The skill is generated from the shipped text with init's marker and refreshed by every init, a tracked copy lags the installed binary whenever the shipped skill changes, and every refresh would be a diff in the consumer's own ranges. Jorijn's ruling: every consumer leaves it untracked, one init per checkout. When init owns the skill - wrote or refreshed it - it writes a .gitignore of `*` beside it, as Python's venv and pytest's cache do for theirs: the directory ignores itself, that file included, on every checkout that runs init, whatever the repository's own rules or one machine's say, and nothing of the repository's is edited for it. A copy init wrote that the repository tracks gets the step that untracks it, in the output and in the README's migration. A skill the repository wrote itself is kept and not ignored - the ignore file an earlier init wrote beside it goes - and an ignore file init did not write is kept unless --force; a link there, dangling or not, is refused like the skill's other links unless --force, which replaces it (the old setup's exemption - a link that resolves into the review-gates submodule at the toplevel, not one that merely names it - is the skill directory's and SKILL.md's alone), and nothing is written through it: the links go before anything is written, and one init cannot remove refuses. The procedure's log records the ruling.
This commit is contained in:
parent
e215ad986e
commit
ddf32a8c02
4 changed files with 185 additions and 20 deletions
|
|
@ -804,7 +804,7 @@ inventory unit, against a frozen commit range.)";
|
|||
|
||||
// ---- init: a repository set up for the gates ---------------------------------------------
|
||||
constexpr std::array<OptionSpec, 1> InitOptions = {{
|
||||
{"--force", false, "", "replace a SKILL.md or a link init did not make, and run from your home directory"},
|
||||
{"--force", false, "", "replace a SKILL.md, the .gitignore beside it or a link init did not make, and run from your home directory"},
|
||||
}};
|
||||
// After the skill's frontmatter: what tells a later `init` the file is
|
||||
// its own to refresh.
|
||||
|
|
@ -818,12 +818,15 @@ inventory unit, against a frozen commit range.)";
|
|||
}
|
||||
return std::format("{}{}", marker, skill);
|
||||
}
|
||||
// The first line of the .gitignore init writes beside the skill it
|
||||
// owns: what tells a later `init` that file is its own.
|
||||
constexpr std::string_view IgnoreMarker = "# written by crafter-review init";
|
||||
|
||||
std::int32_t InitCommand(std::span<const std::string_view> args) {
|
||||
constexpr std::string_view Prog = "crafter-review init";
|
||||
const Parsed parsed = ParseOptions(args, InitOptions);
|
||||
if (parsed.help) {
|
||||
std::print("{}", Help(Prog, "set this repository up for the pre-push gates: .review-gates/config.json from the template, the artifact dirs ignored, the prepush skill", InitOptions));
|
||||
std::print("{}", Help(Prog, "set this repository up for the pre-push gates: .review-gates/config.json from the template, the artifact dirs ignored, the prepush skill with the .gitignore that keeps it out of git", InitOptions));
|
||||
return 0;
|
||||
}
|
||||
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, InitOptions), Prog, parsed.error);
|
||||
|
|
@ -846,20 +849,37 @@ inventory unit, against a frozen commit range.)";
|
|||
// The skill's place. The old setup linked .claude/skills/prepush
|
||||
// into the review-gates submodule: the link goes, never written
|
||||
// through, which would change the submodule's tracked file and
|
||||
// fail the next freeze on a dirty tree. Any other link stays
|
||||
// unless --force - checked before anything is written.
|
||||
// fail the next freeze on a dirty tree. Any other link - the
|
||||
// directory, SKILL.md or the .gitignore init writes beside it,
|
||||
// dangling or not - stays unless --force, and nothing is written
|
||||
// through it: checked before anything is written.
|
||||
const fs::path skillDir = cwd / ".claude" / "skills" / "prepush";
|
||||
const fs::path skill = skillDir / "SKILL.md";
|
||||
const fs::path skillIgnore = skillDir / ".gitignore";
|
||||
std::vector<std::pair<fs::path, fs::path>> links;
|
||||
for (const fs::path& link : {skillDir, skill}) {
|
||||
for (const fs::path& link : {skillDir, skill, skillIgnore}) {
|
||||
if (!fs::is_symlink(fs::symlink_status(link, error))) continue;
|
||||
const fs::path target = fs::read_symlink(link, error);
|
||||
if (!force && !target.string().contains("review-gates")) {
|
||||
// The old setup linked the skill's directory or its SKILL.md -
|
||||
// never the .gitignore - into the review-gates submodule at the
|
||||
// toplevel: a link that resolves under it, not one that merely
|
||||
// names it (the pipeline's own .review-gates/ does too).
|
||||
const fs::path resolved = (link.parent_path() / target).lexically_normal();
|
||||
const fs::path submodule = (cwd / "review-gates").lexically_normal();
|
||||
const bool oldSetup = link != skillIgnore && std::mismatch(submodule.begin(), submodule.end(), resolved.begin(), resolved.end()).first == submodule.end();
|
||||
if (!force && !oldSetup) {
|
||||
return refuse(std::format("{} is a link to {}, not into the review-gates submodule: --force replaces it", link.lexically_relative(cwd).string(), target.string()));
|
||||
}
|
||||
links.emplace_back(link, target);
|
||||
}
|
||||
std::println("init: {}", cwd.string());
|
||||
// The links go first, before anything is written: one init cannot
|
||||
// remove refuses, and nothing is written through it.
|
||||
for (const auto& [link, target] : links) {
|
||||
fs::remove(link, error);
|
||||
if (error) return refuse(std::format("cannot remove the link {}: {}", link.lexically_relative(cwd).string(), error.message()));
|
||||
std::println(" removed the link {} -> {}", link.lexically_relative(cwd).string(), target.string());
|
||||
}
|
||||
// The config, from the template, only when there is none. Written
|
||||
// fresh, not copied: the Flatpak's files are read-only.
|
||||
const fs::path config = cwd / ".review-gates" / "config.json";
|
||||
|
|
@ -889,20 +909,45 @@ inventory unit, against a frozen commit range.)";
|
|||
std::println(" added {} to .gitignore", Dyn::Join(added, " and "));
|
||||
}
|
||||
// The skill.
|
||||
for (const auto& [link, target] : links) {
|
||||
fs::remove(link, error);
|
||||
std::println(" removed the link {} -> {}", link.lexically_relative(cwd).string(), target.string());
|
||||
}
|
||||
fs::create_directories(skillDir);
|
||||
const std::optional<std::string> shipped = Platform::ReadFile(Home() / "process" / "skill" / "prepush" / "SKILL.md");
|
||||
if (!shipped) return refuse(std::format("cannot read the skill under {}", Home().string()));
|
||||
const std::optional<std::string> existing = Platform::ReadFile(skill);
|
||||
if (existing && !existing->contains(InitMarker) && !force) {
|
||||
const bool owned = !existing || existing->contains(InitMarker) || force;
|
||||
if (!owned) {
|
||||
std::println(" kept .claude/skills/prepush/SKILL.md (init did not write it: --force replaces it)");
|
||||
} else {
|
||||
std::ofstream(skill, std::ios::binary) << WithMarker(*shipped);
|
||||
std::println(" {} .claude/skills/prepush/SKILL.md", existing ? "refreshed" : "wrote ");
|
||||
}
|
||||
// The skill init owns stays out of git (Jorijn, 2026-11-01: every
|
||||
// consumer leaves it untracked, one init per checkout; generated
|
||||
// and refreshed by every init, a tracked copy lags the installed
|
||||
// binary). Its directory ignores itself, as a generated directory
|
||||
// does - Python's venv, pytest's cache: a .gitignore of `*` beside
|
||||
// the skill, that file included, on every checkout that runs
|
||||
// init, whatever the repository's own rules or one machine's say,
|
||||
// and nothing of the repository's is edited for it. A tracked
|
||||
// copy init wrote gets the step that untracks it. A skill the
|
||||
// repository wrote itself is its own and stays visible: the
|
||||
// ignore file an earlier init wrote beside it goes. An ignore
|
||||
// file init did not write is kept, unless --force.
|
||||
const std::optional<std::string> ignoring = Platform::ReadFile(skillIgnore);
|
||||
const bool initIgnores = ignoring && ignoring->contains(IgnoreMarker);
|
||||
if (owned) {
|
||||
if (ignoring && !initIgnores && !force) {
|
||||
std::println(" kept .claude/skills/prepush/.gitignore (init did not write it: --force replaces it)");
|
||||
} else {
|
||||
std::ofstream(skillIgnore, std::ios::binary) << std::format("{}: the generated /prepush skill stays out of git; `init` refreshes this file while this line is in it\n*\n", IgnoreMarker);
|
||||
std::println(" {} .claude/skills/prepush/.gitignore (the generated skill ignores itself)", ignoring ? "refreshed" : "wrote ");
|
||||
}
|
||||
if (Process::Run({"git", "-C", cwd.string(), "ls-files", "--error-unmatch", ".claude/skills/prepush/SKILL.md"}).returnCode == 0) {
|
||||
std::println(" note .claude/skills/prepush/SKILL.md is tracked - `git rm --cached .claude/skills/prepush/SKILL.md` untracks it (the skill is generated; init refreshes it)");
|
||||
}
|
||||
} else if (initIgnores) {
|
||||
fs::remove(skillIgnore, error);
|
||||
std::println(" removed .claude/skills/prepush/.gitignore (an earlier init's: the skill is the repository's own)");
|
||||
}
|
||||
// What comes next.
|
||||
const Config::Loaded loaded = Config::Load(cwd);
|
||||
const std::vector<std::string> off = Config::LensesOff(loaded.config);
|
||||
|
|
|
|||
Loading…
Reference in a new issue