fix(project-tool): address independent review of init + staleness

Remediation after an independent review of the two feature commits.

pic init:
- Fix the headline `pic init --dir <new-dir>` flow: slug derivation used
  `canonicalize()`, which fails on a not-yet-created directory → empty
  slug → bail. Derive from the path's own final component first, falling
  back to canonicalize only for `.`. Add a test that exercised the gap
  (the existing tests all passed an explicit slug).
- `ensure_gitignored` no longer overwrites an existing-but-unreadable
  `.gitignore` (only a genuinely-absent file is treated as empty).

Bound-plan staleness:
- Token trigger coverage now mirrors the diff exactly via
  `current_trigger_identity`: dead-letter triggers (which the diff
  ignores and the manifest can't represent) and the currently-inert
  `enabled` flag are no longer hashed, so an out-of-band dead-letter
  change can't force a spurious `--force`. Add a test.
- Correct two overclaiming comments: the check shares the diff's
  pool-read apply-vs-interactive-write window (it is not tx-scoped), and
  the token deliberately mirrors the diff's inputs.
- `.picloud/` self-ignores via a `*` `.gitignore` written alongside the
  plan token, so projects created with `pic pull`/`plan` (not just
  `init`) keep it out of git.
- `clear_plan` is now app-scoped: it won't discard a token recorded for a
  different app sharing the directory.

Tested: manager-core lib 364 + cli bins 31 + 14 project-tool journeys
green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 22:07:31 +02:00
parent be5df06a48
commit 627996cde7
5 changed files with 111 additions and 16 deletions

View File

@@ -52,7 +52,7 @@ pub async fn run(
expected_token.as_deref(),
)
.await?;
crate::linkstate::clear_plan(base_dir);
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
let mut block = KvBlock::new();
block

View File

@@ -147,10 +147,18 @@ fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
}
return Ok(s.to_string());
}
// Derive from the directory's own name. Use the path as given first —
// `canonicalize` requires the dir to already exist, which breaks the
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
// only when the path has no final component of its own (e.g. `.`).
let base = dir
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.or_else(|| {
dir.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
})
.unwrap_or_default();
let derived = slugify(&base);
if !is_valid_slug(&derived) {
@@ -210,7 +218,13 @@ fn title_from_slug(slug: &str) -> String {
/// git still gets a correct `.gitignore` for when it's initialized.
fn ensure_gitignored(dir: &Path) -> Result<()> {
let path = dir.join(".gitignore");
let existing = fs::read_to_string(&path).unwrap_or_default();
// Only a genuinely-absent file is treated as empty; an existing-but-
// unreadable `.gitignore` must error rather than be silently clobbered.
let existing = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("reading .gitignore"),
};
if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
return Ok(());
}