Skip to main content

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}