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

@@ -445,9 +445,13 @@ impl ApplyService {
let current = self.load_current(app_id).await?;
// Bound-plan check (§4.2): if the caller passed the token from a prior
// `pic plan`, refuse when the live state has changed since — computed
// under the apply lock so it reflects what we're about to write. Done
// before any mutation so a stale apply rolls back nothing.
// `pic plan`, refuse when the live state changed since. Computed from
// the same `load_current` snapshot the diff uses, before any mutation
// (so a stale apply rolls back nothing). NOTE: like the diff, this read
// is on the pool, not `&mut *tx` (see the lock comment above), so it
// catches drift between plan and apply but shares that same narrow
// apply-vs-interactive-write window — it is not a substitute for the
// tx-scoped read the follow-up will add.
if let Some(expected) = expected_token {
if state_token(&current) != expected {
return Err(ApplyError::StateMoved);
@@ -1597,9 +1601,13 @@ fn map_trig(e: TriggerRepoError) -> ApplyError {
/// A stable fingerprint of an app's live state, covering exactly what the
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
/// any script edit), route identity + binding/attrs, trigger membership +
/// `enabled`, and secret names. Any out-of-band change to those flips the
/// token, so a `plan`-then-`apply` can detect that the app moved underneath it.
/// any script edit), route identity + binding/attrs, trigger semantic identity
/// (via `current_trigger_identity`, so dead-letter triggers — which the diff
/// ignores — are excluded), and secret names. Any out-of-band change to those
/// flips the token, so `plan`-then-`apply` can detect the app moving underneath.
///
/// Deliberately mirrors the diff's inputs so a mismatch means the *reviewed
/// plan* would now differ — not merely that some unrelated row changed.
///
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
/// per-process seed) so a token stored by `pic plan` still matches on a later
@@ -1631,8 +1639,20 @@ pub fn state_token(current: &CurrentState) -> String {
r.host_param_name.as_deref().unwrap_or(""),
));
}
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
// excludes dead-letter triggers (the diff ignores them) and keys on the
// semantic identity — NOT raw id/enabled. Hashing dead-letter rows or the
// (currently inert) `enabled` flag would force a false refusal over a
// change the manifest can't represent.
let script_name_by_id: HashMap<ScriptId, String> = current
.scripts
.iter()
.map(|s| (s.id, s.name.clone()))
.collect();
for t in &current.triggers {
parts.push(format!("t|{:?}|{}", t.id, t.enabled));
if let Some(id) = current_trigger_identity(t, &script_name_by_id) {
parts.push(format!("t|{id}"));
}
}
for n in &current.secret_names {
parts.push(format!("k|{n}"));
@@ -1997,6 +2017,36 @@ mod tests {
assert_ne!(state_token(&base), state_token(&with_secret));
}
#[test]
fn state_token_ignores_dead_letter_triggers() {
// The diff ignores dead-letter triggers (not manifest-representable),
// so the token must too — otherwise an out-of-band dead-letter change
// would force a spurious `--force`.
let s = script("h", "x");
let sid = s.id;
let base = CurrentState {
scripts: vec![s.clone()],
..CurrentState::default()
};
let with_dl = CurrentState {
scripts: vec![s],
triggers: vec![trig(
sid,
TriggerDetails::DeadLetter {
source_filter: None,
trigger_id_filter: None,
script_id_filter: None,
},
)],
..CurrentState::default()
};
assert_eq!(
state_token(&base),
state_token(&with_dl),
"dead-letter triggers must not affect the token"
);
}
#[test]
fn trigger_diff_create_noop_delete() {
let s = script("h", "x");