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:
@@ -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
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ fn plan_path(base: &Path) -> PathBuf {
|
||||
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(),
|
||||
@@ -48,8 +56,12 @@ pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Remove the recorded plan token (best-effort). Called after a successful
|
||||
/// apply consumes it, so the next apply requires a fresh plan.
|
||||
pub fn clear_plan(base: &Path) {
|
||||
let _ = fs::remove_file(plan_path(base));
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user