ignition_core/actions/workspace.rs
1//! Workspace actions (13-03) — the `.ign-workspace.json` manifest
2//! (the three-way-compare state for `ign workspace status`/`push`,
3//! 13-06) and the checkout action that turns a gateway project
4//! export into a mapped, hash-recorded local directory tree.
5//!
6//! ## The manifest is the workspace's identity — RECORDED, never
7//! recomputed
8//!
9//! [`crate::client::workspace::build_mapping`] runs ONCE at checkout
10//! (Pitfall W1); the manifest stores the gateway↔local path pairs
11//! plus the checkout-time descriptor-normalized FNV-1a hash of every
12//! member. status/push READ this file — re-deriving the mapping or
13//! the hashes downstream is the documented anti-pattern. The
14//! manifest is COMMITTED (git): checkout also writes a `.gitignore`
15//! listing `scripts-manifest.json` and the `*.py` sidecar pattern
16//! (codec artifacts, not source — the codec tree convention).
17//!
18//! Envelope discipline: serde field order is struct order (stable),
19//! and any FUTURE optional field rides
20//! `#[serde(skip_serializing_if = "Option::is_none")]` (the
21//! `loss_report` precedent) so pre-existing envelopes stay
22//! byte-identical. Read ownership is strict: a missing, foreign, or
23//! unparseable manifest REFUSES with a stable message — the prefixes
24//! below are 13-07's golden anchors, never reword casually.
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31use crate::actions::resources::export_zip_bytes;
32use crate::client::GatewayApi;
33use crate::client::resources::{
34 FOLDER_DESCRIPTOR, fnv1a, member_hashes, normalize_descriptor, read_member, remove_member,
35 replace_member, resource_members,
36};
37use crate::client::scripts_codec;
38use crate::client::workspace::{MemberSource, build_mapping};
39use crate::error::CoreError;
40
41/// The workspace manifest's filename at the tree root — dot-prefixed
42/// so it can never collide with a member-derived path, and DISTINCT
43/// from the codec's `scripts-manifest.json` (`encode_export_tree`
44/// strips only its own manifest; verified scripts_codec.rs).
45pub const WORKSPACE_MANIFEST_NAME: &str = ".ign-workspace.json";
46
47/// The only manifest schema this code reads. A mismatch refuses —
48/// never guess at a foreign schema.
49pub const WORKSPACE_MANIFEST_SCHEMA_VERSION: u8 = 1;
50
51/// `.ign-workspace.json` — the recorded checkout state.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct WorkspaceManifest {
54 /// Manifest format version ([`WORKSPACE_MANIFEST_SCHEMA_VERSION`]).
55 pub schema_version: u8,
56 /// The gateway project the workspace checks out.
57 pub project: String,
58 /// The profile name checkout resolved against (13-07 passes it
59 /// down; recorded for status/push context, never re-derived).
60 pub profile: String,
61 /// Checkout time, RFC3339 UTC (`…Z`).
62 pub checked_out_at: String,
63 /// Gateway member path → its recorded checkout facts. BTreeMap:
64 /// deterministic serialization.
65 pub members: BTreeMap<String, ManifestMember>,
66}
67
68/// One member's recorded checkout facts.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct ManifestMember {
71 /// The member's local path relative to the workspace root — the
72 /// [`crate::client::workspace::build_mapping`] output, recorded
73 /// verbatim (the mapping IS the fs truth; downstream never
74 /// re-derives).
75 pub local_path: String,
76 /// The checkout-time content hash — the zip-side,
77 /// descriptor-normalized FNV-1a value (`member_hashes`
78 /// semantics: a `resource.json` member hashes its normalized
79 /// descriptor, so gateway-side `lastModification` volatility
80 /// never masquerades as drift). Identical semantics to the
81 /// Tree-side hash for identical bytes (13-02 equivalence pin).
82 pub hash: u64,
83}
84
85/// Read and validate the workspace manifest at `root`/
86/// [`WORKSPACE_MANIFEST_NAME`]. Strict read ownership — every
87/// failure is `invalid_input` (exit 2) with a STABLE message prefix
88/// (13-07's golden anchors):
89///
90/// - missing file → `not an ign workspace — run \`ign workspace
91/// checkout\` first` + the expected path;
92/// - wrong `schema_version` → names found vs expected;
93/// - unparseable JSON → names the path (never a panic, never a
94/// silent default).
95pub fn read_manifest(root: &Path) -> Result<WorkspaceManifest, CoreError> {
96 let path = root.join(WORKSPACE_MANIFEST_NAME);
97 let bytes = std::fs::read(&path).map_err(|_| CoreError::InvalidInput {
98 reason: format!(
99 "not an ign workspace — run `ign workspace checkout` first \
100 (expected manifest at {})",
101 path.display()
102 ),
103 })?;
104 let manifest: WorkspaceManifest =
105 serde_json::from_slice(&bytes).map_err(|err| CoreError::InvalidInput {
106 reason: format!("{} is not valid JSON: {err}", path.display()),
107 })?;
108 if manifest.schema_version != WORKSPACE_MANIFEST_SCHEMA_VERSION {
109 return Err(CoreError::InvalidInput {
110 reason: format!(
111 "unsupported workspace manifest schema_version {} (expected \
112 {}) at {}",
113 manifest.schema_version,
114 WORKSPACE_MANIFEST_SCHEMA_VERSION,
115 path.display()
116 ),
117 });
118 }
119 Ok(manifest)
120}
121
122/// Write the workspace manifest at `root`/`.ign-workspace.json`:
123/// pretty JSON (stable field order via struct order), trailing
124/// newline, parents created, `0640` perms (unix — it is committed,
125/// not secret, but not world-readable either).
126pub fn write_manifest(root: &Path, manifest: &WorkspaceManifest) -> Result<(), CoreError> {
127 let path = root.join(WORKSPACE_MANIFEST_NAME);
128 std::fs::create_dir_all(root).map_err(|err| {
129 CoreError::Internal(format!(
130 "cannot create workspace root {}: {err}",
131 root.display()
132 ))
133 })?;
134 let mut body = serde_json::to_vec_pretty(manifest).map_err(|err| {
135 CoreError::Internal(format!("cannot serialize workspace manifest: {err}"))
136 })?;
137 body.push(b'\n');
138 std::fs::write(&path, body)
139 .map_err(|err| CoreError::Internal(format!("cannot write {}: {err}", path.display())))?;
140 #[cfg(unix)]
141 {
142 use std::os::unix::fs::PermissionsExt;
143 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).map_err(|err| {
144 CoreError::Internal(format!(
145 "cannot set permissions on {}: {err}",
146 path.display()
147 ))
148 })?;
149 }
150 Ok(())
151}
152
153// ---- checkout (13-03 Task 2) -------------------------------------------------
154
155/// The lines checkout's `.gitignore` owns at the tree root (planner
156/// lock: the manifest is COMMITTED; the codec artifacts — the decode
157/// manifest and the `*.py` sidecars — are not source).
158const WORKSPACE_GITIGNORE_LINES: [&str; 2] = [scripts_codec::MANIFEST_NAME, "*.py"];
159
160/// Root-level file names checkout itself owns — a mapped member may
161/// never land on one of them (the checkout would shadow the member
162/// or the member would shadow workspace machinery).
163const RESERVED_ROOT_NAMES: [&str; 4] = [
164 WORKSPACE_MANIFEST_NAME,
165 scripts_codec::MANIFEST_NAME,
166 ".gitignore",
167 "project.json",
168];
169
170/// `ign workspace checkout`'s result model (serde for 13-07's
171/// envelope — actions never print, ARCHITECTURE.md layering).
172#[derive(Debug, Clone, Serialize)]
173pub struct CheckoutOutcome {
174 /// The project checked out.
175 pub project: String,
176 /// The tree root the workspace landed in.
177 pub target: PathBuf,
178 /// How many resource members were written (the manifest's
179 /// member count).
180 pub member_count: usize,
181 /// Whether the `--decode-scripts` codec leg ran (sidecars +
182 /// `scripts-manifest.json` present).
183 pub scripts_decoded: bool,
184}
185
186/// `ign workspace checkout PROJECT DIR [--decode-scripts]`'s core:
187/// export the project zip → map members through
188/// [`build_mapping`] ONCE → write every member's bytes at its
189/// MAPPED local path (never `path.join` on the raw member name —
190/// the mapping IS the fs truth) → record `.ign-workspace.json`
191/// (gateway↔local pairs + zip-side descriptor-normalized hashes)
192/// → write the idempotent `.gitignore`.
193///
194/// `--decode-scripts` rides the codec's own decode engine
195/// ([`scripts_codec::decode_member`] — no new script extraction):
196/// decoded scripts land as `<member>.<n>.py` sidecars beside their
197/// member's mapped path, and `scripts-manifest.json` at the tree
198/// root keys members by their MAPPED tree-relative paths — the
199/// layout [`scripts_codec::encode_export_tree`] consumes, so an
200/// UNEDITED tree re-encodes BYTE-EXACTLY (per member; the codec's
201/// sacred invariant proven at tree scale — pinned in
202/// `tests/workspace_checkout.rs`).
203///
204/// Refusals (all `invalid_input`, exit 2, before ANY write):
205/// - a non-empty target that is not a valid workspace of the SAME
206/// project (never clobbers an unrelated directory; a corrupt or
207/// foreign manifest refuses naming what it found);
208/// - mapping refusals propagate verbatim from [`build_mapping`]
209/// (case-fold collisions name BOTH members — 13-02);
210/// - a member mapping onto a reserved root name
211/// ([`RESERVED_ROOT_NAMES`]).
212///
213/// `profile` is recorded into the manifest (planner-locked field;
214/// 13-07 passes the resolved profile name down — the action layer
215/// cannot read config itself, so the caller owns it).
216///
217/// Read-only on the wire: exactly ONE export GET, ZERO imports.
218pub async fn workspace_checkout(
219 api: &dyn GatewayApi,
220 project: &str,
221 target_dir: &Path,
222 profile: &str,
223 decode_scripts: bool,
224) -> Result<CheckoutOutcome, CoreError> {
225 // Export — the ONE transport (`project_export_to_file` via the
226 // shared export-to-bytes seam; a nonexistent project surfaces
227 // through export's existing 404 path, `not_found` exit 6).
228 let zip = export_zip_bytes(api, project).await?;
229
230 // Clobber refusal FIRST (before any write — the zip may already
231 // be in memory, but the target is untouched until it is proven
232 // safe).
233 ensure_recheckout_safe(target_dir, project)?;
234
235 // Map once — 13-02's refusals propagate verbatim (traversal
236 // shapes, NUL, oversize segments, case-fold collisions naming
237 // both members, duplicates).
238 let members = resource_members(&zip)?;
239 let mapping = build_mapping(&members)?;
240
241 // Reserved root names: the checkout owns these files; a member
242 // landing on one would shadow (or be shadowed by) workspace
243 // machinery.
244 for (user, local) in &mapping {
245 let root_name = local.file_name().and_then(|name| name.to_str());
246 let root_level = local
247 .parent()
248 .is_none_or(|parent| parent.as_os_str().is_empty());
249 if root_level && root_name.is_some_and(|name| RESERVED_ROOT_NAMES.contains(&name)) {
250 return Err(CoreError::InvalidInput {
251 reason: format!(
252 "workspace member \"{user}\" maps onto reserved workspace file \
253 \"{}\" — the checkout owns this name; refusing",
254 local.display()
255 ),
256 });
257 }
258 }
259
260 std::fs::create_dir_all(target_dir).map_err(|err| {
261 CoreError::Internal(format!(
262 "cannot create workspace root {}: {err}",
263 target_dir.display()
264 ))
265 })?;
266
267 let mapped: BTreeSet<&PathBuf> = mapping.values().collect();
268 let mut codec_manifest = scripts_codec::Manifest {
269 version: 1,
270 members: BTreeMap::new(),
271 };
272
273 for (user, local) in &mapping {
274 // The proven zip-member read (the same engine
275 // `MemberSource::Zip` delegates to verbatim).
276 let bytes = read_member(&zip, user)?;
277 let dest = target_dir.join(local);
278 if let Some(parent) = dest.parent() {
279 std::fs::create_dir_all(parent).map_err(|err| {
280 CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
281 })?;
282 }
283 std::fs::write(&dest, &bytes).map_err(|err| {
284 CoreError::Internal(format!("cannot write {}: {err}", dest.display()))
285 })?;
286
287 // The codec leg — the codec's OWN decode engine, unchanged.
288 // `decode_member` uses the member path only for the sidecar
289 // BASENAME (identical for user and raw member paths).
290 if decode_scripts && let Some(decoded) = scripts_codec::decode_member(&bytes, user) {
291 let mut entries = Vec::with_capacity(decoded.entries.len());
292 for decoded_entry in decoded.entries {
293 let sidecar_local = match local.parent() {
294 Some(parent) if !parent.as_os_str().is_empty() => {
295 parent.join(&decoded_entry.entry.sidecar)
296 }
297 _ => PathBuf::from(&decoded_entry.entry.sidecar),
298 };
299 if mapped.contains(&sidecar_local) {
300 return Err(CoreError::InvalidInput {
301 reason: format!(
302 "sidecar \"{}\" of member \"{user}\" collides \
303 with a real checkout member — refusing to shadow it",
304 sidecar_local.display()
305 ),
306 });
307 }
308 let sidecar_dest = target_dir.join(&sidecar_local);
309 std::fs::write(&sidecar_dest, &decoded_entry.text).map_err(|err| {
310 CoreError::Internal(format!("cannot write {}: {err}", sidecar_dest.display()))
311 })?;
312 entries.push(decoded_entry.entry);
313 }
314 // Keyed by the MAPPED tree-relative path — the exact key
315 // `encode_export_tree` resolves when it walks the tree.
316 codec_manifest
317 .members
318 .insert(local.to_string_lossy().into_owned(), entries);
319 }
320 }
321
322 if decode_scripts {
323 // The codec manifest, in the codec's own format (pretty +
324 // trailing newline — `decode_export_tree`'s byte shape).
325 let mut manifest_bytes = serde_json::to_vec_pretty(&codec_manifest).map_err(|err| {
326 CoreError::Internal(format!("cannot serialize the decode manifest: {err}"))
327 })?;
328 manifest_bytes.push(b'\n');
329 let manifest_path = target_dir.join(scripts_codec::MANIFEST_NAME);
330 std::fs::write(&manifest_path, manifest_bytes).map_err(|err| {
331 CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
332 })?;
333 }
334
335 // Record: zip-side descriptor-normalized hashes (identical
336 // semantics to the Tree side for identical bytes — 13-02
337 // equivalence pin). The manifest is RECORDED; status/push never
338 // re-derive.
339 let hashes = member_hashes(&zip)?;
340 let mut manifest_members = BTreeMap::new();
341 for (user, hash) in hashes {
342 let local = &mapping[&user];
343 manifest_members.insert(
344 user,
345 ManifestMember {
346 local_path: local.to_string_lossy().into_owned(),
347 hash,
348 },
349 );
350 }
351 let manifest = WorkspaceManifest {
352 schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
353 project: project.to_string(),
354 profile: profile.to_string(),
355 checked_out_at: rfc3339_now_utc(),
356 members: manifest_members,
357 };
358 write_manifest(target_dir, &manifest)?;
359 write_gitignore(target_dir)?;
360
361 Ok(CheckoutOutcome {
362 project: project.to_string(),
363 target: target_dir.to_path_buf(),
364 member_count: mapping.len(),
365 scripts_decoded: decode_scripts,
366 })
367}
368
369/// The clobber gate: an EXISTING target must be either empty or a
370/// valid workspace of the SAME project (re-checkout over one's own
371/// workspace = 13-06's refresh semantics, allowed). Anything else —
372/// an unrelated non-empty directory, a file, a corrupt manifest —
373/// refuses BEFORE any write.
374fn ensure_recheckout_safe(target_dir: &Path, project: &str) -> Result<(), CoreError> {
375 if !target_dir.exists() {
376 return Ok(()); // fresh checkout
377 }
378 let entries = std::fs::read_dir(target_dir).map_err(|err| CoreError::InvalidInput {
379 reason: format!(
380 "target directory {} is not a usable checkout target: {err}",
381 target_dir.display()
382 ),
383 })?;
384 if entries.count() == 0 {
385 return Ok(()); // empty dir — checkout owns it from here
386 }
387 match read_manifest(target_dir) {
388 Ok(manifest) => {
389 if manifest.project != project {
390 return Err(CoreError::InvalidInput {
391 reason: format!(
392 "workspace at {} is checked out from project {:?} — refusing \
393 to check out {:?} over it",
394 target_dir.display(),
395 manifest.project,
396 project
397 ),
398 });
399 }
400 Ok(()) // same-project re-checkout: allowed, refreshes
401 }
402 Err(err) => Err(CoreError::InvalidInput {
403 reason: format!(
404 "target directory {} is not empty and is not an ign workspace — \
405 refusing to clobber it ({err})",
406 target_dir.display()
407 ),
408 }),
409 }
410}
411
412/// Write (idempotently) the `.gitignore` at the tree root: the codec
413/// manifest + the sidecar pattern. A file already listing both
414/// entries is left untouched; missing entries are APPENDED (existing
415/// content preserved — never clobber a user's own ignore rules).
416fn write_gitignore(root: &Path) -> Result<(), CoreError> {
417 let path = root.join(".gitignore");
418 let mut lines: Vec<String> = std::fs::read_to_string(&path)
419 .map(|content| content.lines().map(str::to_string).collect())
420 .unwrap_or_default();
421 let mut changed = false;
422 for entry in WORKSPACE_GITIGNORE_LINES {
423 if !lines.iter().any(|line| line.trim() == entry) {
424 lines.push(entry.to_string());
425 changed = true;
426 }
427 }
428 if !changed {
429 return Ok(()); // idempotent
430 }
431 let mut body = lines.join("\n");
432 body.push('\n');
433 std::fs::write(&path, body)
434 .map_err(|err| CoreError::Internal(format!("cannot write {}: {err}", path.display())))
435}
436
437// ---- status (13-06 Task 1) -----------------------------------------------------
438
439/// `ign workspace status`'s result model (serde for 13-07's envelope —
440/// actions never print). Envelope discipline: `rows` is ALL-rows-always
441/// — every row that exists is present; there are no null placeholders
442/// (the sessions-family semantics), and `clean` summarizes so agents
443/// never have to walk the rows to know the verdict.
444#[derive(Debug, Clone, PartialEq, Serialize)]
445pub struct WorkspaceStatus {
446 /// The project the workspace is checked out from (echoed from the
447 /// manifest after the caller's `project` argument is verified
448 /// against it).
449 pub project: String,
450 /// True iff every row is [`StatusKind::Clean`] (additions,
451 /// deletions, untracked files, and drift all make this false).
452 pub clean: bool,
453 /// Every row: one per manifest member, then gateway-only members,
454 /// then untracked files — sorted within each group (deterministic
455 /// render order for 13-07).
456 pub rows: Vec<StatusRow>,
457}
458
459/// One status row. `path` is the GATEWAY member path for
460/// manifest/gateway rows (the manifest's keys) and the local
461/// tree-relative path for [`StatusKind::Untracked`] rows.
462#[derive(Debug, Clone, PartialEq, Serialize)]
463pub struct StatusRow {
464 /// The member path the row is about.
465 pub path: String,
466 /// The verdict — PUSH-RELATIVE (see [`StatusKind`]).
467 pub kind: StatusKind,
468}
469
470/// The three-way-compare verdict for one path.
471///
472/// ## Direction semantics — PUSH-RELATIVE (planner lock, load-bearing)
473///
474/// Every row describes what `workspace push` would do to the
475/// GATEWAY. This is the projects.rs:535-555 lesson applied
476/// explicitly: `diff` speaks B-relative while sync speaks
477/// source→target, and agents misread mixed-direction labels. Here
478/// there is exactly one direction, pinned in code and tests:
479///
480/// - [`StatusKind::LocalEdit`] — the local side changed since
481/// checkout and the gateway still matches the recorded baseline:
482/// push would WRITE this member to the gateway.
483/// - [`StatusKind::GatewayDrift`] — the gateway moved on since
484/// checkout and the local side still matches the baseline: push
485/// would NOT touch this member (a pull/refresh would).
486/// - [`StatusKind::Conflict`] — BOTH sides diverged from the
487/// recorded baseline: push REFUSES outright (never `--yes`-able —
488/// clobbering a concurrent Designer edit is beyond any flag,
489/// Pitfall W2).
490/// - [`StatusKind::Clean`] — both sides still match the baseline.
491///
492/// Set-difference verdicts (not from [`classify`]'s matrix):
493///
494/// - [`StatusKind::Deleted`] — a manifest member gone from one
495/// side. `local: true` = deleted locally (push `--delete` would
496/// remove it gateway-side; without `--delete` push reports it as
497/// skipped). `local: false` = deleted gateway-side since checkout
498/// (pull territory — push would not touch it).
499/// - [`StatusKind::Added`] — a member present in the fresh gateway
500/// export but not in the manifest (exported since checkout;
501/// `local: false` — bringing it into the tree is checkout
502/// `--refresh` territory, which is manual in v1).
503/// - [`StatusKind::Untracked`] — a local file that is not a
504/// checkout member and not workspace machinery. Push IGNORES
505/// untracked files (never imports them): bringing a file into
506/// the gateway is checkout/replace territory, not push's job.
507#[derive(Debug, Clone, PartialEq, Serialize)]
508#[serde(rename_all = "snake_case")]
509pub enum StatusKind {
510 /// Both sides match the recorded checkout baseline.
511 Clean,
512 /// Local changed, gateway at baseline — push would write.
513 LocalEdit,
514 /// Gateway moved on, local at baseline — push would not touch.
515 GatewayDrift,
516 /// Both sides diverged — push refuses, never `--yes`-able.
517 Conflict,
518 /// Member in the fresh export but not in the manifest.
519 Added {
520 /// Always `false` in v1 (gateway-side additions; checkout
521 /// `--refresh` is manual — the payload exists so a future
522 /// local-add flow can reuse the kind additively).
523 local: bool,
524 },
525 /// Manifest member gone from one side.
526 Deleted {
527 /// `true` = deleted locally (push `--delete` territory);
528 /// `false` = deleted gateway-side (pull territory).
529 local: bool,
530 },
531 /// Local file that is not a checkout member; push ignores it.
532 Untracked,
533}
534
535/// THE three-way matrix — pure, total over all 8 `Option`
536/// combinations, unit-tested exhaustively. `manifest` is the
537/// recorded checkout hash, `local`/`gateway` are `None` when that
538/// side lacks the member (local file deleted; member absent from
539/// the fresh export). Returns only the four primary kinds — the
540/// set-difference verdicts ([`StatusKind::Deleted`]/
541/// [`StatusKind::Added`]/[`StatusKind::Untracked`]) are refined by
542/// the caller, which knows which side the absence came from.
543///
544/// None-handling (pinned by test): an absent side never EQUALS a
545/// present hash, so it counts as "moved" in the primary matrix —
546/// the caller then refines `LocalEdit`-with-local-absent into
547/// [`StatusKind::Deleted { local: true }`] and
548/// `GatewayDrift`-with-gateway-absent into
549/// [`StatusKind::Deleted { local: false }`]. The one special case:
550/// a baseline member absent from BOTH sides classifies
551/// [`StatusKind::Clean`] — both sides deleted it since checkout, so
552/// there is nothing to reconcile (push has nothing to delete — the
553/// member is already gone from the fresh export; a refresh
554/// re-checkout drops the stale manifest entry).
555pub fn classify(manifest: Option<u64>, local: Option<u64>, gateway: Option<u64>) -> StatusKind {
556 let Some(m) = manifest else {
557 // No recorded baseline — cannot happen for manifest-driven
558 // rows in production (the caller only feeds manifest
559 // members); totaled for the matrix with the honest verdict:
560 // sides that agree need nothing, sides that disagree get the
561 // refusal (no arbiter to pick a winner).
562 return if local == gateway {
563 StatusKind::Clean
564 } else {
565 StatusKind::Conflict
566 };
567 };
568 if local.is_none() && gateway.is_none() {
569 // Baseline member absent from BOTH sides: both deleted it
570 // since checkout — nothing to reconcile (push has nothing to
571 // delete; the member is already gone from the fresh export).
572 return StatusKind::Clean;
573 }
574 let local_same = local == Some(m);
575 let gateway_same = gateway == Some(m);
576 match (local_same, gateway_same) {
577 (true, true) => StatusKind::Clean,
578 (false, true) => StatusKind::LocalEdit,
579 (true, false) => StatusKind::GatewayDrift,
580 (false, false) => StatusKind::Conflict,
581 }
582}
583
584/// The tolerant, per-member Tree-equivalent hash — the SAME
585/// primitives [`MemberSource::Tree::member_hashes`] uses
586/// ([`normalize_descriptor`] for a `resource.json` basename,
587/// [`fnv1a`] for the content) read at the RECORDED local path (the
588/// manifest is the mapping truth — never re-derived). Status owns
589/// the absence verdict, so a `NotFound` is `Ok(None)` (a local
590/// deletion, not an error); any OTHER read failure refuses naming
591/// the member (a permission problem is not a verdict).
592fn local_member_hash(
593 root: &Path,
594 recorded: &ManifestMember,
595 member: &str,
596) -> Result<Option<u64>, CoreError> {
597 match std::fs::read(root.join(&recorded.local_path)) {
598 Ok(bytes) => {
599 let is_descriptor = Path::new(&recorded.local_path).file_name()
600 == Some(std::ffi::OsStr::new(FOLDER_DESCRIPTOR));
601 let content = if is_descriptor {
602 normalize_descriptor(&bytes).unwrap_or(bytes)
603 } else {
604 bytes
605 };
606 Ok(Some(fnv1a(&content)))
607 }
608 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
609 Err(err) => Err(CoreError::InvalidInput {
610 reason: format!(
611 "workspace member \"{member}\" cannot be read from the checkout \
612 tree (expected at \"{}\"): {err}",
613 recorded.local_path
614 ),
615 }),
616 }
617}
618
619/// Local files that are the workspace's OWN machinery or codec
620/// artifacts — never reported as [`StatusKind::Untracked`]: the two
621/// checkout-owned root files, the codec manifest, and `.py` files
622/// (the decode sidecars — the workspace `.gitignore`'s own
623/// convention). Anything else in the tree that is not a checkout
624/// member is a genuine untracked file the human created.
625fn workspace_owned_or_artifact(rel: &str) -> bool {
626 rel == WORKSPACE_MANIFEST_NAME
627 || rel == ".gitignore"
628 || rel == scripts_codec::MANIFEST_NAME
629 || rel.ends_with(".py")
630}
631
632/// Every regular file under `root`, relative + `/`-separated,
633/// EXCLUDING workspace-owned files and recorded member paths — the
634/// untracked candidate set. Sorted (BTreeSet) so the rows are
635/// deterministic.
636fn untracked_files(
637 root: &Path,
638 member_locals: &BTreeSet<String>,
639) -> Result<Vec<String>, CoreError> {
640 fn walk(dir: &Path, prefix: &str, found: &mut BTreeSet<String>) -> std::io::Result<()> {
641 for entry in std::fs::read_dir(dir)? {
642 let entry = entry?;
643 let name = entry.file_name().to_string_lossy().into_owned();
644 let rel = if prefix.is_empty() {
645 name
646 } else {
647 format!("{prefix}/{name}")
648 };
649 if entry.path().is_dir() {
650 walk(&entry.path(), &rel, found)?;
651 } else if entry.path().is_file() {
652 found.insert(rel);
653 }
654 }
655 Ok(())
656 }
657 let mut found = BTreeSet::new();
658 walk(root, "", &mut found).map_err(|err| {
659 CoreError::Internal(format!("cannot walk workspace tree {root:?}: {err}"))
660 })?;
661 Ok(found
662 .into_iter()
663 .filter(|rel| !workspace_owned_or_artifact(rel) && !member_locals.contains(rel))
664 .collect())
665}
666
667/// `ign workspace status`'s core: the manifest three-way compare.
668/// Reads the RECORDED manifest (strict read ownership — a missing
669/// manifest refuses with 13-03's stable prefix), verifies the
670/// caller's `project` against it, exports the FRESH gateway zip
671/// (ONE read GET — status is read-only on the wire), and classifies
672/// every path through the ONE hash/normalize implementation:
673/// gateway hashes ride [`MemberSource::Zip::member_hashes`]
674/// (descriptor-normalized — `lastModification` volatility never
675/// masquerades as drift); local hashes ride the Tree-equivalent
676/// per-member hash at the recorded local paths ([`fnv1a`] +
677/// [`normalize_descriptor`], never a second implementation). No
678/// byte-compare anywhere: `resource.json` members compare
679/// descriptor-normalized on both sides.
680pub async fn workspace_status(
681 root: &Path,
682 api: &dyn GatewayApi,
683 project: &str,
684) -> Result<WorkspaceStatus, CoreError> {
685 let manifest = read_manifest(root)?;
686 if manifest.project != project {
687 return Err(CoreError::InvalidInput {
688 reason: format!(
689 "workspace at {} is checked out from project {:?} — refusing to \
690 status {:?}",
691 root.display(),
692 manifest.project,
693 project
694 ),
695 });
696 }
697
698 // The fresh gateway side — the ONE transport, descriptor-
699 // normalized hashes (identical semantics to checkout's recording).
700 let zip = export_zip_bytes(api, project).await?;
701 let gateway_hashes = MemberSource::Zip(zip).member_hashes()?;
702
703 // Per-manifest-path matrix + absence refinement.
704 let mut rows = Vec::new();
705 for (member, recorded) in &manifest.members {
706 let local = local_member_hash(root, recorded, member)?;
707 let gateway = gateway_hashes.get(member).copied();
708 let kind = match classify(Some(recorded.hash), local, gateway) {
709 StatusKind::LocalEdit if local.is_none() => StatusKind::Deleted { local: true },
710 StatusKind::GatewayDrift if gateway.is_none() => StatusKind::Deleted { local: false },
711 other => other,
712 };
713 rows.push(StatusRow {
714 path: member.clone(),
715 kind,
716 });
717 }
718
719 // Set differences: gateway members the manifest never recorded —
720 // exported since checkout (pull/refresh territory, never push's).
721 for member in gateway_hashes.keys() {
722 if !manifest.members.contains_key(member) {
723 rows.push(StatusRow {
724 path: member.clone(),
725 kind: StatusKind::Added { local: false },
726 });
727 }
728 }
729
730 // Untracked local files — reported, never pushed.
731 let member_locals: BTreeSet<String> = manifest
732 .members
733 .values()
734 .map(|recorded| recorded.local_path.clone())
735 .collect();
736 for rel in untracked_files(root, &member_locals)? {
737 rows.push(StatusRow {
738 path: rel,
739 kind: StatusKind::Untracked,
740 });
741 }
742
743 let clean = rows.iter().all(|row| matches!(row.kind, StatusKind::Clean));
744 Ok(WorkspaceStatus {
745 project: project.to_string(),
746 clean,
747 rows,
748 })
749}
750
751// ---- push (13-06 Task 2) -------------------------------------------------------
752
753/// The push blast radius (serde for 13-07's envelope): the full
754/// status plus the effective selection — what push would write and
755/// what `--delete` would remove. This IS the confirmation gate's
756/// message ([`render_push_preview`]); there is no second preview
757/// shape.
758#[derive(Debug, Clone, Serialize)]
759pub struct PushPreview {
760 /// The full status rows (all kinds — the agent sees the whole
761 /// picture, including what push will NOT touch).
762 pub rows: Vec<StatusRow>,
763 /// The members push would WRITE (the [`StatusKind::LocalEdit`]
764 /// rows, in status order).
765 pub would_write: Vec<String>,
766 /// The members `--delete` would REMOVE gateway-side (the
767 /// [`StatusKind::Deleted { local: true }`] rows, in status
768 /// order). Empty without `--delete`.
769 pub would_delete: Vec<String>,
770}
771
772/// `ign workspace push`'s result model. Honest bookkeeping: `wrote`
773/// and `deleted` are exactly what landed in the ONE import; `skipped`
774/// records locally-deleted members left alone for want of `--delete`
775/// (and deletions that turned out to have nothing to remove).
776#[derive(Debug, Clone, Serialize)]
777pub struct PushOutcome {
778 /// The project pushed to (echoed from the manifest after
779 /// verification).
780 pub project: String,
781 /// Members written into the fresh export (and imported).
782 pub wrote: Vec<String>,
783 /// Members actually removed from the fresh export (and
784 /// imported). A locally-deleted member the fresh export no
785 /// longer carries is skipped, not deleted.
786 pub deleted: Vec<String>,
787 /// Locally-deleted members NOT removed because `--delete` was
788 /// absent (reported, never silently dropped), plus deletions
789 /// that found nothing to remove.
790 pub skipped: Vec<String>,
791}
792
793/// The deterministic preview render — the gate's message text (ONE
794/// fn so the CLI refusal and any future TUI body agree; the 10-04
795/// pattern). Line-set, status order, no dramatization.
796fn render_push_preview(preview: &PushPreview) -> String {
797 let mut lines = vec![format!(
798 "workspace push would write {} member(s) and delete {} member(s)",
799 preview.would_write.len(),
800 preview.would_delete.len()
801 )];
802 for path in &preview.would_write {
803 lines.push(format!(" write: {path}"));
804 }
805 for path in &preview.would_delete {
806 lines.push(format!(" delete: {path}"));
807 }
808 lines.join("\n")
809}
810
811/// THE single confirmation site for workspace push (the 10-04
812/// one-gate lesson): the preview_then_confirm composition's confirm
813/// step — build the [`PushPreview`], render it
814/// ([`render_push_preview`]), and require confirmation. The refusal's
815/// message IS the preview (it rides `ConfirmationRequired`'s
816/// `operation`, exit 2, with the destructive-operation hint
817/// attached). One gate function means the refusal shape cannot
818/// drift per branch — there are no per-branch guards.
819fn require_confirmation(yes: bool, preview: &PushPreview) -> Result<(), CoreError> {
820 if yes {
821 return Ok(());
822 }
823 Err(CoreError::ConfirmationRequired {
824 operation: render_push_preview(preview),
825 })
826}
827
828/// `ign workspace push`'s core: splice the manifest-recorded local
829/// edits into a FRESH gateway export behind ONE composed gate.
830///
831/// Order (each step before any mutation):
832/// 1. Status first ([`workspace_status`]) — the three-way compare.
833/// 2. Conflict refusal: ANY [`StatusKind::Conflict`] row refuses
834/// outright with every diverged member named and the resolution
835/// hint. NOT gated by `--yes` — clobbering a concurrent Designer
836/// edit is beyond any flag (Pitfall W2).
837/// 3. Selection: would_write = LocalEdit rows; would_delete =
838/// locally-deleted rows ONLY when `delete` is true (project_sync's
839/// opt-in semantics — without it, locally-deleted members are
840/// REPORTED as skipped, never removed gateway-side).
841/// 4. Zero-write honesty: an empty effective selection returns
842/// immediately with ZERO mutation requests (projects.rs:577
843/// precedent) — and never prompts.
844/// 5. The ONE gate ([`require_confirmation`]) unless `yes`.
845/// 6. Execute: a FRESH export (never push a stale full zip — the
846/// stale base would resurrect members deleted gateway-side since
847/// checkout; conflict detection protects the concurrent writer,
848/// the fresh base protects the tree), each would_write member
849/// spliced from its RECORDED local path via
850/// [`replace_member`] (descriptor landing rules ride free), each
851/// confirmed delete via [`remove_member`] (a member the fresh
852/// export already lost is skipped, not an error), then exactly
853/// ONE [`project_import`]. Splice = raw local bytes — no
854/// re-serialization of member content anywhere in the push path.
855pub async fn workspace_push(
856 root: &Path,
857 api: &dyn GatewayApi,
858 project: &str,
859 yes: bool,
860 delete: bool,
861) -> Result<PushOutcome, CoreError> {
862 // 1. Status — the three-way compare (one read export inside).
863 let status = workspace_status(root, api, project).await?;
864
865 // 2. Conflicts refuse outright — BEFORE selection, BEFORE the
866 // gate, NEVER --yes-able (planner lock).
867 let conflicts: Vec<&str> = status
868 .rows
869 .iter()
870 .filter_map(|row| matches!(row.kind, StatusKind::Conflict).then_some(row.path.as_str()))
871 .collect();
872 if !conflicts.is_empty() {
873 let named: Vec<String> = conflicts.iter().map(|path| format!("\"{path}\"")).collect();
874 return Err(CoreError::InvalidInput {
875 reason: format!(
876 "workspace push refused — {} member(s) changed on BOTH sides since \
877 checkout: {}; pull a fresh checkout or reconcile manually — conflicts \
878 are never force-pushed",
879 conflicts.len(),
880 named.join(", ")
881 ),
882 });
883 }
884
885 // 3. Selection — push-relative rows.
886 let would_write: Vec<String> = status
887 .rows
888 .iter()
889 .filter_map(|row| matches!(row.kind, StatusKind::LocalEdit).then_some(row.path.clone()))
890 .collect();
891 let locally_deleted: Vec<String> = status
892 .rows
893 .iter()
894 .filter_map(|row| {
895 matches!(row.kind, StatusKind::Deleted { local: true }).then_some(row.path.clone())
896 })
897 .collect();
898 let (would_delete, mut skipped): (Vec<String>, Vec<String>) = if delete {
899 (locally_deleted.clone(), Vec::new())
900 } else {
901 (Vec::new(), locally_deleted.clone())
902 };
903
904 // 4. Zero-write honesty: empty effective selection — ZERO
905 // mutation requests, no gate, no second export.
906 if would_write.is_empty() && would_delete.is_empty() {
907 return Ok(PushOutcome {
908 project: project.to_string(),
909 wrote: Vec::new(),
910 deleted: Vec::new(),
911 skipped,
912 });
913 }
914
915 // 5. THE gate — the refusal message IS the preview.
916 let preview = PushPreview {
917 rows: status.rows.clone(),
918 would_write: would_write.clone(),
919 would_delete: would_delete.clone(),
920 };
921 require_confirmation(yes, &preview)?;
922
923 // 6. Execute — the splice rides the RECORDED manifest mapping
924 // (never re-derived) into a FRESH export.
925 let manifest = read_manifest(root)?;
926 let fresh = export_zip_bytes(api, project).await?;
927 let mut spliced = fresh;
928 let mut wrote = Vec::new();
929 for member in &would_write {
930 let recorded = &manifest.members[member];
931 let bytes = std::fs::read(root.join(&recorded.local_path)).map_err(|err| {
932 CoreError::InvalidInput {
933 reason: format!(
934 "workspace member \"{member}\" cannot be read from the checkout \
935 tree (expected at \"{}\"): {err}",
936 recorded.local_path
937 ),
938 }
939 })?;
940 spliced = replace_member(&spliced, member, &bytes)?;
941 wrote.push(member.clone());
942 }
943 let mut deleted = Vec::new();
944 for member in &would_delete {
945 match remove_member(&spliced, member) {
946 Ok(next) => {
947 spliced = next;
948 deleted.push(member.clone());
949 }
950 // The fresh export already lost it (deleted gateway-side
951 // between status and splice) — nothing to remove, report
952 // honestly.
953 Err(CoreError::NotFound { .. }) => skipped.push(member.clone()),
954 Err(other) => return Err(other),
955 }
956 }
957
958 // Exactly ONE import of the spliced fresh zip (overwrite — the
959 // existing import call; the splice rides proven replace/remove
960 // landing rules, so "import answers ok while nothing lands" stays
961 // closed).
962 api.project_import(project, spliced, true).await?;
963
964 Ok(PushOutcome {
965 project: project.to_string(),
966 wrote,
967 deleted,
968 skipped,
969 })
970}
971
972/// Now, RFC3339 UTC (`…Z`, millisecond precision) — hand-rolled
973/// (civil-from-days; the tags.rs hand-rolled-parser precedent) so
974/// the manifest carries a human-readable checkout timestamp with
975/// ZERO new dependencies.
976fn rfc3339_now_utc() -> String {
977 let now = std::time::SystemTime::now()
978 .duration_since(std::time::UNIX_EPOCH)
979 .expect("system clock is after the unix epoch");
980 unix_ms_to_rfc3339_utc(now.as_millis() as i64)
981}
982
983/// Epoch milliseconds → `YYYY-MM-DDTHH:MM:SS.mmmZ` (Howard
984/// Hinnant's civil-from-days algorithm — the inverse of tags.rs's
985/// days-from-civil parser).
986fn unix_ms_to_rfc3339_utc(ms: i64) -> String {
987 let secs = ms.div_euclid(1000);
988 let millis = ms.rem_euclid(1000);
989 let days = secs.div_euclid(86_400);
990 let sod = secs.rem_euclid(86_400); // seconds of day
991 let z = days + 719_468;
992 let era = z.div_euclid(146_097);
993 let doe = z.rem_euclid(146_097); // [0, 146096]
994 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
995 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
996 let mp = (5 * doy + 2) / 153; // [0, 11]
997 let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
998 let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
999 let y = yoe + era * 400 + i64::from(m <= 2);
1000 format!(
1001 "{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z",
1002 hh = sod / 3600,
1003 mm = (sod % 3600) / 60,
1004 ss = sod % 60,
1005 )
1006}
1007
1008#[cfg(test)]
1009mod manifest_tests {
1010 use super::*;
1011
1012 fn sample() -> WorkspaceManifest {
1013 let mut members = BTreeMap::new();
1014 members.insert(
1015 "com.example/views/Dashboard/view.json".to_string(),
1016 ManifestMember {
1017 local_path: "com.example/views/Dashboard/view.json".to_string(),
1018 hash: 0xDEAD_BEEF,
1019 },
1020 );
1021 members.insert(
1022 "ignition/script-python/e2e/scratch".to_string(),
1023 ManifestMember {
1024 local_path: "ignition/script-python/e2e/scratch".to_string(),
1025 hash: 42,
1026 },
1027 );
1028 WorkspaceManifest {
1029 schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
1030 project: "My Proj".to_string(),
1031 profile: "rig".to_string(),
1032 checked_out_at: "2026-09-15T02:25:19.000Z".to_string(),
1033 members,
1034 }
1035 }
1036
1037 /// Round-trip: write → read reproduces the manifest exactly
1038 /// (BTreeMap + struct order keep serialization deterministic).
1039 #[test]
1040 fn manifest_round_trips_write_read() {
1041 let root = tempfile::tempdir().expect("tempdir");
1042 let manifest = sample();
1043 write_manifest(root.path(), &manifest).expect("writes");
1044 let read = read_manifest(root.path()).expect("reads back");
1045 assert_eq!(read, manifest, "write→read is lossless");
1046
1047 // Deterministic serialization: two writes are byte-identical.
1048 let first = std::fs::read(root.path().join(WORKSPACE_MANIFEST_NAME)).expect("read 1");
1049 write_manifest(root.path(), &manifest).expect("rewrites");
1050 let second = std::fs::read(root.path().join(WORKSPACE_MANIFEST_NAME)).expect("read 2");
1051 assert_eq!(first, second, "serialization is deterministic");
1052 assert!(
1053 first.ends_with(b"\n"),
1054 "the manifest file ends with a trailing newline"
1055 );
1056 }
1057
1058 /// THE missing-manifest golden anchor (13-07 pins this prefix):
1059 /// a directory without `.ign-workspace.json` is not a workspace.
1060 #[test]
1061 fn missing_manifest_refuses_with_stable_prefix() {
1062 let root = tempfile::tempdir().expect("tempdir");
1063 let err = read_manifest(root.path()).expect_err("missing refuses");
1064 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1065 assert_eq!(err.exit_code(), 2);
1066 let message = err.to_string();
1067 assert!(
1068 message.contains("not an ign workspace — run `ign workspace checkout` first"),
1069 "stable prefix missing: {message}"
1070 );
1071 assert!(
1072 message.contains(WORKSPACE_MANIFEST_NAME),
1073 "names the expected path: {message}"
1074 );
1075 }
1076
1077 /// A foreign schema version refuses, naming found vs expected.
1078 #[test]
1079 fn schema_version_mismatch_refuses() {
1080 let root = tempfile::tempdir().expect("tempdir");
1081 let mut manifest = sample();
1082 manifest.schema_version = 99;
1083 write_manifest(root.path(), &manifest).expect("writes foreign version");
1084 let err = read_manifest(root.path()).expect_err("mismatch refuses");
1085 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1086 let message = err.to_string();
1087 assert!(
1088 message.contains("schema_version 99") && message.contains("expected 1"),
1089 "names found vs expected: {message}"
1090 );
1091 }
1092
1093 /// Unparseable JSON refuses (never a panic, never a silent
1094 /// default) and names the path.
1095 #[test]
1096 fn corrupt_manifest_refuses() {
1097 let root = tempfile::tempdir().expect("tempdir");
1098 std::fs::write(root.path().join(WORKSPACE_MANIFEST_NAME), b"{not json").expect("writes");
1099 let err = read_manifest(root.path()).expect_err("corrupt refuses");
1100 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1101 let message = err.to_string();
1102 assert!(
1103 message.contains("is not valid JSON"),
1104 "stable corruption prefix missing: {message}"
1105 );
1106 }
1107
1108 /// The manifest file lands at `0640` (unix) — committed, not
1109 /// world-readable.
1110 #[cfg(unix)]
1111 #[test]
1112 fn manifest_writes_0640() {
1113 use std::os::unix::fs::PermissionsExt;
1114 let root = tempfile::tempdir().expect("tempdir");
1115 write_manifest(root.path(), &sample()).expect("writes");
1116 let mode = root
1117 .path()
1118 .join(WORKSPACE_MANIFEST_NAME)
1119 .metadata()
1120 .expect("meta")
1121 .permissions()
1122 .mode();
1123 assert_eq!(mode & 0o777, 0o640, "manifest mode is 0640");
1124 }
1125}
1126
1127#[cfg(test)]
1128mod checkout_tests {
1129 use super::*;
1130
1131 /// The RFC3339 formatter against well-known epoch values —
1132 /// including the famous 1234567890 (2009-02-13T23:31:30Z) —
1133 /// so the manifest's `checked_out_at` is trustworthy UTC.
1134 #[test]
1135 fn unix_ms_formats_rfc3339_utc() {
1136 assert_eq!(unix_ms_to_rfc3339_utc(0), "1970-01-01T00:00:00.000Z");
1137 assert_eq!(
1138 unix_ms_to_rfc3339_utc(1_000_000_000_000),
1139 "2001-09-09T01:46:40.000Z"
1140 );
1141 assert_eq!(
1142 unix_ms_to_rfc3339_utc(1_234_567_890_123),
1143 "2009-02-13T23:31:30.123Z"
1144 );
1145 // Leap-year day: 2024-02-29T12:00:00Z = 1709208000 s.
1146 assert_eq!(
1147 unix_ms_to_rfc3339_utc(1_709_208_000_000),
1148 "2024-02-29T12:00:00.000Z"
1149 );
1150 }
1151
1152 /// `rfc3339_now_utc` produces a well-formed, parseable-shaped
1153 /// timestamp (the wall-clock pin lives here; exact values are
1154 /// pinned by `unix_ms_formats_rfc3339_utc`).
1155 #[test]
1156 fn now_utc_is_well_formed() {
1157 let stamp = rfc3339_now_utc();
1158 assert!(stamp.ends_with('Z'), "{stamp}");
1159 assert_eq!(stamp.len(), 24, "YYYY-MM-DDTHH:MM:SS.mmmZ: {stamp}");
1160 assert!(stamp.starts_with("20"), "{stamp}");
1161 }
1162
1163 /// `write_gitignore` is idempotent (a second call is a no-op)
1164 /// and APPENDS to pre-existing content rather than clobbering.
1165 #[test]
1166 fn gitignore_is_idempotent_and_append_safe() {
1167 let root = tempfile::tempdir().expect("tempdir");
1168
1169 write_gitignore(root.path()).expect("writes");
1170 let first = std::fs::read_to_string(root.path().join(".gitignore")).expect("read");
1171 assert!(first.contains("scripts-manifest.json") && first.contains("*.py"));
1172
1173 write_gitignore(root.path()).expect("rewrites");
1174 let second = std::fs::read_to_string(root.path().join(".gitignore")).expect("read");
1175 assert_eq!(first, second, "second write is a no-op");
1176
1177 // Pre-existing user content survives; missing entries append.
1178 let root2 = tempfile::tempdir().expect("tempdir");
1179 std::fs::write(root2.path().join(".gitignore"), "target/\n").expect("seed");
1180 write_gitignore(root2.path()).expect("appends");
1181 let merged = std::fs::read_to_string(root2.path().join(".gitignore")).expect("read");
1182 assert!(
1183 merged.starts_with("target/"),
1184 "user content first: {merged:?}"
1185 );
1186 assert!(
1187 merged.contains("*.py"),
1188 "codec pattern appended: {merged:?}"
1189 );
1190 }
1191
1192 /// The clobber gate, pure-fs level: empty/absent targets pass; a
1193 /// valid same-project manifest passes; everything else refuses
1194 /// BEFORE any write.
1195 #[test]
1196 fn recheckout_gate_refuses_unrelated_non_empty_targets() {
1197 let root = tempfile::tempdir().expect("tempdir");
1198 let target = root.path().join("ws");
1199
1200 // Absent target: fine.
1201 assert!(ensure_recheckout_safe(&target, "p").is_ok());
1202
1203 // Empty dir: fine.
1204 std::fs::create_dir_all(&target).expect("mkdir");
1205 assert!(ensure_recheckout_safe(&target, "p").is_ok());
1206
1207 // Unrelated non-empty: refuses with the stable phrase, naming
1208 // the directory.
1209 std::fs::write(target.join("unrelated.txt"), b"x").expect("seed");
1210 let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
1211 let message = err.to_string();
1212 assert!(
1213 message.contains("not empty and is not an ign workspace"),
1214 "{message}"
1215 );
1216 assert!(message.contains("ws"), "names the dir: {message}");
1217
1218 // Corrupt manifest in a non-empty dir: still refuses (the
1219 // corrupt reason folds into the clobber message).
1220 std::fs::write(target.join(WORKSPACE_MANIFEST_NAME), b"{bad").expect("seed");
1221 let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
1222 assert!(
1223 err.to_string()
1224 .contains("not empty and is not an ign workspace")
1225 );
1226
1227 // Valid manifest, foreign project: refuses naming BOTH.
1228 let mut manifest = WorkspaceManifest {
1229 schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
1230 project: "other".to_string(),
1231 profile: "rig".to_string(),
1232 checked_out_at: "2026-09-15T00:00:00.000Z".to_string(),
1233 members: BTreeMap::new(),
1234 };
1235 write_manifest(&target, &manifest).expect("writes");
1236 let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
1237 let message = err.to_string();
1238 assert!(
1239 message.contains("\"other\"") && message.contains("\"p\""),
1240 "{message}"
1241 );
1242
1243 // Same project: allowed (refresh semantics).
1244 manifest.project = "p".to_string();
1245 write_manifest(&target, &manifest).expect("rewrites");
1246 assert!(ensure_recheckout_safe(&target, "p").is_ok());
1247 }
1248}