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>
68 lines
2.6 KiB
Rust
68 lines
2.6 KiB
Rust
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
|
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
|
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
|
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
|
//!
|
|
//! All paths are relative to the manifest's directory (the project root).
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const DIR: &str = ".picloud";
|
|
const PLAN_FILE: &str = "plan.json";
|
|
|
|
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlanLink {
|
|
/// App slug the token belongs to — guards against replaying a token from a
|
|
/// different app if the manifest's `slug` changed.
|
|
pub app: String,
|
|
pub state_token: String,
|
|
}
|
|
|
|
fn plan_path(base: &Path) -> PathBuf {
|
|
base.join(DIR).join(PLAN_FILE)
|
|
}
|
|
|
|
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
|
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
|
let dir = base.join(DIR);
|
|
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
|
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
|
|
// dir out of git regardless of the project root's `.gitignore` — so this
|
|
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
|
|
// don't touch the root `.gitignore`).
|
|
let ignore = dir.join(".gitignore");
|
|
if !ignore.exists() {
|
|
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
|
}
|
|
let link = PlanLink {
|
|
app: app.to_string(),
|
|
state_token: state_token.to_string(),
|
|
};
|
|
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
|
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the recorded plan token, if any. Returns `None` when absent or
|
|
/// unreadable (treated as "no prior plan" — never an error).
|
|
#[must_use]
|
|
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
|
let body = fs::read(plan_path(base)).ok()?;
|
|
serde_json::from_slice(&body).ok()
|
|
}
|
|
|
|
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
|
|
/// Called after a successful apply consumes it, so the next apply requires a
|
|
/// fresh plan — without clobbering a token recorded for a different app that
|
|
/// happens to share the directory.
|
|
pub fn clear_plan(base: &Path, app: &str) {
|
|
if read_plan(base).is_some_and(|l| l.app == app) {
|
|
let _ = fs::remove_file(plan_path(base));
|
|
}
|
|
}
|