Skip to main content

turbovault_tools/
git_file_tools.rs

1//! Git-backed write tools (GWS.12).
2//!
3//! Mirrors the mutating surface of [`crate::FileTools`] + [`crate::BatchTools`]
4//! but routes every change through the git substrate's
5//! [`turbovault_git::VaultRepo::commit_changeset`]. Reads still go through the
6//! shared [`VaultManager`] — working tree == HEAD, so working-tree reads agree
7//! with the git tip.
8//!
9//! Selected per vault by [`turbovault_core::config::WriteBackend::Git`]. Lives
10//! beside `FileTools` only until the cutover (GWS.15), then this becomes the
11//! sole write surface.
12//!
13//! **Discipline (do not violate):** `GitFileTools` reads from `VaultManager`
14//! but must **never** call its mutators (`write_file`/`edit_file`/`delete_file`
15//! /`move_file`). All mutations route through [`VaultRepo::commit_changeset`].
16
17use crate::file_tools::{NoteInfo, WriteMode};
18use futures::future::BoxFuture;
19use std::path::PathBuf;
20use std::sync::Arc;
21use turbovault_batch::{BatchOperation, BatchResult, OperationRecord};
22use turbovault_core::prelude::*;
23use turbovault_git::{Changeset, CommitHook, CommitLocks, Oid, VaultRepo};
24use turbovault_vault::{EditEngine, EditResult, VaultManager};
25
26/// turbovault-lqr: result of an atomic `move_file_with_link_updates`. The
27/// rename + every link-source rewrite landed as ONE commit. Reports which
28/// sources were rewritten so the caller can surface the diff to the user.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct MoveWithLinksResult {
31    pub from: String,
32    pub to: String,
33    /// Vault-relative paths of source files whose inbound wikilinks were
34    /// rewritten in the same commit.
35    pub link_sources_updated: Vec<String>,
36}
37
38/// Callback invoked **before** returning a `ConcurrencyError` from
39/// the internal `GitFileTools::apply_txn` path (GWS.14b). The MCP server installs one that
40/// drains the per-vault reindex queue — so the agent's re-read (which the
41/// error tells it to do) sees a coherent graph + search state, not the
42/// pre-conflict snapshot.
43///
44/// Boxed-future shape rather than a plain `async fn` so the type can be
45/// stored on the `GitFileTools` struct without each call site naming an
46/// `impl Future`.
47pub type CasCollisionFlush = Arc<dyn Fn() -> BoxFuture<'static, Result<()>> + Send + Sync>;
48
49/// turbovault-a0l (PERF-1): a per-vault cached substrate handle. `VaultRepo`
50/// wraps a `git2::Repository` which is `Send + !Sync` (libgit2 raw pointers),
51/// so it lives behind a `std::sync::Mutex`; the `Arc` lets the MCP server cache
52/// one handle per vault and hand a clone to each `GitFileTools`. Reusing it
53/// elides the ~140µs `Repository::open` (config re-parse + odb/strmap setup)
54/// that otherwise fired on every write. The `Mutex` serializes commit sections
55/// exactly where `CommitLocks` already does, so net concurrency is unchanged,
56/// and cross-process CAS stays safe (libgit2 re-reads refs under `lock_ref` —
57/// guarded by `cas::tests::reused_handle_detects_external_ref_advance_no_lost_update`).
58pub type CachedRepo = Arc<std::sync::Mutex<VaultRepo>>;
59
60/// Write-side tools backed by the git substrate.
61///
62/// Holds the vault path + a shared `CommitLocks` registry rather than an
63/// `Arc<VaultRepo>` — `VaultRepo` wraps a `git2::Repository` which is `!Sync`
64/// (raw pointer), so it cannot live inside an `async fn` future that needs
65/// to be `Send`. The substrate handle is opened fresh inside a
66/// `spawn_blocking` task per call (open is ~µs); the shared `CommitLocks`
67/// keeps cross-call commit-section serialization intact.
68#[derive(Clone)]
69pub struct GitFileTools {
70    pub manager: Arc<VaultManager>,
71    pub vault_path: PathBuf,
72    pub commit_locks: Arc<CommitLocks>,
73    /// Optional post-commit hook installed on every `VaultRepo` opened
74    /// inside `apply_txn`. Plumbed for GWS.14 lazy GSU: the MCP server
75    /// passes a closure that pushes the new commit onto a per-vault
76    /// `ReindexQueue`. `None` = no reindex wiring (acceptable for tests
77    /// that don't care about derived state).
78    pub commit_hook: Option<CommitHook>,
79    /// Optional flush callback fired BEFORE returning a `ConcurrencyError`
80    /// (GWS.14b). Drains the reindex queue so the agent's re-read sees
81    /// coherent derived state. `None` = skip flush; callers see the raw
82    /// concurrency error and the graph stays as stale as the last
83    /// flush-on-query did.
84    pub flush_on_collision: Option<CasCollisionFlush>,
85    /// turbovault-lri: when `false`, every mutation pre-checks each
86    /// touched path against the worktree's `.gitignore` matcher and
87    /// refuses the changeset if any path would be ignored. Default
88    /// `true` preserves pre-lri "always-write" behavior. Wired from
89    /// `VaultGitConfig::include_ignored` by the MCP server.
90    pub include_ignored: bool,
91    /// turbovault-a0l (PERF-1): optional cached per-vault `VaultRepo` handle.
92    /// When `Some`, `apply_txn` reuses it instead of opening a fresh repo per
93    /// call (saving the ~140µs `Repository::open`). The MCP server installs one
94    /// shared across all in-process writes to the vault. Bare `Self::new*`
95    /// leaves it `None`, falling back to per-call open (tests / migrations that
96    /// don't run the server-side cache).
97    pub cached_repo: Option<CachedRepo>,
98}
99
100impl GitFileTools {
101    /// Construct without a reindex hook (graph + search stay stale until
102    /// another path triggers their rebuild). Tests use this; the MCP
103    /// server uses [`Self::new_with_hook`].
104    pub fn new(
105        manager: Arc<VaultManager>,
106        vault_path: PathBuf,
107        commit_locks: Arc<CommitLocks>,
108    ) -> Self {
109        Self {
110            manager,
111            vault_path,
112            commit_locks,
113            commit_hook: None,
114            flush_on_collision: None,
115            include_ignored: true,
116            cached_repo: None,
117        }
118    }
119
120    /// Construct with a reindex hook fired post-commit. The MCP server
121    /// installs one that pushes onto a per-vault [`crate::ReindexQueue`].
122    pub fn new_with_hook(
123        manager: Arc<VaultManager>,
124        vault_path: PathBuf,
125        commit_locks: Arc<CommitLocks>,
126        commit_hook: CommitHook,
127    ) -> Self {
128        Self {
129            manager,
130            vault_path,
131            commit_locks,
132            commit_hook: Some(commit_hook),
133            flush_on_collision: None,
134            include_ignored: true,
135            cached_repo: None,
136        }
137    }
138
139    /// Construct with both a reindex hook AND a CAS-collision flush callback
140    /// (GWS.14b). The flush callback runs BEFORE the `ConcurrencyError` is
141    /// returned to the caller, so the agent's re-read sees coherent derived
142    /// state.
143    pub fn new_with_hook_and_flush(
144        manager: Arc<VaultManager>,
145        vault_path: PathBuf,
146        commit_locks: Arc<CommitLocks>,
147        commit_hook: CommitHook,
148        flush_on_collision: CasCollisionFlush,
149    ) -> Self {
150        Self {
151            manager,
152            vault_path,
153            commit_locks,
154            commit_hook: Some(commit_hook),
155            flush_on_collision: Some(flush_on_collision),
156            include_ignored: true,
157            cached_repo: None,
158        }
159    }
160
161    /// turbovault-lri: builder-style override for `include_ignored`.
162    /// `false` makes every subsequent mutation pre-check each touched
163    /// path against the worktree's `.gitignore` matcher and refuse the
164    /// changeset if any path would be ignored. Default `true`.
165    pub fn with_include_ignored(mut self, include_ignored: bool) -> Self {
166        self.include_ignored = include_ignored;
167        self
168    }
169
170    /// turbovault-a0l (PERF-1): install a cached per-vault `VaultRepo` handle so
171    /// writes reuse it instead of opening a fresh repo per call. The handle must
172    /// already carry the shared `CommitLocks` + reindex `CommitHook` (the MCP
173    /// server opens it that way via `get_or_init_git_repo`). When set, `apply_txn`
174    /// ignores `commit_locks`/`commit_hook` on `self` — the cached handle owns
175    /// both.
176    pub fn with_cached_repo(mut self, cached_repo: CachedRepo) -> Self {
177        self.cached_repo = Some(cached_repo);
178        self
179    }
180
181    // -------- Reads (forwarded to VaultManager / fs) --------
182
183    /// Read a file from the vault (working tree == HEAD, so this is the
184    /// committed bytes).
185    pub async fn read_file(&self, path: &str) -> Result<String> {
186        self.manager.read_file(&PathBuf::from(path)).await
187    }
188
189    /// Lightweight metadata for multiple files — same shape as
190    /// [`crate::FileTools::get_notes_info`].
191    pub async fn get_notes_info(&self, paths: &[String]) -> Result<Vec<NoteInfo>> {
192        let files = crate::FileTools::new(Arc::clone(&self.manager));
193        files.get_notes_info(paths).await
194    }
195
196    // -------- Writes (route through VaultRepo) --------
197
198    /// Write a file — overwrite by default, append/prepend for the other
199    /// modes (mirrors [`crate::FileTools::write_file_with_mode`]).
200    ///
201    /// `expected_hash`, when present, must be a **git blob oid hex string**
202    /// (40 hex chars). The substrate's version token is the blob oid (not a
203    /// SHA-256 content hash). A non-Oid string is rejected loudly rather than
204    /// silently dropping CAS protection.
205    pub async fn write_file_with_mode(
206        &self,
207        path: &str,
208        content: &str,
209        mode: WriteMode,
210        expected_hash: Option<&str>,
211    ) -> Result<()> {
212        // v3b.1: delegate to the _with_message variant with the auto-derived
213        // subject rather than duplicating the body.
214        self.write_file_with_mode_and_message(
215            path,
216            content,
217            mode,
218            expected_hash,
219            &format!("write_file {}", path),
220        )
221        .await
222    }
223
224    /// turbovault-0bh: caller-supplied commit message variant of
225    /// [`Self::write_file_with_mode`]. Substrate auto-derives the message
226    /// otherwise (`write_file <path>`); this override lets the MCP layer
227    /// pass a richer message (caller's text + verb=tool_name per TV-008).
228    pub async fn write_file_with_mode_and_message(
229        &self,
230        path: &str,
231        content: &str,
232        mode: WriteMode,
233        expected_hash: Option<&str>,
234        message: &str,
235    ) -> Result<()> {
236        let final_content = self.resolve_write_content(path, content, mode).await?;
237        let expected = parse_blob_oid(expected_hash)?;
238        let txn = build_upsert_txn(message.to_string(), path, &final_content, expected);
239        self.apply_txn(&txn).await
240    }
241
242    /// Overwrite shortcut — equivalent to `write_file_with_mode(.., Overwrite, None)`.
243    pub async fn write_file(&self, path: &str, content: &str) -> Result<()> {
244        self.write_file_with_mode(path, content, WriteMode::Overwrite, None)
245            .await
246    }
247
248    /// Strict create: write a NEW file with an `expect_absent` precondition.
249    /// If the path becomes occupied between the caller's check and the
250    /// substrate's CAS, `apply_txn` returns `ConcurrencyError` — the create
251    /// race the MCP layer's pre-check cannot close on its own.
252    ///
253    /// This is the substrate-side guarantee for turbovault-947 / write-note
254    /// CAS-by-default: even with parallel subagents racing to create the
255    /// same absent path, exactly one commit lands; the loser sees a loud
256    /// ConcurrencyError and re-decides.
257    pub async fn create_file(&self, path: &str, content: &str) -> Result<()> {
258        self.create_file_with_message(path, content, &format!("create_file {}", path))
259            .await
260    }
261
262    /// turbovault-0bh: caller-supplied commit message variant of
263    /// [`Self::create_file`]. The `message` becomes the commit subject (and
264    /// body, when newline-separated). All other semantics are identical.
265    pub async fn create_file_with_message(
266        &self,
267        path: &str,
268        content: &str,
269        message: &str,
270    ) -> Result<()> {
271        let txn = Changeset::new(message.to_string()).create(path, content.as_bytes().to_vec());
272        self.apply_txn(&txn).await
273    }
274
275    /// Edit a file via SEARCH/REPLACE blocks. Reads working-tree bytes,
276    /// applies the blocks in memory, and commits the result as one
277    /// changeset. `dry_run = true` returns the preview without committing.
278    pub async fn edit_file(
279        &self,
280        path: &str,
281        edits: &str,
282        expected_hash: Option<&str>,
283        dry_run: bool,
284    ) -> Result<EditResult> {
285        // v3b.1: delegate to the _with_message variant with the auto-derived
286        // subject rather than duplicating the read/parse/apply/hash body.
287        self.edit_file_with_message(
288            path,
289            edits,
290            expected_hash,
291            dry_run,
292            &format!("edit_file {}", path),
293        )
294        .await
295    }
296
297    /// turbovault-0bh: caller-supplied commit message variant of
298    /// [`Self::edit_file`]. Behaviorally identical except the commit
299    /// subject is the caller's message instead of the auto-derived
300    /// `edit_file <path>`.
301    pub async fn edit_file_with_message(
302        &self,
303        path: &str,
304        edits: &str,
305        expected_hash: Option<&str>,
306        dry_run: bool,
307        message: &str,
308    ) -> Result<EditResult> {
309        let expected = parse_blob_oid(expected_hash)?;
310        let current = self.read_file(path).await?;
311        let engine = EditEngine::new();
312        let blocks = engine.parse_blocks(edits)?;
313        let (mut result, new_content) = engine.apply_edits(&current, &blocks, dry_run)?;
314        // 6sj: blob-OID hashes; same as edit_file.
315        result.old_hash = VaultRepo::blob_oid_of(current.as_bytes())
316            .map_err(|e| Error::config_error(format!("blob_oid_of(current): {}", e)))?
317            .to_string();
318        result.new_hash = VaultRepo::blob_oid_of(new_content.as_bytes())
319            .map_err(|e| Error::config_error(format!("blob_oid_of(new): {}", e)))?
320            .to_string();
321        if dry_run {
322            return Ok(result);
323        }
324        let txn = build_upsert_txn(message.to_string(), path, &new_content, expected);
325        self.apply_txn(&txn).await?;
326        Ok(result)
327    }
328
329    /// Delete a file. `expected_hash` (blob oid hex) enforces a CAS
330    /// precondition — pass `None` for a blind delete.
331    pub async fn delete_file(&self, path: &str) -> Result<()> {
332        self.delete_file_with_hash(path, None).await
333    }
334
335    /// Delete with optional blob-oid CAS.
336    pub async fn delete_file_with_hash(
337        &self,
338        path: &str,
339        expected_hash: Option<&str>,
340    ) -> Result<()> {
341        // v3b.1: delegate to the _with_message variant with the auto-derived
342        // subject rather than duplicating the body.
343        self.delete_file_with_hash_and_message(
344            path,
345            expected_hash,
346            &format!("delete_file {}", path),
347        )
348        .await
349    }
350
351    /// turbovault-0bh: caller-supplied commit message variant of
352    /// [`Self::delete_file_with_hash`].
353    pub async fn delete_file_with_hash_and_message(
354        &self,
355        path: &str,
356        expected_hash: Option<&str>,
357        message: &str,
358    ) -> Result<()> {
359        let expected = parse_blob_oid(expected_hash)?;
360        let mut txn = Changeset::new(message.to_string()).remove(path);
361        if let Some(oid) = expected {
362            txn = txn.expect_blob(path, oid);
363        }
364        self.apply_txn(&txn).await
365    }
366
367    /// Move a file — `remove(from) + upsert(to, bytes)` in one commit.
368    pub async fn move_file(&self, from: &str, to: &str) -> Result<()> {
369        self.move_file_with_hash(from, to, None).await
370    }
371
372    /// Move with optional blob-oid CAS on the source path.
373    pub async fn move_file_with_hash(
374        &self,
375        from: &str,
376        to: &str,
377        expected_hash: Option<&str>,
378    ) -> Result<()> {
379        self.move_file_with_hash_and_message(
380            from,
381            to,
382            expected_hash,
383            &format!("move_file {} -> {}", from, to),
384        )
385        .await
386    }
387
388    /// turbovault-0bh: caller-supplied commit message variant of
389    /// [`Self::move_file_with_hash`].
390    pub async fn move_file_with_hash_and_message(
391        &self,
392        from: &str,
393        to: &str,
394        expected_hash: Option<&str>,
395        message: &str,
396    ) -> Result<()> {
397        let expected_from = parse_blob_oid(expected_hash)?;
398        let content = self.read_file(from).await?;
399
400        let mut txn = Changeset::new(message.to_string())
401            .remove(from)
402            .upsert(to, content.into_bytes());
403        if let Some(oid) = expected_from {
404            txn = txn.expect_blob(from, oid);
405        }
406        // Destination is always required to be absent — refuses to clobber.
407        txn = txn.expect_absent(to);
408        self.apply_txn(&txn).await
409    }
410
411    /// turbovault-oz6: atomic delete + inbound-wikilink wrap-as-stale.
412    /// Removes `path` AND rewrites every backlinking source's wikilinks
413    /// targeting it as `~~[[old]]~~` strikethrough (signaling a dead
414    /// reference) — all in **one substrate changeset**.
415    ///
416    /// Each source carries an `expect_blob` precondition; a concurrent
417    /// edit to ANY source aborts the whole delete with
418    /// `ConcurrencyError`. `expected_hash` (optional, blob OID hex)
419    /// guards the target page itself.
420    ///
421    /// Returns the list of source paths whose content was rewritten so
422    /// the caller can surface what changed.
423    pub async fn delete_file_with_link_rewrite_to_stale(
424        &self,
425        path: &str,
426        expected_hash: Option<&str>,
427        message: &str,
428    ) -> Result<MoveWithLinksResult> {
429        let expected_target = parse_blob_oid(expected_hash)?;
430        let txn = Changeset::new(message.to_string());
431        let (txn, link_sources_updated) = self
432            .fold_delete_with_stale_links(txn, path, expected_target)
433            .await?;
434        self.apply_txn(&txn).await?;
435        Ok(MoveWithLinksResult {
436            from: path.to_string(),
437            to: String::new(), // No destination for a delete.
438            link_sources_updated,
439        })
440    }
441
442    /// turbovault-0g4.7: fold a delete + inbound-wikilink stale-wrap onto an
443    /// existing changeset. Resolves backlinks via the link graph, wraps each
444    /// linker's references to `path` as `~~[[old]]~~` strikethrough, and chains
445    /// `remove(path)` (+ optional `expect_blob(path)`) + each source's
446    /// `upsert`/`expect_blob`. Returns the augmented changeset and the list of
447    /// rewritten source paths. Shared by the single-op
448    /// [`Self::delete_file_with_link_rewrite_to_stale`] and the batch `DeleteNote`
449    /// arm so both produce identical commits. Same link-graph coherence caveat
450    /// as [`Self::fold_move_with_links`].
451    async fn fold_delete_with_stale_links(
452        &self,
453        txn: Changeset,
454        path: &str,
455        expected_target: Option<Oid>,
456    ) -> Result<(Changeset, Vec<String>)> {
457        use crate::wikilink_rewriter::wrap_wikilinks_as_stale;
458
459        let backlink_paths = {
460            let lg = self.manager.link_graph();
461            let graph = lg.read().await;
462            graph
463                .backlinks(&self.manager.vault_path().join(path))
464                .map_err(|e| Error::config_error(format!("backlink lookup: {}", e)))?
465                .into_iter()
466                .map(|(p, _links)| p)
467                .collect::<Vec<_>>()
468        };
469
470        let mut link_updates: Vec<(String, String, Oid)> = Vec::new();
471        for full_src in &backlink_paths {
472            let rel = full_src
473                .strip_prefix(self.manager.vault_path())
474                .map(|p| p.to_path_buf())
475                .unwrap_or_else(|_| full_src.clone());
476            let rel_str = rel
477                .to_str()
478                .ok_or_else(|| Error::config_error(format!("non-utf8 source path: {:?}", rel)))?
479                .to_string();
480            let src_content = self.read_file(&rel_str).await?;
481            let rewritten = wrap_wikilinks_as_stale(&src_content, path);
482            if rewritten == src_content {
483                continue;
484            }
485            let src_oid = VaultRepo::blob_oid_of(src_content.as_bytes())
486                .map_err(|e| Error::config_error(format!("blob_oid_of: {}", e)))?;
487            link_updates.push((rel_str, rewritten, src_oid));
488        }
489
490        let mut txn = txn.remove(path);
491        if let Some(oid) = expected_target {
492            txn = txn.expect_blob(path, oid);
493        }
494        for (rel_path, rewritten, oid) in &link_updates {
495            txn = txn
496                .upsert(rel_path.clone(), rewritten.clone().into_bytes())
497                .expect_blob(rel_path.clone(), *oid);
498        }
499
500        let updated = link_updates.into_iter().map(|(p, _, _)| p).collect();
501        Ok((txn, updated))
502    }
503
504    /// turbovault-oz6: return the list of vault-relative source paths
505    /// that have inbound wikilinks targeting `path`. Used by the MCP
506    /// layer's "refuse-if-backlinks" pre-check (option A) before
507    /// committing to a delete.
508    pub async fn list_inbound_backlinks(&self, path: &str) -> Result<Vec<String>> {
509        let backlink_paths = {
510            let lg = self.manager.link_graph();
511            let graph = lg.read().await;
512            graph
513                .backlinks(&self.manager.vault_path().join(path))
514                .map_err(|e| Error::config_error(format!("backlink lookup: {}", e)))?
515                .into_iter()
516                .map(|(p, _)| p)
517                .collect::<Vec<_>>()
518        };
519        let mut out = Vec::new();
520        for full_src in backlink_paths {
521            let rel = full_src
522                .strip_prefix(self.manager.vault_path())
523                .map(|p| p.to_path_buf())
524                .unwrap_or_else(|_| full_src.clone());
525            if let Some(s) = rel.to_str() {
526                out.push(s.to_string());
527            }
528        }
529        Ok(out)
530    }
531
532    /// turbovault-lqr: atomic move + inbound-wikilink rewrite. Renames
533    /// `from` -> `to` AND rewrites every backlinking source's
534    /// `[[from-basename]]` / `[[from-path]]` (plus alias / section /
535    /// block-anchor / embed forms) to point at the new target, all in
536    /// **one substrate changeset**.
537    ///
538    /// Per-source CAS: each rewritten source carries an `expect_blob`
539    /// precondition. If ANY source's blob OID changed between the
540    /// read-modify and the substrate apply, the WHOLE changeset
541    /// aborts (architecture §6.3 reconsideration domino). The
542    /// destination always carries `expect_absent` (no clobber).
543    ///
544    /// `expected_hash` (optional, blob OID hex) protects the SOURCE
545    /// against a concurrent edit between the caller's read and this
546    /// call.
547    ///
548    /// Returns the list of source paths whose content was rewritten so
549    /// the caller can surface what changed.
550    pub async fn move_file_with_link_updates(
551        &self,
552        from: &str,
553        to: &str,
554        expected_hash: Option<&str>,
555        message: &str,
556    ) -> Result<MoveWithLinksResult> {
557        let expected_from = parse_blob_oid(expected_hash)?;
558        let txn = Changeset::new(message.to_string());
559        let (txn, link_sources_updated) = self
560            .fold_move_with_links(txn, from, to, expected_from)
561            .await?;
562        self.apply_txn(&txn).await?;
563        Ok(MoveWithLinksResult {
564            from: from.to_string(),
565            to: to.to_string(),
566            link_sources_updated,
567        })
568    }
569
570    /// turbovault-0g4.6: fold an atomic move + inbound-wikilink rewrite onto an
571    /// existing changeset. Resolves backlinks via the in-memory link graph,
572    /// rewrites each source (OFM-aware), and chains `remove(from)` +
573    /// `upsert(to)` + `expect_absent(to)` (+ optional `expect_blob(from)`) +
574    /// each source's `upsert`/`expect_blob`. Returns the augmented changeset
575    /// and the list of rewritten source paths.
576    ///
577    /// Shared by the single-op [`Self::move_file_with_link_updates`] and the
578    /// batch `MoveNote` arm so both produce identical commits. Resolves against
579    /// the link graph, so the caller must ensure it is coherent: the MCP layer
580    /// drains the reindex queue before a backlink-aware move; unit tests call
581    /// `manager.initialize()`.
582    async fn fold_move_with_links(
583        &self,
584        txn: Changeset,
585        from: &str,
586        to: &str,
587        expected_from: Option<Oid>,
588    ) -> Result<(Changeset, Vec<String>)> {
589        use crate::wikilink_rewriter::rewrite_wikilinks;
590
591        let content = self.read_file(from).await?;
592
593        // Source paths are vault-relative PathBuf.
594        let backlink_paths = {
595            let lg = self.manager.link_graph();
596            let graph = lg.read().await;
597            graph
598                .backlinks(&self.manager.vault_path().join(from))
599                .map_err(|e| Error::config_error(format!("backlink lookup: {}", e)))?
600                .into_iter()
601                .map(|(p, _links)| p)
602                .collect::<Vec<_>>()
603        };
604
605        // Read each source, rewrite, capture blob OID for the precondition.
606        // Skip sources whose rewritten content equals the original (no actual
607        // link change — e.g. the `[[from]]` literal sits in a code fence).
608        let mut link_updates: Vec<(String, String, Oid)> = Vec::new();
609        for full_src in &backlink_paths {
610            let rel = full_src
611                .strip_prefix(self.manager.vault_path())
612                .map(|p| p.to_path_buf())
613                .unwrap_or_else(|_| full_src.clone());
614            let rel_str = rel
615                .to_str()
616                .ok_or_else(|| Error::config_error(format!("non-utf8 source path: {:?}", rel)))?
617                .to_string();
618            let src_content = self.read_file(&rel_str).await?;
619            let rewritten = rewrite_wikilinks(&src_content, from, to);
620            if rewritten == src_content {
621                continue;
622            }
623            let src_oid = VaultRepo::blob_oid_of(src_content.as_bytes())
624                .map_err(|e| Error::config_error(format!("blob_oid_of: {}", e)))?;
625            link_updates.push((rel_str, rewritten, src_oid));
626        }
627
628        // Source rename + each link source's rewrite, with preconditions.
629        let mut txn = txn.remove(from).upsert(to, content.into_bytes());
630        if let Some(oid) = expected_from {
631            txn = txn.expect_blob(from, oid);
632        }
633        txn = txn.expect_absent(to);
634        for (rel_path, rewritten, oid) in &link_updates {
635            txn = txn
636                .upsert(rel_path.clone(), rewritten.clone().into_bytes())
637                .expect_blob(rel_path.clone(), *oid);
638        }
639
640        let updated = link_updates.into_iter().map(|(p, _, _)| p).collect();
641        Ok((txn, updated))
642    }
643
644    /// Copy a file — read source, commit target (no source change). One
645    /// commit. Same expect-absent guard on the destination as `move_file`.
646    pub async fn copy_file(&self, from: &str, to: &str) -> Result<()> {
647        let content = self.read_file(from).await?;
648        let txn = Changeset::new(format!("copy_file {} -> {}", from, to))
649            .upsert(to, content.into_bytes())
650            .expect_absent(to);
651        self.apply_txn(&txn).await
652    }
653
654    // -------- Batch (the atomicity win) --------
655
656    /// Translate every [`BatchOperation`] to a single [`Changeset`] and
657    /// commit as **one atomic commit** — either every op lands or none do.
658    /// This is the spec-promised behavior the legacy [`crate::BatchTools`] never
659    /// actually delivered (the legacy path stopped at `failed_at` and left
660    /// partial state on disk).
661    pub async fn batch_execute(&self, operations: Vec<BatchOperation>) -> Result<BatchResult> {
662        self.batch_execute_inner(operations, None).await
663    }
664
665    /// turbovault-0bh: caller-supplied commit message variant of
666    /// [`Self::batch_execute`]. Overrides the auto-derived
667    /// `batch_execute (N ops)` subject with the caller's message.
668    pub async fn batch_execute_with_message(
669        &self,
670        operations: Vec<BatchOperation>,
671        message: &str,
672    ) -> Result<BatchResult> {
673        self.batch_execute_inner(operations, Some(message)).await
674    }
675
676    // -------- internals --------
677
678    async fn translate_op(&self, txn: Changeset, op: &BatchOperation) -> Result<Changeset> {
679        // Per-op preconditions (turbovault-c0e). Every variant that touches
680        // an existing target accepts `expected_hash` (git blob OID hex on
681        // git backend); `CreateNote` carries an implicit `expect_absent`
682        // unless `force == Some(true)`. A mismatch on any single op aborts
683        // the whole batch (architecture §6.3 reconsideration domino).
684        Ok(match op {
685            BatchOperation::CreateNote {
686                path,
687                content,
688                force,
689            } => {
690                if force.unwrap_or(false) {
691                    // Caller-acknowledged blind create/overwrite — drops
692                    // expect_absent. Equivalent to a WriteNote with no
693                    // expected_hash but kept under CreateNote semantics
694                    // for the intent the caller declared.
695                    txn.upsert(path, content.as_bytes())
696                } else {
697                    // Strict create — `txn.create` carries `expect_absent`.
698                    txn.create(path, content.as_bytes())
699                }
700            }
701            BatchOperation::WriteNote {
702                path,
703                content,
704                expected_hash,
705            } => upsert_expecting(
706                txn,
707                path,
708                content.as_bytes().to_vec(),
709                expected_hash.as_deref(),
710            )?,
711            BatchOperation::DeleteNote {
712                path,
713                expected_hash,
714                on_backlinks,
715            } => {
716                self.fold_delete_note(txn, path, expected_hash.as_deref(), on_backlinks.as_deref())
717                    .await?
718            }
719            BatchOperation::MoveNote {
720                from,
721                to,
722                expected_hash,
723                update_backlinks,
724            } => {
725                self.fold_move_note(txn, from, to, expected_hash.as_deref(), *update_backlinks)
726                    .await?
727            }
728            BatchOperation::UpdateLinks {
729                file,
730                old_target,
731                new_target,
732                expected_hash,
733            } => {
734                let current = self.read_file(file).await?;
735                let updated = current.replace(old_target, new_target);
736                upsert_expecting(txn, file, updated.into_bytes(), expected_hash.as_deref())?
737            }
738            BatchOperation::EditNote {
739                path,
740                edits,
741                expected_hash,
742            } => {
743                self.fold_edit_note(txn, path, edits, expected_hash.as_deref())
744                    .await?
745            }
746            BatchOperation::UpdateFrontmatter {
747                path,
748                frontmatter,
749                merge,
750                expected_hash,
751            } => {
752                self.fold_update_frontmatter(
753                    txn,
754                    path,
755                    frontmatter,
756                    *merge,
757                    expected_hash.as_deref(),
758                )
759                .await?
760            }
761            BatchOperation::ManageTags {
762                path,
763                operation,
764                tags,
765                expected_hash,
766            } => {
767                self.fold_manage_tags(txn, path, operation, tags, expected_hash.as_deref())
768                    .await?
769            }
770            BatchOperation::CreateFromTemplate {
771                template_id,
772                path,
773                fields,
774                force,
775            } => {
776                self.fold_create_from_template(txn, template_id, path, fields, *force)
777                    .await?
778            }
779        })
780    }
781
782    /// v3b.2: DeleteNote arm (turbovault-0g4.7) — backlink-aware delete:
783    /// refuse (default) / rewrite-stale-callout / force. Extracted from
784    /// translate_op to keep that dispatcher flat.
785    async fn fold_delete_note(
786        &self,
787        txn: Changeset,
788        path: &str,
789        expected_hash: Option<&str>,
790        on_backlinks: Option<&str>,
791    ) -> Result<Changeset> {
792        let expected = parse_blob_oid(expected_hash)?;
793        Ok(match on_backlinks.unwrap_or("refuse") {
794            // Bare delete — leave inbound links dangling (pre-0g4.7 behavior).
795            "force" => remove_expecting(txn, path, expected),
796            // Atomically strikethrough every linker in the same commit.
797            "rewrite-stale-callout" => {
798                self.fold_delete_with_stale_links(txn, path, expected)
799                    .await?
800                    .0
801            }
802            "refuse" => {
803                let backlinks = self.list_inbound_backlinks(path).await?;
804                if !backlinks.is_empty() {
805                    return Err(Error::config_error(format!(
806                        "DeleteNote refused (turbovault-0g4.7): '{}' has {} inbound backlink(s) [{}]. Pass on_backlinks=\"rewrite-stale-callout\" to strikethrough every linker in the same commit, or \"force\" to delete and leave them broken.",
807                        path,
808                        backlinks.len(),
809                        backlinks.join(", ")
810                    )));
811                }
812                remove_expecting(txn, path, expected)
813            }
814            other => {
815                return Err(Error::config_error(format!(
816                    "DeleteNote: unknown on_backlinks mode '{}' (expected refuse|rewrite-stale-callout|force)",
817                    other
818                )));
819            }
820        })
821    }
822
823    /// v3b.2: MoveNote arm (turbovault-0g4.6) — default rewrites inbound
824    /// wikilinks in the same commit; update_backlinks=false is rename-only.
825    async fn fold_move_note(
826        &self,
827        txn: Changeset,
828        from: &str,
829        to: &str,
830        expected_hash: Option<&str>,
831        update_backlinks: Option<bool>,
832    ) -> Result<Changeset> {
833        let expected_from = parse_blob_oid(expected_hash)?;
834        if update_backlinks.unwrap_or(true) {
835            Ok(self
836                .fold_move_with_links(txn, from, to, expected_from)
837                .await?
838                .0)
839        } else {
840            // Rename-only: no backlink rewrite, inbound links dangle. The
841            // destination always carries expect_absent (refuses to clobber).
842            let content = self.read_file(from).await?;
843            let mut t = txn.remove(from).upsert(to, content.into_bytes());
844            if let Some(oid) = expected_from {
845                t = t.expect_blob(from, oid);
846            }
847            Ok(t.expect_absent(to))
848        }
849    }
850
851    /// v3b.2: EditNote arm (turbovault-0g4.1) — SEARCH/REPLACE blocks folded
852    /// into the batch commit (the same EditEngine path edit_file uses, minus
853    /// the dry-run/hash reporting a batch doesn't need).
854    async fn fold_edit_note(
855        &self,
856        txn: Changeset,
857        path: &str,
858        edits: &str,
859        expected_hash: Option<&str>,
860    ) -> Result<Changeset> {
861        let current = self.read_file(path).await?;
862        let engine = EditEngine::new();
863        let blocks = engine.parse_blocks(edits)?;
864        let (_result, new_content) = engine.apply_edits(&current, &blocks, false)?;
865        upsert_expecting(txn, path, new_content.into_bytes(), expected_hash)
866    }
867
868    /// v3b.2: UpdateFrontmatter arm (turbovault-0g4.2) — reuse the pure
869    /// compute_update_frontmatter helper (read + merge in memory), fold the
870    /// resulting content into the batch commit.
871    async fn fold_update_frontmatter(
872        &self,
873        txn: Changeset,
874        path: &str,
875        frontmatter: &std::collections::HashMap<String, serde_json::Value>,
876        merge: Option<bool>,
877        expected_hash: Option<&str>,
878    ) -> Result<Changeset> {
879        let mt = crate::MetadataTools::new(Arc::clone(&self.manager));
880        let fm_map: serde_json::Map<String, serde_json::Value> =
881            frontmatter.clone().into_iter().collect();
882        let (new_content, _info) = mt
883            .compute_update_frontmatter(path, fm_map, merge.unwrap_or(true))
884            .await?;
885        upsert_expecting(txn, path, new_content.into_bytes(), expected_hash)
886    }
887
888    /// v3b.2: ManageTags arm (turbovault-0g4.3) — reuse compute_manage_tags;
889    /// "list" is read-only (returns None) and rejected inside a batch.
890    async fn fold_manage_tags(
891        &self,
892        txn: Changeset,
893        path: &str,
894        operation: &str,
895        tags: &[String],
896        expected_hash: Option<&str>,
897    ) -> Result<Changeset> {
898        let mt = crate::MetadataTools::new(Arc::clone(&self.manager));
899        let (maybe, _info) = mt.compute_manage_tags(path, operation, Some(tags)).await?;
900        let new_content = maybe.ok_or_else(|| {
901            Error::config_error(format!(
902                "ManageTags operation '{}' produces no write; only 'add'/'remove' are valid in a batch ('list' is read-only)",
903                operation
904            ))
905        })?;
906        upsert_expecting(txn, path, new_content.into_bytes(), expected_hash)
907    }
908
909    /// v3b.2: CreateFromTemplate arm (turbovault-0g4.4) — render via
910    /// TemplateEngine, then strict-create (default, expect_absent) or
911    /// force-upsert (overwrite).
912    async fn fold_create_from_template(
913        &self,
914        txn: Changeset,
915        template_id: &str,
916        path: &str,
917        fields: &std::collections::HashMap<String, String>,
918        force: Option<bool>,
919    ) -> Result<Changeset> {
920        let engine = crate::TemplateEngine::new(Arc::clone(&self.manager));
921        let (content, _info) = engine
922            .compute_from_template(template_id, path, fields.clone())
923            .await?;
924        Ok(if force.unwrap_or(false) {
925            txn.upsert(path, content.into_bytes())
926        } else {
927            txn.create(path, content.into_bytes())
928        })
929    }
930
931    /// turbovault-0bh internal: full batch implementation accepting an
932    /// optional caller-supplied commit message. `None` falls back to the
933    /// auto-derived `batch_execute (N ops)` subject (existing behavior).
934    async fn batch_execute_inner(
935        &self,
936        operations: Vec<BatchOperation>,
937        message: Option<&str>,
938    ) -> Result<BatchResult> {
939        let started = std::time::Instant::now();
940        let transaction_id = uuid::Uuid::new_v4().to_string();
941        let total = operations.len();
942
943        if operations.is_empty() {
944            return Ok(BatchResult {
945                success: false,
946                executed: 0,
947                total: 0,
948                failed_at: None,
949                changes: vec![],
950                errors: vec!["Batch cannot be empty".to_string()],
951                records: vec![],
952                transaction_id,
953                duration_ms: started.elapsed().as_millis() as u64,
954            });
955        }
956
957        let commit_msg = message
958            .map(String::from)
959            .unwrap_or_else(|| format!("batch_execute ({} ops)", total));
960        let mut txn = Changeset::new(commit_msg);
961        let mut changes = Vec::with_capacity(total);
962        let mut records = Vec::with_capacity(total);
963        // turbovault-0g4.5: intra-batch same-path conflict policy. The git
964        // path skips the legacy `validate()`/`conflicts_with()` O(n²) check;
965        // the substrate DOES reject a changeset with duplicate change paths
966        // (`commit_changeset` → "duplicate change for path …"), but only at
967        // apply time and with a message that names neither the offending op
968        // index nor that the cause is a *batch* overlap. Detect the collision
969        // here instead — as each op folds into the shared changeset, any path
970        // it newly writes that an earlier op already wrote aborts the batch with
971        // a clear, op-indexed error. Reject-overlap (not coalesce): a path may
972        // be mutated by at most one op per batch.
973        let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
974
975        for (idx, op) in operations.iter().enumerate() {
976            let operation_desc = format!("{:?}", op);
977            let affected = op.affected_files();
978            // Paths already folded into `txn` before this op runs; anything
979            // appended past this index is what THIS op contributes.
980            let before = txn.touched_paths().len();
981            match self.translate_op(txn, op).await {
982                Ok(next) => {
983                    txn = next;
984                    if let Some(dup) = txn
985                        .touched_paths()
986                        .into_iter()
987                        .skip(before)
988                        .find(|p| !seen_paths.insert(p.clone()))
989                    {
990                        let err_msg = format!(
991                            "intra-batch path collision (turbovault-0g4.5): operation {} writes '{}', which an earlier operation in this batch already writes. A path may be mutated by at most one operation per batch — split the conflicting writes across separate batches.",
992                            idx, dup
993                        );
994                        records.push(OperationRecord {
995                            operation_index: idx,
996                            operation: operation_desc,
997                            success: false,
998                            error: Some(err_msg.clone()),
999                            affected_files: affected,
1000                        });
1001                        return Ok(BatchResult {
1002                            success: false,
1003                            executed: idx,
1004                            total,
1005                            failed_at: Some(idx),
1006                            changes,
1007                            errors: vec![err_msg],
1008                            records,
1009                            transaction_id,
1010                            duration_ms: started.elapsed().as_millis() as u64,
1011                        });
1012                    }
1013                    changes.push(describe_op(op));
1014                    records.push(OperationRecord {
1015                        operation_index: idx,
1016                        operation: operation_desc,
1017                        success: true,
1018                        error: None,
1019                        affected_files: affected,
1020                    });
1021                }
1022                Err(e) => {
1023                    let err_msg = e.to_string();
1024                    records.push(OperationRecord {
1025                        operation_index: idx,
1026                        operation: operation_desc,
1027                        success: false,
1028                        error: Some(err_msg.clone()),
1029                        affected_files: affected,
1030                    });
1031                    return Ok(BatchResult {
1032                        success: false,
1033                        executed: idx,
1034                        total,
1035                        failed_at: Some(idx),
1036                        changes,
1037                        errors: vec![err_msg],
1038                        records,
1039                        transaction_id,
1040                        duration_ms: started.elapsed().as_millis() as u64,
1041                    });
1042                }
1043            }
1044        }
1045
1046        match self.apply_txn(&txn).await {
1047            Ok(()) => Ok(BatchResult {
1048                success: true,
1049                executed: total,
1050                total,
1051                failed_at: None,
1052                changes,
1053                errors: vec![],
1054                records,
1055                transaction_id,
1056                duration_ms: started.elapsed().as_millis() as u64,
1057            }),
1058            Err(e) => {
1059                let err_msg = e.to_string();
1060                // turbovault-jk6 (TV-013): the per-op records were built
1061                // `success: true` during the translate loop, but an apply-phase
1062                // abort (a stale CAS precondition rolls the whole batch back)
1063                // commits NOTHING (`executed: 0`). Re-mark every op not-applied
1064                // so a caller iterating `records[]` cannot conclude any op
1065                // committed. Point the error at the op whose path the failure
1066                // names; the rest are rolled back.
1067                for rec in records.iter_mut() {
1068                    rec.success = false;
1069                    rec.error = Some(
1070                        if rec
1071                            .affected_files
1072                            .iter()
1073                            .any(|f| err_msg.contains(f.as_str()))
1074                        {
1075                            err_msg.clone()
1076                        } else {
1077                            format!("rolled back (batch aborted): {err_msg}")
1078                        },
1079                    );
1080                }
1081                Ok(BatchResult {
1082                    success: false,
1083                    executed: 0,
1084                    total,
1085                    failed_at: None,
1086                    changes: vec![],
1087                    errors: vec![err_msg],
1088                    records,
1089                    transaction_id,
1090                    duration_ms: started.elapsed().as_millis() as u64,
1091                })
1092            }
1093        }
1094    }
1095
1096    /// Compute the bytes to write given mode + path.
1097    async fn resolve_write_content(
1098        &self,
1099        path: &str,
1100        content: &str,
1101        mode: WriteMode,
1102    ) -> Result<String> {
1103        Ok(match mode {
1104            WriteMode::Overwrite => content.to_string(),
1105            WriteMode::Append => {
1106                let existing = self.read_file(path).await.unwrap_or_default();
1107                if existing.is_empty() {
1108                    content.to_string()
1109                } else {
1110                    format!("{}\n{}", existing, content)
1111                }
1112            }
1113            WriteMode::Prepend => {
1114                let existing = self.read_file(path).await.unwrap_or_default();
1115                if existing.is_empty() {
1116                    content.to_string()
1117                } else if existing.starts_with("---\n") || existing.starts_with("---\r\n") {
1118                    if let Some(end_idx) = find_frontmatter_end(&existing) {
1119                        let (fm, body) = existing.split_at(end_idx);
1120                        format!("{}\n{}\n{}", fm.trim_end(), content, body.trim_start())
1121                    } else {
1122                        format!("{}\n{}", content, existing)
1123                    }
1124                } else {
1125                    format!("{}\n{}", content, existing)
1126                }
1127            }
1128        })
1129    }
1130
1131    async fn apply_txn(&self, txn: &Changeset) -> Result<()> {
1132        // `VaultRepo` is `Send` but `!Sync`; the substrate work is blocking
1133        // libgit2. Move it to the blocking pool. The `Arc<CommitLocks>` is
1134        // shared across calls so cross-call commit-section serialization
1135        // survives even though we open a fresh `VaultRepo` per call. The
1136        // optional commit hook is cloned in and installed on each per-call
1137        // open so the substrate fires it after a successful materialize.
1138        let txn = txn.clone();
1139        let include_ignored = self.include_ignored;
1140        let result = match &self.cached_repo {
1141            // turbovault-a0l (PERF-1): reuse the cached per-vault handle — no
1142            // per-op `Repository::open`. Lock it on the blocking thread (the
1143            // Mutex makes the `!Sync` `VaultRepo` workable and serializes the
1144            // commit section, matching the CommitLocks boundary writes already
1145            // pass through). Cross-process CAS stays safe (libgit2 re-reads refs
1146            // under `lock_ref`).
1147            Some(cached) => {
1148                let cached = Arc::clone(cached);
1149                tokio::task::spawn_blocking(move || -> Result<()> {
1150                    let repo = cached
1151                        .lock()
1152                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1153                    run_txn(&repo, &txn, include_ignored)
1154                })
1155                .await
1156                .map_err(|e| Error::config_error(format!("git changeset task failed: {}", e)))?
1157            }
1158            // Fallback: open a fresh `VaultRepo` per call (the pre-PERF-1 path).
1159            // Used by bare `Self::new*` — tests / migrations without the
1160            // server-side cache. The `Arc<CommitLocks>` is shared across calls
1161            // so cross-call commit-section serialization survives; the optional
1162            // hook is installed on each per-call open.
1163            None => {
1164                let path = self.vault_path.clone();
1165                let locks = Arc::clone(&self.commit_locks);
1166                let hook = self.commit_hook.clone();
1167                tokio::task::spawn_blocking(move || -> Result<()> {
1168                    let repo = match hook {
1169                        Some(h) => VaultRepo::open_with_locks_and_hook(&path, locks, h),
1170                        None => VaultRepo::open_with_locks(&path, locks),
1171                    }
1172                    .map_err(git_err_to_core)?;
1173                    run_txn(&repo, &txn, include_ignored)
1174                })
1175                .await
1176                .map_err(|e| Error::config_error(format!("git changeset task failed: {}", e)))?
1177            }
1178        };
1179
1180        // GWS.14b: on the reconsideration-domino abort, drain the reindex
1181        // queue BEFORE returning the error so the agent's re-read sees a
1182        // coherent graph/search state. In-process bursts where the conflict
1183        // is against THIS process's own earlier commit benefit directly;
1184        // cross-process conflicts (§8.4) still need a separate listener.
1185        if let Err(ref e) = result
1186            && matches!(e, Error::ConcurrencyError { .. })
1187            && let Some(flush) = &self.flush_on_collision
1188            && let Err(flush_err) = flush().await
1189        {
1190            log::warn!(
1191                "GWS.14b CAS-collision flush failed (returning original error): {}",
1192                flush_err
1193            );
1194        }
1195
1196        result
1197    }
1198}
1199
1200/// Run one changeset against an already-open `repo`: the turbovault-lri
1201/// gitignore gate (when `include_ignored == false`), then `commit_changeset`.
1202/// Shared by `apply_txn`'s cached-handle and per-call-open paths so the policy
1203/// + commit logic stays in one place.
1204fn run_txn(repo: &VaultRepo, txn: &Changeset, include_ignored: bool) -> Result<()> {
1205    if !include_ignored {
1206        for changed in txn.touched_paths() {
1207            if repo.is_path_ignored(&changed).map_err(git_err_to_core)? {
1208                return Err(Error::config_error(format!(
1209                    "path '{}' is gitignored and include_ignored=false (turbovault-lri); enable include_ignored or add an exclusion in .gitignore",
1210                    changed
1211                )));
1212            }
1213        }
1214    }
1215    repo.commit_changeset(txn)
1216        .map(|_| ())
1217        .map_err(git_err_to_core)
1218}
1219
1220fn build_upsert_txn(
1221    message: String,
1222    path: &str,
1223    content: &str,
1224    expected: Option<Oid>,
1225) -> Changeset {
1226    let mut txn = Changeset::new(message).upsert(path, content.as_bytes().to_vec());
1227    if let Some(oid) = expected {
1228        txn = txn.expect_blob(path, oid);
1229    }
1230    txn
1231}
1232
1233/// v3b.2: fold `upsert(path, bytes)` plus an optional `expect_blob` CAS
1234/// precondition (parsed from a blob-OID hex string) into `txn`. The shared tail
1235/// of every content-replacing batch op (WriteNote / UpdateLinks / EditNote /
1236/// UpdateFrontmatter / ManageTags).
1237fn upsert_expecting(
1238    txn: Changeset,
1239    path: &str,
1240    bytes: Vec<u8>,
1241    expected_hash: Option<&str>,
1242) -> Result<Changeset> {
1243    let mut t = txn.upsert(path, bytes);
1244    if let Some(oid) = parse_blob_oid(expected_hash)? {
1245        t = t.expect_blob(path, oid);
1246    }
1247    Ok(t)
1248}
1249
1250/// v3b.2: fold `remove(path)` plus an already-parsed optional `expect_blob`
1251/// precondition into `txn` — the shared tail of the bare-delete branches.
1252fn remove_expecting(txn: Changeset, path: &str, expected: Option<Oid>) -> Changeset {
1253    let mut t = txn.remove(path);
1254    if let Some(oid) = expected {
1255        t = t.expect_blob(path, oid);
1256    }
1257    t
1258}
1259
1260fn parse_blob_oid(s: Option<&str>) -> Result<Option<Oid>> {
1261    match s {
1262        None => Ok(None),
1263        Some(hex) => Oid::from_str(hex).map(Some).map_err(|_| {
1264            // `ConcurrencyError` (not `ConfigError`) so callers can switch on
1265            // ONE error type for both backends. Cross-restart edge case
1266            // (server flipped `write_backend` between the client's read and
1267            // write) lands here for SHA-256→git, and as a hash-mismatch
1268            // `ConcurrencyError` for git→SHA-256 — same shape, same fix
1269            // (re-read + retry).
1270            Error::ConcurrencyError {
1271                reason: format!(
1272                    "expected_hash for git backend must be a 40-char git blob oid hex (got {:?}). Re-read the file and retry with the fresh token.",
1273                    hex
1274                ),
1275            }
1276        }),
1277    }
1278}
1279
1280fn describe_op(op: &BatchOperation) -> String {
1281    match op {
1282        BatchOperation::CreateNote { path, .. } => format!("created {}", path),
1283        BatchOperation::WriteNote { path, .. } => format!("wrote {}", path),
1284        BatchOperation::DeleteNote { path, .. } => format!("deleted {}", path),
1285        BatchOperation::MoveNote { from, to, .. } => format!("moved {} -> {}", from, to),
1286        BatchOperation::UpdateLinks { file, .. } => format!("updated links in {}", file),
1287        BatchOperation::EditNote { path, .. } => format!("edited {}", path),
1288        BatchOperation::UpdateFrontmatter { path, .. } => {
1289            format!("updated frontmatter in {}", path)
1290        }
1291        BatchOperation::ManageTags {
1292            path, operation, ..
1293        } => format!("{} tags in {}", operation, path),
1294        BatchOperation::CreateFromTemplate {
1295            template_id, path, ..
1296        } => format!("created {} from template {}", path, template_id),
1297    }
1298}
1299
1300/// Translate a substrate error into the core error space used by the tool
1301/// layer. Precondition failures (the OCC CAS abort) become `ConcurrencyError`
1302/// so callers and tests can switch on the same shape they get from the
1303/// legacy path.
1304fn git_err_to_core(e: turbovault_git::Error) -> Error {
1305    match e {
1306        turbovault_git::Error::PreconditionFailed {
1307            path,
1308            expected,
1309            found,
1310        } => Error::ConcurrencyError {
1311            reason: format!(
1312                "precondition failed for {}: expected {:?}, found {:?}",
1313                path, expected, found
1314            ),
1315        },
1316        other => Error::config_error(format!("git substrate error: {}", other)),
1317    }
1318}
1319
1320// -------- frontmatter helper (mirrors file_tools, deliberately not re-exported) --------
1321
1322fn find_frontmatter_end(content: &str) -> Option<usize> {
1323    let start = if content.starts_with("---\r\n") {
1324        5
1325    } else if content.starts_with("---\n") {
1326        4
1327    } else {
1328        return None;
1329    };
1330    let bytes = content.as_bytes();
1331    let check_closing = |pos: usize| -> Option<usize> {
1332        if !bytes[pos..].starts_with(b"---") {
1333            return None;
1334        }
1335        let after = pos + 3;
1336        if after >= bytes.len() {
1337            return Some(after);
1338        }
1339        match bytes[after] {
1340            b'\n' => Some(after + 1),
1341            b'\r' if after + 1 < bytes.len() && bytes[after + 1] == b'\n' => Some(after + 2),
1342            _ => None,
1343        }
1344    };
1345    if let Some(end) = check_closing(start) {
1346        return Some(end);
1347    }
1348    let mut i = start;
1349    while i < bytes.len() {
1350        let nl = bytes[i..]
1351            .iter()
1352            .position(|&b| b == b'\n' || b == b'\r')
1353            .map(|p| i + p)?;
1354        let line_start = if bytes[nl] == b'\r' && nl + 1 < bytes.len() && bytes[nl + 1] == b'\n' {
1355            nl + 2
1356        } else {
1357            nl + 1
1358        };
1359        if line_start >= bytes.len() {
1360            break;
1361        }
1362        if let Some(end) = check_closing(line_start) {
1363            return Some(end);
1364        }
1365        i = line_start;
1366    }
1367    None
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373    use std::path::Path as StdPath;
1374    use tempfile::TempDir;
1375    use turbovault_core::config::{ServerConfig, VaultConfig};
1376    use turbovault_vault::VaultManager;
1377
1378    fn init_repo(dir: &StdPath) {
1379        let mut opts = git2::RepositoryInitOptions::new();
1380        opts.initial_head("main");
1381        git2::Repository::init_opts(dir, &opts).unwrap();
1382    }
1383
1384    fn test_server_config(vault_dir: &StdPath) -> ServerConfig {
1385        let mut cfg = ServerConfig::new();
1386        cfg.vaults
1387            .push(VaultConfig::builder("t", vault_dir).build().unwrap());
1388        cfg
1389    }
1390
1391    async fn setup() -> (TempDir, GitFileTools) {
1392        let tmp = TempDir::new().unwrap();
1393        init_repo(tmp.path());
1394        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
1395        let locks = Arc::new(CommitLocks::new());
1396        let tools = GitFileTools::new(manager, tmp.path().to_path_buf(), locks);
1397        (tmp, tools)
1398    }
1399
1400    /// turbovault-a0l: like `setup`, but installs a server-style CACHED
1401    /// `VaultRepo` handle (the PERF-1 path) so writes reuse it instead of
1402    /// opening a fresh repo per call.
1403    async fn setup_cached() -> (TempDir, GitFileTools) {
1404        let tmp = TempDir::new().unwrap();
1405        init_repo(tmp.path());
1406        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
1407        let locks = Arc::new(CommitLocks::new());
1408        let repo = VaultRepo::open_with_locks(tmp.path(), Arc::clone(&locks)).unwrap();
1409        let cached: CachedRepo = Arc::new(std::sync::Mutex::new(repo));
1410        let tools =
1411            GitFileTools::new(manager, tmp.path().to_path_buf(), locks).with_cached_repo(cached);
1412        (tmp, tools)
1413    }
1414
1415    fn head_oid(tools: &GitFileTools) -> Option<git2::Oid> {
1416        VaultRepo::open(&tools.vault_path).unwrap().head_oid()
1417    }
1418
1419    fn head_commit_message(tools: &GitFileTools) -> String {
1420        let repo = git2::Repository::open(&tools.vault_path).unwrap();
1421        let oid = head_oid(tools).unwrap();
1422        repo.find_commit(oid)
1423            .unwrap()
1424            .message()
1425            .unwrap()
1426            .to_string()
1427    }
1428
1429    #[tokio::test]
1430    async fn write_file_creates_commit_and_materializes() {
1431        let (tmp, tools) = setup().await;
1432        tools.write_file("a.md", "alpha").await.unwrap();
1433        assert_eq!(
1434            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
1435            "alpha"
1436        );
1437        assert!(head_oid(&tools).is_some(), "commit landed on HEAD");
1438    }
1439
1440    #[tokio::test]
1441    async fn write_file_overwrites_existing() {
1442        let (_tmp, tools) = setup().await;
1443        tools.write_file("a.md", "v1").await.unwrap();
1444        tools.write_file("a.md", "v2").await.unwrap();
1445        assert_eq!(tools.read_file("a.md").await.unwrap(), "v2");
1446    }
1447
1448    /// turbovault-a0l (PERF-1): the cached-handle path writes, reuses the
1449    /// handle across calls (the cached repo sees its own prior commits, so the
1450    /// parent chain advances correctly), and materializes — same observable
1451    /// behavior as the per-call-open path.
1452    #[tokio::test]
1453    async fn cached_repo_path_writes_reuses_and_reads_back() {
1454        let (tmp, tools) = setup_cached().await;
1455        tools.write_file("a.md", "v1").await.unwrap();
1456        tools.write_file("a.md", "v2").await.unwrap();
1457        assert_eq!(tools.read_file("a.md").await.unwrap(), "v2");
1458        assert_eq!(
1459            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
1460            "v2"
1461        );
1462        assert!(
1463            head_oid(&tools).is_some(),
1464            "commit landed via the cached handle"
1465        );
1466        // A second distinct file through the same handle also lands.
1467        tools.write_file("b.md", "B").await.unwrap();
1468        assert_eq!(tools.read_file("b.md").await.unwrap(), "B");
1469    }
1470
1471    /// turbovault-a0l (PERF-1): the cached path must still enforce the blob-oid
1472    /// CAS precondition — caching the handle changes nothing about correctness.
1473    #[tokio::test]
1474    async fn cached_repo_path_still_enforces_cas() {
1475        let (_tmp, tools) = setup_cached().await;
1476        tools.write_file("a.md", "v1").await.unwrap();
1477        let bogus = VaultRepo::blob_oid_of(b"NOPE").unwrap();
1478        let err = tools
1479            .write_file_with_mode("a.md", "v2", WriteMode::Overwrite, Some(&bogus.to_string()))
1480            .await
1481            .unwrap_err();
1482        assert!(
1483            matches!(err, Error::ConcurrencyError { .. }),
1484            "got: {err:?}"
1485        );
1486        assert_eq!(
1487            tools.read_file("a.md").await.unwrap(),
1488            "v1",
1489            "stale CAS did not apply"
1490        );
1491    }
1492
1493    /// turbovault-jk6 (TV-013): on an apply-phase abort (a stale CAS
1494    /// precondition on one op rolls the whole batch back, `executed: 0`),
1495    /// the per-op `records[]` must NOT report `success: true` — nothing
1496    /// committed. The failing op carries the error; the rest are rolled back.
1497    #[tokio::test]
1498    async fn batch_abort_marks_records_not_applied() {
1499        let (tmp, tools) = setup().await;
1500        // Seed an existing file so a stale `expected_hash` forces a CAS abort.
1501        tools.write_file("s1.md", "v1").await.unwrap();
1502        let stale = VaultRepo::blob_oid_of(b"STALE").unwrap().to_string();
1503        let ops = vec![
1504            BatchOperation::CreateNote {
1505                path: "ghost.md".to_string(),
1506                content: "x".to_string(),
1507                force: None,
1508            },
1509            BatchOperation::WriteNote {
1510                path: "s1.md".to_string(),
1511                content: "v2".to_string(),
1512                expected_hash: Some(stale),
1513            },
1514        ];
1515        let res = tools.batch_execute(ops).await.unwrap();
1516
1517        // Top-level: aborted, nothing executed.
1518        assert!(!res.success, "batch must report failure");
1519        assert_eq!(res.executed, 0, "nothing committed");
1520        assert!(res.changes.is_empty());
1521        assert!(!res.errors.is_empty(), "top-level error populated");
1522
1523        // Per-op records reflect the abort — the TV-013 bug was success:true here.
1524        assert_eq!(res.records.len(), 2);
1525        assert!(
1526            res.records.iter().all(|r| !r.success),
1527            "no op may claim success on an aborted batch: {:?}",
1528            res.records
1529        );
1530        let s1 = res
1531            .records
1532            .iter()
1533            .find(|r| r.affected_files.iter().any(|f| f == "s1.md"))
1534            .expect("s1 op record present");
1535        assert!(
1536            s1.error.as_deref().is_some_and(|e| !e.is_empty()),
1537            "failing op carries an error: {s1:?}"
1538        );
1539
1540        // Disk: atomicity intact — ghost not created, s1 unchanged.
1541        assert!(!tmp.path().join("ghost.md").exists(), "ghost not created");
1542        assert_eq!(tools.read_file("s1.md").await.unwrap(), "v1");
1543    }
1544
1545    /// turbovault-0g4.5: two ops in one batch writing the SAME path are
1546    /// rejected with a clear, op-indexed collision error (not the substrate's
1547    /// cryptic apply-time "duplicate change for path" abort), and NOTHING
1548    /// commits.
1549    #[tokio::test]
1550    async fn batch_same_path_collision_is_loud_and_atomic() {
1551        let (tmp, tools) = setup().await;
1552        let ops = vec![
1553            BatchOperation::WriteNote {
1554                path: "dup.md".to_string(),
1555                content: "first".to_string(),
1556                expected_hash: None,
1557            },
1558            BatchOperation::WriteNote {
1559                path: "dup.md".to_string(),
1560                content: "second".to_string(),
1561                expected_hash: None,
1562            },
1563        ];
1564        let res = tools.batch_execute(ops).await.unwrap();
1565        assert!(!res.success, "same-path collision must fail the batch");
1566        assert_eq!(res.failed_at, Some(1), "the second op is the collision");
1567        assert!(
1568            res.errors
1569                .iter()
1570                .any(|e| e.contains("dup.md") && e.to_lowercase().contains("collision")),
1571            "error names the colliding path: {:?}",
1572            res.errors
1573        );
1574        assert!(
1575            !tmp.path().join("dup.md").exists(),
1576            "atomic: nothing committed on collision"
1577        );
1578    }
1579
1580    /// turbovault-0g4.5: a MoveNote's two endpoints colliding with a sibling
1581    /// write is caught (the `to` path is already written by an earlier op).
1582    #[tokio::test]
1583    async fn batch_move_dest_collision_with_prior_write_is_caught() {
1584        let (tmp, tools) = setup().await;
1585        tools.write_file("src.md", "body").await.unwrap();
1586        let ops = vec![
1587            BatchOperation::WriteNote {
1588                path: "dest.md".to_string(),
1589                content: "occupant".to_string(),
1590                expected_hash: None,
1591            },
1592            BatchOperation::MoveNote {
1593                from: "src.md".to_string(),
1594                to: "dest.md".to_string(),
1595                expected_hash: None,
1596                update_backlinks: None,
1597            },
1598        ];
1599        let res = tools.batch_execute(ops).await.unwrap();
1600        assert!(!res.success);
1601        assert_eq!(res.failed_at, Some(1));
1602        assert!(res.errors.iter().any(|e| e.contains("dest.md")));
1603        // src untouched, dest never created.
1604        assert_eq!(tools.read_file("src.md").await.unwrap(), "body");
1605        assert!(!tmp.path().join("dest.md").exists());
1606    }
1607
1608    /// turbovault-0g4.5: a disjoint multi-op batch still succeeds — the guard
1609    /// only fires on genuine same-path overlap.
1610    #[tokio::test]
1611    async fn batch_disjoint_paths_still_succeed() {
1612        let (tmp, tools) = setup().await;
1613        let ops = vec![
1614            BatchOperation::WriteNote {
1615                path: "a.md".to_string(),
1616                content: "A".to_string(),
1617                expected_hash: None,
1618            },
1619            BatchOperation::WriteNote {
1620                path: "b.md".to_string(),
1621                content: "B".to_string(),
1622                expected_hash: None,
1623            },
1624        ];
1625        let res = tools.batch_execute(ops).await.unwrap();
1626        assert!(res.success);
1627        assert_eq!(res.executed, 2);
1628        assert_eq!(
1629            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
1630            "A"
1631        );
1632        assert_eq!(
1633            std::fs::read_to_string(tmp.path().join("b.md")).unwrap(),
1634            "B"
1635        );
1636    }
1637
1638    /// turbovault-0g4.1: EditNote folds SEARCH/REPLACE blocks into the batch
1639    /// commit; multiple blocks edit multiple locations in the one file, and a
1640    /// sibling op rides the same atomic commit.
1641    #[tokio::test]
1642    async fn batch_edit_note_multi_block_in_one_commit() {
1643        let (_tmp, tools) = setup().await;
1644        tools
1645            .write_file("doc.md", "alpha\nbeta\ngamma\n")
1646            .await
1647            .unwrap();
1648        let before = head_oid(&tools);
1649        let edits = "<<<<<<< SEARCH\nalpha\n=======\nALPHA\n>>>>>>> REPLACE\n\
1650                     <<<<<<< SEARCH\ngamma\n=======\nGAMMA\n>>>>>>> REPLACE";
1651        let ops = vec![
1652            BatchOperation::EditNote {
1653                path: "doc.md".to_string(),
1654                edits: edits.to_string(),
1655                expected_hash: None,
1656            },
1657            BatchOperation::WriteNote {
1658                path: "sibling.md".to_string(),
1659                content: "S".to_string(),
1660                expected_hash: None,
1661            },
1662        ];
1663        let res = tools.batch_execute(ops).await.unwrap();
1664        assert!(res.success, "edit batch failed: {:?}", res.errors);
1665        assert_eq!(res.executed, 2);
1666        assert_eq!(
1667            tools.read_file("doc.md").await.unwrap(),
1668            "ALPHA\nbeta\nGAMMA\n"
1669        );
1670        assert_eq!(tools.read_file("sibling.md").await.unwrap(), "S");
1671        assert_ne!(head_oid(&tools), before, "one new commit for the batch");
1672    }
1673
1674    /// turbovault-0g4.1: a stale `expected_hash` on an EditNote aborts the
1675    /// whole batch atomically — the file is untouched.
1676    #[tokio::test]
1677    async fn batch_edit_note_stale_hash_aborts() {
1678        let (_tmp, tools) = setup().await;
1679        tools.write_file("doc.md", "x\n").await.unwrap();
1680        let stale = VaultRepo::blob_oid_of(b"STALE").unwrap().to_string();
1681        let ops = vec![BatchOperation::EditNote {
1682            path: "doc.md".to_string(),
1683            edits: "<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE".to_string(),
1684            expected_hash: Some(stale),
1685        }];
1686        let res = tools.batch_execute(ops).await.unwrap();
1687        assert!(!res.success);
1688        assert_eq!(tools.read_file("doc.md").await.unwrap(), "x\n", "unchanged");
1689    }
1690
1691    /// turbovault-0g4.2: UpdateFrontmatter merges keys into an existing note's
1692    /// frontmatter as part of the batch commit (existing keys + body preserved).
1693    #[tokio::test]
1694    async fn batch_update_frontmatter_merges_in_one_commit() {
1695        let (_tmp, tools) = setup().await;
1696        tools
1697            .write_file("n.md", "---\ntitle: T\n---\nbody\n")
1698            .await
1699            .unwrap();
1700        let mut fm = std::collections::HashMap::new();
1701        fm.insert("status".to_string(), serde_json::json!("active"));
1702        let ops = vec![BatchOperation::UpdateFrontmatter {
1703            path: "n.md".to_string(),
1704            frontmatter: fm,
1705            merge: Some(true),
1706            expected_hash: None,
1707        }];
1708        let res = tools.batch_execute(ops).await.unwrap();
1709        assert!(res.success, "frontmatter batch failed: {:?}", res.errors);
1710        let content = tools.read_file("n.md").await.unwrap();
1711        assert!(
1712            content.contains("title: T"),
1713            "existing key preserved: {content}"
1714        );
1715        assert!(
1716            content.contains("status: active"),
1717            "new key merged: {content}"
1718        );
1719        assert!(content.contains("body"), "body preserved: {content}");
1720    }
1721
1722    /// turbovault-0g4.3: ManageTags "add" folds new frontmatter tags into the
1723    /// batch commit.
1724    #[tokio::test]
1725    async fn batch_manage_tags_add_in_one_commit() {
1726        let (_tmp, tools) = setup().await;
1727        tools
1728            .write_file("t.md", "---\ntitle: T\n---\nbody\n")
1729            .await
1730            .unwrap();
1731        let ops = vec![BatchOperation::ManageTags {
1732            path: "t.md".to_string(),
1733            operation: "add".to_string(),
1734            tags: vec!["work".to_string(), "urgent".to_string()],
1735            expected_hash: None,
1736        }];
1737        let res = tools.batch_execute(ops).await.unwrap();
1738        assert!(res.success, "manage_tags batch failed: {:?}", res.errors);
1739        let content = tools.read_file("t.md").await.unwrap();
1740        assert!(content.contains("work"), "tag added: {content}");
1741        assert!(content.contains("urgent"), "tag added: {content}");
1742    }
1743
1744    /// turbovault-0g4.3: a "list" ManageTags op in a batch is rejected — it is
1745    /// read-only, so there is no write to fold into the commit.
1746    #[tokio::test]
1747    async fn batch_manage_tags_list_is_rejected() {
1748        let (_tmp, tools) = setup().await;
1749        tools
1750            .write_file("t.md", "---\ntags: [a]\n---\n")
1751            .await
1752            .unwrap();
1753        let ops = vec![BatchOperation::ManageTags {
1754            path: "t.md".to_string(),
1755            operation: "list".to_string(),
1756            tags: vec![],
1757            expected_hash: None,
1758        }];
1759        let res = tools.batch_execute(ops).await.unwrap();
1760        assert!(!res.success, "list must be rejected inside a batch");
1761    }
1762
1763    /// turbovault-0g4.4: CreateFromTemplate renders a built-in template and
1764    /// creates the note as part of the batch commit (fields substituted,
1765    /// template frontmatter present).
1766    #[tokio::test]
1767    async fn batch_create_from_template_in_one_commit() {
1768        let (_tmp, tools) = setup().await;
1769        let mut fields = std::collections::HashMap::new();
1770        fields.insert("title".to_string(), "Auth".to_string());
1771        fields.insert("summary".to_string(), "How auth works".to_string());
1772        let ops = vec![BatchOperation::CreateFromTemplate {
1773            template_id: "doc".to_string(),
1774            path: "notes/auth.md".to_string(),
1775            fields,
1776            force: None,
1777        }];
1778        let res = tools.batch_execute(ops).await.unwrap();
1779        assert!(res.success, "template batch failed: {:?}", res.errors);
1780        let content = tools.read_file("notes/auth.md").await.unwrap();
1781        assert!(content.contains("# Auth"), "title substituted: {content}");
1782        assert!(
1783            content.contains("How auth works"),
1784            "summary substituted: {content}"
1785        );
1786        assert!(
1787            content.contains("type: documentation"),
1788            "template frontmatter present: {content}"
1789        );
1790    }
1791
1792    /// turbovault-0g4.4: default (force=None) is a strict create — a colliding
1793    /// path aborts the whole batch (expect_absent), leaving the occupant intact.
1794    #[tokio::test]
1795    async fn batch_create_from_template_strict_create_aborts_on_collision() {
1796        let (_tmp, tools) = setup().await;
1797        tools.write_file("dup.md", "occupied").await.unwrap();
1798        let mut fields = std::collections::HashMap::new();
1799        fields.insert("title".to_string(), "X".to_string());
1800        fields.insert("summary".to_string(), "Y".to_string());
1801        let ops = vec![BatchOperation::CreateFromTemplate {
1802            template_id: "doc".to_string(),
1803            path: "dup.md".to_string(),
1804            fields,
1805            force: None,
1806        }];
1807        let res = tools.batch_execute(ops).await.unwrap();
1808        assert!(!res.success, "strict create must abort on an existing path");
1809        assert_eq!(
1810            tools.read_file("dup.md").await.unwrap(),
1811            "occupied",
1812            "occupant unchanged"
1813        );
1814    }
1815
1816    /// turbovault-0g4.6: a batch MoveNote rewrites inbound wikilinks atomically
1817    /// by default (parity with the standalone move_note) — the rename and the
1818    /// backlink source land in ONE commit.
1819    #[tokio::test]
1820    async fn batch_move_note_rewrites_backlinks_by_default() {
1821        let (tmp, tools) = setup().await;
1822        tools.write_file("old.md", "# Old\n").await.unwrap();
1823        tools
1824            .write_file("linker.md", "see [[old]] here\n")
1825            .await
1826            .unwrap();
1827        // Populate the link graph so backlinks resolve (the MCP layer drains
1828        // the reindex queue; the unit test initializes directly).
1829        tools.manager.initialize().await.unwrap();
1830        let before = head_oid(&tools);
1831        let ops = vec![BatchOperation::MoveNote {
1832            from: "old.md".to_string(),
1833            to: "new.md".to_string(),
1834            expected_hash: None,
1835            update_backlinks: None, // default → rewrite
1836        }];
1837        let res = tools.batch_execute(ops).await.unwrap();
1838        assert!(res.success, "move batch failed: {:?}", res.errors);
1839        assert!(!tmp.path().join("old.md").exists());
1840        assert_eq!(
1841            std::fs::read_to_string(tmp.path().join("new.md")).unwrap(),
1842            "# Old\n"
1843        );
1844        assert_eq!(
1845            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
1846            "see [[new]] here\n",
1847            "inbound wikilink rewritten in the same commit"
1848        );
1849        assert_ne!(head_oid(&tools), before, "one new commit");
1850    }
1851
1852    /// turbovault-0g4.6: update_backlinks=false keeps the pre-0g4.6 rename-only
1853    /// behavior — inbound links are left dangling.
1854    #[tokio::test]
1855    async fn batch_move_note_rename_only_when_backlinks_disabled() {
1856        let (tmp, tools) = setup().await;
1857        tools.write_file("old.md", "# Old\n").await.unwrap();
1858        tools
1859            .write_file("linker.md", "see [[old]] here\n")
1860            .await
1861            .unwrap();
1862        tools.manager.initialize().await.unwrap();
1863        let ops = vec![BatchOperation::MoveNote {
1864            from: "old.md".to_string(),
1865            to: "new.md".to_string(),
1866            expected_hash: None,
1867            update_backlinks: Some(false),
1868        }];
1869        let res = tools.batch_execute(ops).await.unwrap();
1870        assert!(res.success, "rename-only batch failed: {:?}", res.errors);
1871        assert!(tmp.path().join("new.md").exists());
1872        assert_eq!(
1873            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
1874            "see [[old]] here\n",
1875            "rename-only leaves the inbound link dangling"
1876        );
1877    }
1878
1879    /// turbovault-0g4.7: a batch DeleteNote of a backlinked note is REFUSED by
1880    /// default, aborting the batch — prevents silently shipping broken links.
1881    #[tokio::test]
1882    async fn batch_delete_note_refuses_backlinked_by_default() {
1883        let (tmp, tools) = setup().await;
1884        tools.write_file("doomed.md", "# Doomed\n").await.unwrap();
1885        tools
1886            .write_file("linker.md", "see [[doomed]]\n")
1887            .await
1888            .unwrap();
1889        tools.manager.initialize().await.unwrap();
1890        let ops = vec![BatchOperation::DeleteNote {
1891            path: "doomed.md".to_string(),
1892            expected_hash: None,
1893            on_backlinks: None, // default → refuse
1894        }];
1895        let res = tools.batch_execute(ops).await.unwrap();
1896        assert!(!res.success, "refuse must abort the batch");
1897        assert!(
1898            res.errors
1899                .iter()
1900                .any(|e| e.contains("linker.md") && e.to_lowercase().contains("backlink")),
1901            "error names the linker: {:?}",
1902            res.errors
1903        );
1904        assert!(tmp.path().join("doomed.md").exists(), "nothing deleted");
1905    }
1906
1907    /// turbovault-0g4.7: on_backlinks="rewrite-stale-callout" strikethroughs
1908    /// every linker in the SAME commit as the delete.
1909    #[tokio::test]
1910    async fn batch_delete_note_rewrite_stale_wraps_linkers() {
1911        let (tmp, tools) = setup().await;
1912        tools.write_file("doomed.md", "# Doomed\n").await.unwrap();
1913        tools
1914            .write_file("linker.md", "see [[doomed]] here\n")
1915            .await
1916            .unwrap();
1917        tools.manager.initialize().await.unwrap();
1918        let ops = vec![BatchOperation::DeleteNote {
1919            path: "doomed.md".to_string(),
1920            expected_hash: None,
1921            on_backlinks: Some("rewrite-stale-callout".to_string()),
1922        }];
1923        let res = tools.batch_execute(ops).await.unwrap();
1924        assert!(res.success, "stale-wrap batch failed: {:?}", res.errors);
1925        assert!(!tmp.path().join("doomed.md").exists());
1926        assert_eq!(
1927            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
1928            "see ~~[[doomed]]~~ here\n",
1929            "linker strikethrough-wrapped in the delete commit"
1930        );
1931    }
1932
1933    /// turbovault-0g4.7: on_backlinks="force" deletes and leaves linkers broken
1934    /// (the pre-0g4.7 bare-delete behavior).
1935    #[tokio::test]
1936    async fn batch_delete_note_force_leaves_linkers_broken() {
1937        let (tmp, tools) = setup().await;
1938        tools.write_file("doomed.md", "# Doomed\n").await.unwrap();
1939        tools
1940            .write_file("linker.md", "see [[doomed]] here\n")
1941            .await
1942            .unwrap();
1943        tools.manager.initialize().await.unwrap();
1944        let ops = vec![BatchOperation::DeleteNote {
1945            path: "doomed.md".to_string(),
1946            expected_hash: None,
1947            on_backlinks: Some("force".to_string()),
1948        }];
1949        let res = tools.batch_execute(ops).await.unwrap();
1950        assert!(res.success, "force batch failed: {:?}", res.errors);
1951        assert!(!tmp.path().join("doomed.md").exists());
1952        assert_eq!(
1953            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
1954            "see [[doomed]] here\n",
1955            "force leaves the inbound link dangling"
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn write_file_with_stale_blob_oid_aborts_concurrency_error() {
1961        let (_tmp, tools) = setup().await;
1962        tools.write_file("a.md", "v1").await.unwrap();
1963        // Use a deliberately wrong blob oid.
1964        let bogus = VaultRepo::blob_oid_of(b"NOPE").unwrap();
1965        let err = tools
1966            .write_file_with_mode("a.md", "v2", WriteMode::Overwrite, Some(&bogus.to_string()))
1967            .await
1968            .unwrap_err();
1969        assert!(
1970            matches!(err, Error::ConcurrencyError { .. }),
1971            "got: {err:?}"
1972        );
1973        assert_eq!(tools.read_file("a.md").await.unwrap(), "v1");
1974    }
1975
1976    #[tokio::test]
1977    async fn write_file_with_garbage_hash_is_loud_concurrency_error() {
1978        // A malformed hash from the caller (e.g. cross-restart edge case
1979        // where the legacy SHA-256 hex still lives in the client) lands as
1980        // ConcurrencyError, NOT ConfigError — same shape callers handle for
1981        // any other stale-token failure, single switch arm fixes both
1982        // backends.
1983        let (_tmp, tools) = setup().await;
1984        let err = tools
1985            .write_file_with_mode("a.md", "v1", WriteMode::Overwrite, Some("not-a-hash"))
1986            .await
1987            .unwrap_err();
1988        assert!(
1989            matches!(err, Error::ConcurrencyError { .. }),
1990            "got: {err:?}"
1991        );
1992    }
1993
1994    #[tokio::test]
1995    async fn delete_file_removes_and_commits() {
1996        let (tmp, tools) = setup().await;
1997        tools.write_file("a.md", "x").await.unwrap();
1998        tools.delete_file("a.md").await.unwrap();
1999        assert!(!tmp.path().join("a.md").exists());
2000    }
2001
2002    #[tokio::test]
2003    async fn move_file_atomic_remove_plus_add_one_commit() {
2004        let (tmp, tools) = setup().await;
2005        tools.write_file("old.md", "body").await.unwrap();
2006        let before = head_oid(&tools);
2007        tools.move_file("old.md", "new.md").await.unwrap();
2008        assert!(!tmp.path().join("old.md").exists());
2009        assert_eq!(
2010            std::fs::read_to_string(tmp.path().join("new.md")).unwrap(),
2011            "body"
2012        );
2013        assert_ne!(head_oid(&tools), before, "new commit");
2014    }
2015
2016    #[tokio::test]
2017    async fn move_file_refuses_to_clobber_existing_destination() {
2018        let (_tmp, tools) = setup().await;
2019        tools.write_file("a.md", "A").await.unwrap();
2020        tools.write_file("b.md", "B").await.unwrap();
2021        let err = tools.move_file("a.md", "b.md").await.unwrap_err();
2022        assert!(
2023            matches!(err, Error::ConcurrencyError { .. }),
2024            "got: {err:?}"
2025        );
2026        // Both files still present, untouched.
2027        assert_eq!(tools.read_file("a.md").await.unwrap(), "A");
2028        assert_eq!(tools.read_file("b.md").await.unwrap(), "B");
2029    }
2030
2031    #[tokio::test]
2032    async fn copy_file_writes_destination_only() {
2033        let (_tmp, tools) = setup().await;
2034        tools.write_file("a.md", "alpha").await.unwrap();
2035        tools.copy_file("a.md", "b.md").await.unwrap();
2036        assert_eq!(tools.read_file("a.md").await.unwrap(), "alpha");
2037        assert_eq!(tools.read_file("b.md").await.unwrap(), "alpha");
2038    }
2039
2040    #[tokio::test]
2041    async fn edit_file_search_replace_commits() {
2042        let (_tmp, tools) = setup().await;
2043        tools.write_file("a.md", "hello world\n").await.unwrap();
2044        let edits = "<<<<<<< SEARCH\nhello world\n=======\nhi world\n>>>>>>> REPLACE\n";
2045        tools.edit_file("a.md", edits, None, false).await.unwrap();
2046        assert_eq!(tools.read_file("a.md").await.unwrap(), "hi world\n");
2047    }
2048
2049    #[tokio::test]
2050    async fn edit_file_dry_run_does_not_commit() {
2051        let (_tmp, tools) = setup().await;
2052        tools.write_file("a.md", "hello\n").await.unwrap();
2053        let head_before = head_oid(&tools);
2054        let edits = "<<<<<<< SEARCH\nhello\n=======\nbye\n>>>>>>> REPLACE\n";
2055        let _ = tools.edit_file("a.md", edits, None, true).await.unwrap();
2056        assert_eq!(head_oid(&tools), head_before, "no commit on dry_run");
2057        assert_eq!(tools.read_file("a.md").await.unwrap(), "hello\n");
2058    }
2059
2060    /// turbovault-6sj / TV-011: edit_file's returned `old_hash`/`new_hash`
2061    /// must be 40-char git blob OIDs on the git backend (NOT 64-char SHA-256).
2062    /// Without this, callers cannot use them as `expected_hash` on a follow-up
2063    /// call — the CAS round-trip breaks.
2064    #[tokio::test]
2065    async fn edit_file_returns_blob_oid_hashes_not_sha256() {
2066        let (_tmp, tools) = setup().await;
2067        tools.write_file("a.md", "hello\n").await.unwrap();
2068        let edits = "<<<<<<< SEARCH\nhello\n=======\nbye\n>>>>>>> REPLACE\n";
2069        let result = tools.edit_file("a.md", edits, None, false).await.unwrap();
2070        assert_eq!(
2071            result.old_hash.len(),
2072            40,
2073            "old_hash must be 40-char blob OID hex, got {:?}",
2074            result.old_hash
2075        );
2076        assert_eq!(
2077            result.new_hash.len(),
2078            40,
2079            "new_hash must be 40-char blob OID hex, got {:?}",
2080            result.new_hash
2081        );
2082        // Sanity: each hash equals the blob OID of the actual content.
2083        let expected_old = VaultRepo::blob_oid_of(b"hello\n").unwrap().to_string();
2084        let expected_new = VaultRepo::blob_oid_of(b"bye\n").unwrap().to_string();
2085        assert_eq!(result.old_hash, expected_old);
2086        assert_eq!(result.new_hash, expected_new);
2087    }
2088
2089    /// turbovault-6sj: the `new_hash` an edit returns must round-trip as
2090    /// `expected_hash` on the next call. This is the CAS contract the legacy
2091    /// path delivered; the git backend must do the same.
2092    #[tokio::test]
2093    async fn edit_file_new_hash_round_trips_as_expected_hash() {
2094        let (_tmp, tools) = setup().await;
2095        tools.write_file("a.md", "v1\n").await.unwrap();
2096        let edits1 = "<<<<<<< SEARCH\nv1\n=======\nv2\n>>>>>>> REPLACE\n";
2097        let r1 = tools.edit_file("a.md", edits1, None, false).await.unwrap();
2098        // Use r1.new_hash as the next expected_hash — must succeed because
2099        // no concurrent change has touched the file.
2100        let edits2 = "<<<<<<< SEARCH\nv2\n=======\nv3\n>>>>>>> REPLACE\n";
2101        let r2 = tools
2102            .edit_file("a.md", edits2, Some(&r1.new_hash), false)
2103            .await
2104            .unwrap();
2105        assert_eq!(
2106            r2.old_hash, r1.new_hash,
2107            "old_hash chains to prior new_hash"
2108        );
2109        assert_eq!(tools.read_file("a.md").await.unwrap(), "v3\n");
2110    }
2111
2112    /// turbovault-6sj: dry-run hashes must match what an actual apply would
2113    /// produce, so callers can use a preview's `new_hash` to plan the next
2114    /// `expected_hash`.
2115    #[tokio::test]
2116    async fn edit_file_dry_run_hashes_match_real_apply() {
2117        let (_tmp, tools) = setup().await;
2118        tools.write_file("a.md", "hello\n").await.unwrap();
2119        let edits = "<<<<<<< SEARCH\nhello\n=======\nbye\n>>>>>>> REPLACE\n";
2120        let dry = tools.edit_file("a.md", edits, None, true).await.unwrap();
2121        let live = tools.edit_file("a.md", edits, None, false).await.unwrap();
2122        assert_eq!(dry.old_hash, live.old_hash);
2123        assert_eq!(dry.new_hash, live.new_hash);
2124    }
2125
2126    /// turbovault-947: create_file on an absent path lands one commit.
2127    #[tokio::test]
2128    async fn create_file_writes_absent_path() {
2129        let (tmp, tools) = setup().await;
2130        let head_before = head_oid(&tools);
2131        tools.create_file("new.md", "fresh\n").await.unwrap();
2132        assert_eq!(tools.read_file("new.md").await.unwrap(), "fresh\n");
2133        let head_after = head_oid(&tools).unwrap();
2134        assert_ne!(Some(head_after), head_before, "create advanced HEAD");
2135        // Existence side-effect lands in the working tree.
2136        assert!(tmp.path().join("new.md").exists());
2137    }
2138
2139    /// turbovault-c0e: `WriteNote.expected_hash` carries an `expect_blob`
2140    /// precondition. A stale hash aborts the WHOLE batch (atomicity §6.3),
2141    /// not just that op — zero files land, HEAD unchanged. The substrate
2142    /// folds the apply-time ConcurrencyError into the returned `BatchResult`
2143    /// (matching the existing batch-failure shape) rather than surfacing
2144    /// it as `Err`.
2145    #[tokio::test]
2146    async fn batch_write_note_with_stale_expected_hash_aborts_atomically() {
2147        let (tmp, tools) = setup().await;
2148        tools.write_file("a.md", "v1\n").await.unwrap();
2149        let bogus = VaultRepo::blob_oid_of(b"NEVER_HERE").unwrap().to_string();
2150        let head_before = head_oid(&tools).unwrap();
2151
2152        let ops = vec![
2153            BatchOperation::CreateNote {
2154                path: "fresh.md".into(),
2155                content: "ok".into(),
2156                force: None,
2157            },
2158            BatchOperation::WriteNote {
2159                path: "a.md".into(),
2160                content: "v2\n".into(),
2161                expected_hash: Some(bogus),
2162            },
2163        ];
2164        let res = tools.batch_execute(ops).await.unwrap();
2165        assert!(!res.success, "batch reports failure");
2166        assert_eq!(res.executed, 0, "no op committed on abort");
2167        let any_concurrency = res.errors.iter().any(|e| e.contains("precondition failed"));
2168        assert!(
2169            any_concurrency,
2170            "expected precondition-failed error in result: {:?}",
2171            res.errors
2172        );
2173        // Atomic abort: fresh.md was NOT created; a.md is unchanged.
2174        assert!(!tmp.path().join("fresh.md").exists());
2175        assert_eq!(tools.read_file("a.md").await.unwrap(), "v1\n");
2176        assert_eq!(head_oid(&tools), Some(head_before), "no commit on abort");
2177    }
2178
2179    /// turbovault-c0e: matching `expected_hash` succeeds — the whole batch
2180    /// lands as one commit.
2181    #[tokio::test]
2182    async fn batch_write_note_with_matching_expected_hash_lands() {
2183        let (_tmp, tools) = setup().await;
2184        tools.write_file("a.md", "v1\n").await.unwrap();
2185        let current = VaultRepo::blob_oid_of(b"v1\n").unwrap().to_string();
2186        let head_before = head_oid(&tools);
2187
2188        let ops = vec![BatchOperation::WriteNote {
2189            path: "a.md".into(),
2190            content: "v2\n".into(),
2191            expected_hash: Some(current),
2192        }];
2193        let res = tools.batch_execute(ops).await.unwrap();
2194        assert!(res.success);
2195        assert_eq!(tools.read_file("a.md").await.unwrap(), "v2\n");
2196        assert_ne!(head_oid(&tools), head_before, "commit advanced HEAD");
2197    }
2198
2199    /// turbovault-c0e: `CreateNote { force: true }` drops `expect_absent`,
2200    /// behaving as a blind upsert. Existing content is replaced; the batch
2201    /// lands.
2202    #[tokio::test]
2203    async fn batch_create_note_force_true_is_blind_upsert() {
2204        let (_tmp, tools) = setup().await;
2205        tools.write_file("dup.md", "v1\n").await.unwrap();
2206        let ops = vec![BatchOperation::CreateNote {
2207            path: "dup.md".into(),
2208            content: "v2\n".into(),
2209            force: Some(true),
2210        }];
2211        let res = tools.batch_execute(ops).await.unwrap();
2212        assert!(res.success);
2213        assert_eq!(tools.read_file("dup.md").await.unwrap(), "v2\n");
2214    }
2215
2216    /// turbovault-947: create_file on an existing path fails its `expect_absent`
2217    /// precondition. ZERO commits land; the working tree is unchanged. This is
2218    /// the substrate guarantee for the concurrent-create race the MCP layer
2219    /// pre-check cannot close on its own.
2220    #[tokio::test]
2221    async fn create_file_aborts_on_existing_path() {
2222        let (tmp, tools) = setup().await;
2223        tools.write_file("dup.md", "v1\n").await.unwrap();
2224        let head_before = head_oid(&tools).unwrap();
2225
2226        let err = tools.create_file("dup.md", "v2\n").await.unwrap_err();
2227        assert!(
2228            matches!(err, Error::ConcurrencyError { .. }),
2229            "expected ConcurrencyError, got: {err:?}"
2230        );
2231        assert_eq!(
2232            tools.read_file("dup.md").await.unwrap(),
2233            "v1\n",
2234            "original content untouched on aborted create"
2235        );
2236        assert_eq!(head_oid(&tools), Some(head_before), "no commit on abort");
2237        // Working tree file count unchanged: only `dup.md` exists.
2238        assert!(tmp.path().join("dup.md").exists());
2239    }
2240
2241    #[tokio::test]
2242    async fn batch_execute_one_atomic_commit_all_op_types() {
2243        let (tmp, tools) = setup().await;
2244        // Seed for delete + move + update-links.
2245        tools.write_file("seed_del.md", "gone").await.unwrap();
2246        tools.write_file("seed_mv.md", "moveme").await.unwrap();
2247        tools
2248            .write_file("links.md", "see [[old-target]]")
2249            .await
2250            .unwrap();
2251        let head_before = head_oid(&tools);
2252
2253        let ops = vec![
2254            BatchOperation::CreateNote {
2255                path: "new1.md".into(),
2256                content: "C1".into(),
2257                force: None,
2258            },
2259            BatchOperation::WriteNote {
2260                path: "new2.md".into(),
2261                content: "W2".into(),
2262                expected_hash: None,
2263            },
2264            BatchOperation::DeleteNote {
2265                path: "seed_del.md".into(),
2266                expected_hash: None,
2267                on_backlinks: None,
2268            },
2269            BatchOperation::MoveNote {
2270                from: "seed_mv.md".into(),
2271                to: "moved.md".into(),
2272                expected_hash: None,
2273                update_backlinks: None,
2274            },
2275            BatchOperation::UpdateLinks {
2276                file: "links.md".into(),
2277                old_target: "old-target".into(),
2278                new_target: "new-target".into(),
2279                expected_hash: None,
2280            },
2281        ];
2282
2283        let res = tools.batch_execute(ops).await.unwrap();
2284        assert!(res.success, "batch should succeed: {:?}", res.errors);
2285        assert_eq!(res.executed, 5);
2286
2287        // HEAD advanced by exactly one commit (the batch landed as one txn).
2288        let head_after = head_oid(&tools).unwrap();
2289        assert_ne!(Some(head_after), head_before);
2290        let repo = git2::Repository::open(tmp.path()).unwrap();
2291        let commit = repo.find_commit(head_after).unwrap();
2292        assert_eq!(commit.parent_count(), 1, "exactly one new commit");
2293
2294        // Filesystem state matches.
2295        assert_eq!(
2296            std::fs::read_to_string(tmp.path().join("new1.md")).unwrap(),
2297            "C1"
2298        );
2299        assert_eq!(
2300            std::fs::read_to_string(tmp.path().join("new2.md")).unwrap(),
2301            "W2"
2302        );
2303        assert!(!tmp.path().join("seed_del.md").exists());
2304        assert!(!tmp.path().join("seed_mv.md").exists());
2305        assert_eq!(
2306            std::fs::read_to_string(tmp.path().join("moved.md")).unwrap(),
2307            "moveme"
2308        );
2309        assert_eq!(
2310            std::fs::read_to_string(tmp.path().join("links.md")).unwrap(),
2311            "see [[new-target]]"
2312        );
2313    }
2314
2315    #[tokio::test]
2316    async fn batch_execute_failure_leaves_no_partial_state() {
2317        // CreateNote on a path that already exists -> precondition abort.
2318        // Atomicity contract: zero files from the batch should land.
2319        let (tmp, tools) = setup().await;
2320        tools.write_file("exists.md", "already").await.unwrap();
2321        let head_before = head_oid(&tools);
2322
2323        let ops = vec![
2324            BatchOperation::WriteNote {
2325                path: "untouched1.md".into(),
2326                content: "X".into(),
2327                expected_hash: None,
2328            },
2329            BatchOperation::CreateNote {
2330                path: "exists.md".into(),
2331                content: "boom".into(),
2332                force: None,
2333            },
2334            BatchOperation::WriteNote {
2335                path: "untouched2.md".into(),
2336                content: "Y".into(),
2337                expected_hash: None,
2338            },
2339        ];
2340
2341        let res = tools.batch_execute(ops).await.unwrap();
2342        assert!(!res.success);
2343        // Neither untouched file was written; the existing file is unchanged.
2344        assert!(!tmp.path().join("untouched1.md").exists());
2345        assert!(!tmp.path().join("untouched2.md").exists());
2346        assert_eq!(tools.read_file("exists.md").await.unwrap(), "already");
2347        assert_eq!(head_oid(&tools), head_before, "no commit on abort");
2348    }
2349
2350    #[tokio::test]
2351    async fn batch_execute_empty_is_a_loud_failure() {
2352        let (_tmp, tools) = setup().await;
2353        let res = tools.batch_execute(vec![]).await.unwrap();
2354        assert!(!res.success);
2355        assert_eq!(res.total, 0);
2356    }
2357
2358    // -------- GWS.14b: CAS-collision flush --------
2359
2360    #[tokio::test]
2361    async fn cas_collision_flush_fires_before_concurrency_error_returns() {
2362        // Wire a sentinel flush callback that flips an Arc<AtomicBool> so we
2363        // can prove flush ran BEFORE the error reached the caller.
2364        let tmp = TempDir::new().unwrap();
2365        init_repo(tmp.path());
2366        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
2367        let locks = Arc::new(CommitLocks::new());
2368        let commit_hook: CommitHook = Arc::new(|_p, _c| {});
2369
2370        let flushed = Arc::new(std::sync::atomic::AtomicBool::new(false));
2371        let flushed_clone = Arc::clone(&flushed);
2372        let flush: CasCollisionFlush = Arc::new(move || {
2373            let f = Arc::clone(&flushed_clone);
2374            Box::pin(async move {
2375                f.store(true, std::sync::atomic::Ordering::SeqCst);
2376                Ok(())
2377            })
2378        });
2379
2380        let tools = GitFileTools::new_with_hook_and_flush(
2381            manager,
2382            tmp.path().to_path_buf(),
2383            locks,
2384            commit_hook,
2385            flush,
2386        );
2387
2388        // Trigger a guaranteed precondition failure: write v1, then update
2389        // with a stale expected blob.
2390        tools.write_file("a.md", "v1").await.unwrap();
2391        let stale_oid = VaultRepo::blob_oid_of(b"WAS_NEVER_HERE").unwrap();
2392        let err = tools
2393            .write_file_with_mode(
2394                "a.md",
2395                "v2",
2396                WriteMode::Overwrite,
2397                Some(&stale_oid.to_string()),
2398            )
2399            .await
2400            .unwrap_err();
2401        assert!(
2402            matches!(err, Error::ConcurrencyError { .. }),
2403            "got: {err:?}"
2404        );
2405        assert!(
2406            flushed.load(std::sync::atomic::Ordering::SeqCst),
2407            "flush callback must fire before the ConcurrencyError surfaces to the caller"
2408        );
2409    }
2410
2411    /// turbovault-9zr: the full batch path. `batch_execute` must fire the
2412    /// commit hook (enqueue exactly one commit), and draining that commit must
2413    /// produce the one -> two link edge in the graph.
2414    #[tokio::test]
2415    async fn batch_execute_enqueues_and_reindexes_intra_commit_edge() {
2416        let tmp = TempDir::new().unwrap();
2417        init_repo(tmp.path());
2418        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
2419        let locks = Arc::new(CommitLocks::new());
2420
2421        let queue = Arc::new(crate::ReindexQueue::new());
2422        let q = Arc::clone(&queue);
2423        let commit_hook: CommitHook = Arc::new(move |_p, c| q.push(c));
2424        let flush: CasCollisionFlush = Arc::new(|| Box::pin(async { Ok(()) }));
2425        let tools = GitFileTools::new_with_hook_and_flush(
2426            Arc::clone(&manager),
2427            tmp.path().to_path_buf(),
2428            locks,
2429            commit_hook,
2430            flush,
2431        );
2432
2433        tools
2434            .batch_execute(vec![
2435                BatchOperation::CreateNote {
2436                    path: "one.md".to_string(),
2437                    content: "# One\n\nlinks [[two]]\n".to_string(),
2438                    force: None,
2439                },
2440                BatchOperation::CreateNote {
2441                    path: "two.md".to_string(),
2442                    content: "# Two\n".to_string(),
2443                    force: None,
2444                },
2445            ])
2446            .await
2447            .unwrap();
2448
2449        assert_eq!(
2450            queue.pending_count(),
2451            1,
2452            "batch_execute should enqueue exactly one commit"
2453        );
2454
2455        let repo = VaultRepo::open(tmp.path()).unwrap();
2456        queue.drain_through(&repo, &manager).await.unwrap();
2457
2458        assert_eq!(
2459            manager.link_graph().read().await.edge_count(),
2460            1,
2461            "drained batch commit should produce the one -> two edge"
2462        );
2463    }
2464
2465    #[tokio::test]
2466    async fn cas_collision_flush_skipped_on_successful_write() {
2467        let tmp = TempDir::new().unwrap();
2468        init_repo(tmp.path());
2469        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
2470        let locks = Arc::new(CommitLocks::new());
2471        let commit_hook: CommitHook = Arc::new(|_p, _c| {});
2472
2473        let flush_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2474        let flush_calls_clone = Arc::clone(&flush_calls);
2475        let flush: CasCollisionFlush = Arc::new(move || {
2476            let c = Arc::clone(&flush_calls_clone);
2477            Box::pin(async move {
2478                c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2479                Ok(())
2480            })
2481        });
2482
2483        let tools = GitFileTools::new_with_hook_and_flush(
2484            manager,
2485            tmp.path().to_path_buf(),
2486            locks,
2487            commit_hook,
2488            flush,
2489        );
2490
2491        tools.write_file("a.md", "alpha").await.unwrap();
2492        tools.write_file("b.md", "beta").await.unwrap();
2493        assert_eq!(
2494            flush_calls.load(std::sync::atomic::Ordering::SeqCst),
2495            0,
2496            "flush only fires on ConcurrencyError, never on successful writes"
2497        );
2498    }
2499
2500    #[tokio::test]
2501    async fn cas_collision_flush_error_does_not_mask_original_concurrency_error() {
2502        // Even when the flush callback itself errors, the caller still sees
2503        // the original ConcurrencyError — flush failures are logged + dropped
2504        // (correctness contract).
2505        let tmp = TempDir::new().unwrap();
2506        init_repo(tmp.path());
2507        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
2508        let locks = Arc::new(CommitLocks::new());
2509        let commit_hook: CommitHook = Arc::new(|_p, _c| {});
2510
2511        let flush: CasCollisionFlush =
2512            Arc::new(|| Box::pin(async { Err(Error::config_error("simulated flush failure")) }));
2513
2514        let tools = GitFileTools::new_with_hook_and_flush(
2515            manager,
2516            tmp.path().to_path_buf(),
2517            locks,
2518            commit_hook,
2519            flush,
2520        );
2521
2522        tools.write_file("a.md", "v1").await.unwrap();
2523        let stale_oid = VaultRepo::blob_oid_of(b"WAS_NEVER_HERE").unwrap();
2524        let err = tools
2525            .write_file_with_mode(
2526                "a.md",
2527                "v2",
2528                WriteMode::Overwrite,
2529                Some(&stale_oid.to_string()),
2530            )
2531            .await
2532            .unwrap_err();
2533        // Caller sees the original ConcurrencyError, NOT the flush's
2534        // ConfigError. Flush failures are best-effort.
2535        assert!(
2536            matches!(err, Error::ConcurrencyError { .. }),
2537            "got: {err:?}"
2538        );
2539    }
2540
2541    // -------- turbovault-0bh: caller-supplied commit messages --------
2542
2543    #[tokio::test]
2544    async fn write_file_with_mode_and_message_uses_caller_subject() {
2545        let (_tmp, tools) = setup().await;
2546        tools
2547            .write_file_with_mode_and_message(
2548                "a.md",
2549                "alpha",
2550                WriteMode::Overwrite,
2551                None,
2552                "add concept page for Alpha",
2553            )
2554            .await
2555            .unwrap();
2556        let msg = head_commit_message(&tools);
2557        assert!(msg.contains("add concept page for Alpha"), "got: {msg:?}");
2558    }
2559
2560    #[tokio::test]
2561    async fn create_file_with_message_uses_caller_subject() {
2562        let (_tmp, tools) = setup().await;
2563        tools
2564            .create_file_with_message("new.md", "fresh", "create stub page")
2565            .await
2566            .unwrap();
2567        let msg = head_commit_message(&tools);
2568        assert!(msg.contains("create stub page"), "got: {msg:?}");
2569    }
2570
2571    #[tokio::test]
2572    async fn edit_file_with_message_uses_caller_subject() {
2573        let (_tmp, tools) = setup().await;
2574        tools.write_file("a.md", "hello\n").await.unwrap();
2575        let edits = "<<<<<<< SEARCH\nhello\n=======\nbye\n>>>>>>> REPLACE\n";
2576        let _ = tools
2577            .edit_file_with_message("a.md", edits, None, false, "fix greeting")
2578            .await
2579            .unwrap();
2580        let msg = head_commit_message(&tools);
2581        assert!(msg.contains("fix greeting"), "got: {msg:?}");
2582    }
2583
2584    #[tokio::test]
2585    async fn delete_file_with_hash_and_message_uses_caller_subject() {
2586        let (_tmp, tools) = setup().await;
2587        tools.write_file("a.md", "v").await.unwrap();
2588        tools
2589            .delete_file_with_hash_and_message("a.md", None, "remove superseded page")
2590            .await
2591            .unwrap();
2592        let msg = head_commit_message(&tools);
2593        assert!(msg.contains("remove superseded page"), "got: {msg:?}");
2594    }
2595
2596    #[tokio::test]
2597    async fn move_file_with_hash_and_message_uses_caller_subject() {
2598        let (_tmp, tools) = setup().await;
2599        tools.write_file("a.md", "v").await.unwrap();
2600        tools
2601            .move_file_with_hash_and_message("a.md", "b.md", None, "rename to canonical slug")
2602            .await
2603            .unwrap();
2604        let msg = head_commit_message(&tools);
2605        assert!(msg.contains("rename to canonical slug"), "got: {msg:?}");
2606    }
2607
2608    #[tokio::test]
2609    async fn batch_execute_with_message_uses_caller_subject() {
2610        let (_tmp, tools) = setup().await;
2611        let ops = vec![
2612            BatchOperation::CreateNote {
2613                path: "x.md".into(),
2614                content: "x".into(),
2615                force: None,
2616            },
2617            BatchOperation::CreateNote {
2618                path: "y.md".into(),
2619                content: "y".into(),
2620                force: None,
2621            },
2622        ];
2623        tools
2624            .batch_execute_with_message(ops, "ingest source S: 2 concept pages")
2625            .await
2626            .unwrap();
2627        let msg = head_commit_message(&tools);
2628        assert!(
2629            msg.contains("ingest source S: 2 concept pages"),
2630            "got: {msg:?}"
2631        );
2632    }
2633
2634    /// Auto-derived fallback still says `batch_execute (N ops)` when no
2635    /// message is supplied (legacy behavior preserved).
2636    #[tokio::test]
2637    async fn batch_execute_auto_derive_unchanged() {
2638        let (_tmp, tools) = setup().await;
2639        let ops = vec![BatchOperation::CreateNote {
2640            path: "x.md".into(),
2641            content: "x".into(),
2642            force: None,
2643        }];
2644        tools.batch_execute(ops).await.unwrap();
2645        let msg = head_commit_message(&tools);
2646        assert!(msg.contains("batch_execute (1 ops)"), "got: {msg:?}");
2647    }
2648
2649    // -------- turbovault-lqr: atomic move + wikilink rewrite --------
2650
2651    /// turbovault-lqr: move with one backlinking source. The rename AND
2652    /// the link rewrite land as ONE commit. HEAD advances by exactly one
2653    /// commit touching both paths.
2654    #[tokio::test]
2655    async fn move_with_link_updates_atomic_one_commit() {
2656        let (tmp, tools) = setup().await;
2657        tools.write_file("old.md", "# Old\n").await.unwrap();
2658        tools
2659            .write_file("linker.md", "I link to [[old]] here.\n")
2660            .await
2661            .unwrap();
2662        // Initialize link graph from the seeded files so backlinks resolve.
2663        tools.manager.initialize().await.unwrap();
2664        let head_before = head_oid(&tools).unwrap();
2665
2666        let result = tools
2667            .move_file_with_link_updates("old.md", "new.md", None, "rename old -> new")
2668            .await
2669            .unwrap();
2670        assert_eq!(result.link_sources_updated, vec!["linker.md".to_string()]);
2671        // Working tree state.
2672        assert!(!tmp.path().join("old.md").exists());
2673        assert_eq!(
2674            std::fs::read_to_string(tmp.path().join("new.md")).unwrap(),
2675            "# Old\n"
2676        );
2677        assert_eq!(
2678            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
2679            "I link to [[new]] here.\n"
2680        );
2681        // Exactly one new commit touched both files.
2682        let head_after = head_oid(&tools).unwrap();
2683        assert_ne!(head_after, head_before);
2684        let repo = git2::Repository::open(&tools.vault_path).unwrap();
2685        let commit = repo.find_commit(head_after).unwrap();
2686        assert_eq!(commit.parent_count(), 1, "single parent");
2687    }
2688
2689    /// turbovault-lqr: move with multiple backlinking sources. All link
2690    /// rewrites + the rename land in one commit.
2691    #[tokio::test]
2692    async fn move_with_link_updates_handles_multiple_sources() {
2693        let (tmp, tools) = setup().await;
2694        tools.write_file("old.md", "# Old\n").await.unwrap();
2695        tools
2696            .write_file("a.md", "see [[old|the page]]\n")
2697            .await
2698            .unwrap();
2699        tools
2700            .write_file("b.md", "embed: ![[old]]\nsection: [[old#Header]]\n")
2701            .await
2702            .unwrap();
2703        // turbovault-34p: a source where "old" is a SUBSTRING of unrelated words
2704        // (golden, oldie) AND that also links to a page we must NOT touch
2705        // ([[keeper]]). A substring/too-greedy rewrite would corrupt these; only
2706        // the [[old]] wikilink may change.
2707        tools
2708            .write_file("c.md", "golden oldie [[old]] keep [[keeper]]\n")
2709            .await
2710            .unwrap();
2711        tools.manager.initialize().await.unwrap();
2712
2713        let result = tools
2714            .move_file_with_link_updates("old.md", "new.md", None, "rename")
2715            .await
2716            .unwrap();
2717        let mut updated = result.link_sources_updated.clone();
2718        updated.sort();
2719        assert_eq!(
2720            updated,
2721            vec!["a.md".to_string(), "b.md".to_string(), "c.md".to_string()]
2722        );
2723        assert_eq!(
2724            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
2725            "see [[new|the page]]\n"
2726        );
2727        assert_eq!(
2728            std::fs::read_to_string(tmp.path().join("b.md")).unwrap(),
2729            "embed: ![[new]]\nsection: [[new#Header]]\n"
2730        );
2731        // Only the [[old]] wikilink changed: "golden"/"oldie" + [[keeper]] intact.
2732        assert_eq!(
2733            std::fs::read_to_string(tmp.path().join("c.md")).unwrap(),
2734            "golden oldie [[new]] keep [[keeper]]\n"
2735        );
2736    }
2737
2738    /// turbovault-uag: git-backend Prepend into a note WITH frontmatter inserts
2739    /// AFTER the `---` block (never above it); Append goes to the end. The legacy
2740    /// `test_file_tools` suite covered only the legacy path; the git-backend
2741    /// resolve_write_content + find_frontmatter_end path was uncovered.
2742    #[tokio::test]
2743    async fn git_prepend_after_frontmatter_and_append_at_end() {
2744        let (tmp, tools) = setup().await;
2745        tools
2746            .write_file("n.md", "---\ntitle: T\n---\n\nbody line\n")
2747            .await
2748            .unwrap();
2749
2750        // Prepend lands below the closing `---`, above the body.
2751        tools
2752            .write_file_with_mode("n.md", "PRE", WriteMode::Prepend, None)
2753            .await
2754            .unwrap();
2755        assert_eq!(
2756            tools.read_file("n.md").await.unwrap(),
2757            "---\ntitle: T\n---\nPRE\nbody line\n",
2758            "prepend must not push above the frontmatter"
2759        );
2760
2761        // Append lands at the very end.
2762        tools
2763            .write_file_with_mode("n.md", "POST", WriteMode::Append, None)
2764            .await
2765            .unwrap();
2766        let after = tools.read_file("n.md").await.unwrap();
2767        assert_eq!(after, "---\ntitle: T\n---\nPRE\nbody line\n\nPOST");
2768        // Working tree == HEAD.
2769        assert_eq!(
2770            std::fs::read_to_string(tmp.path().join("n.md")).unwrap(),
2771            after
2772        );
2773    }
2774
2775    /// Advance the branch ref + change `file`'s blob via a bare git2 commit,
2776    /// then materialize that commit as the external writer would.
2777    fn external_commit_change(repo_path: &StdPath, file: &str, content: &str) {
2778        let commit = {
2779            let repo = git2::Repository::open(repo_path).unwrap();
2780            let head = repo.head().unwrap();
2781            let branch = head.shorthand().unwrap().to_string();
2782            let parent = head.peel_to_commit().unwrap();
2783            let mut tb = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
2784            let blob = repo.blob(content.as_bytes()).unwrap();
2785            tb.insert(file, blob, 0o100644).unwrap();
2786            let tree = repo.find_tree(tb.write().unwrap()).unwrap();
2787            let sig = git2::Signature::now("Ext", "ext@x").unwrap();
2788            repo.commit(
2789                Some(&format!("refs/heads/{branch}")),
2790                &sig,
2791                &sig,
2792                "external",
2793                &tree,
2794                &[&parent],
2795            )
2796            .unwrap()
2797        };
2798
2799        VaultRepo::open(repo_path)
2800            .unwrap()
2801            .materialize(commit, &[file.to_string()])
2802            .unwrap();
2803    }
2804
2805    /// turbovault-xw4: the CACHED `VaultRepo` handle (PERF-1) must still detect
2806    /// a SEPARATE-PROCESS ref advance and reject a stale-precondition write — no
2807    /// lost update. Prior coverage proved this for a raw handle (cas.rs) but not
2808    /// at the cached GitFileTools seam, which is the exact safety question PERF-1
2809    /// raised.
2810    #[tokio::test]
2811    async fn cached_handle_detects_external_ref_advance() {
2812        let (tmp, tools) = setup_cached().await;
2813        tools.write_file("a.md", "v1").await.unwrap();
2814        let v1 = VaultRepo::blob_oid_of(b"v1").unwrap().to_string();
2815
2816        // Another process advances the ref + rewrites a.md's blob.
2817        external_commit_change(tmp.path(), "a.md", "EXTERNAL");
2818
2819        // The cached handle must re-read the ref under lock and REJECT the write
2820        // carrying the now-stale precondition.
2821        let err = tools
2822            .write_file_with_mode("a.md", "v2", WriteMode::Overwrite, Some(&v1))
2823            .await
2824            .unwrap_err();
2825        assert!(
2826            matches!(err, Error::ConcurrencyError { .. }),
2827            "stale precondition must surface ConcurrencyError, got: {err:?}"
2828        );
2829        // The external commit is still HEAD — our stale write did not clobber it.
2830        let repo = git2::Repository::open(tmp.path()).unwrap();
2831        let head = repo.head().unwrap().peel_to_commit().unwrap();
2832        assert_eq!(
2833            head.message().unwrap(),
2834            "external",
2835            "external commit survived; no lost update"
2836        );
2837    }
2838
2839    /// turbovault-oz6: atomic delete + wrap-as-stale across multiple
2840    /// linkers. One commit; target gone; sources strikethrough-wrapped.
2841    #[tokio::test]
2842    async fn delete_with_link_rewrite_to_stale_wraps_all_linkers() {
2843        let (tmp, tools) = setup().await;
2844        tools.write_file("doomed.md", "# Doomed").await.unwrap();
2845        tools
2846            .write_file("a.md", "see [[doomed]] for details\n")
2847            .await
2848            .unwrap();
2849        tools
2850            .write_file("b.md", "another ref ![[doomed#Sec]]\n")
2851            .await
2852            .unwrap();
2853        tools.manager.initialize().await.unwrap();
2854        let head_before = head_oid(&tools).unwrap();
2855
2856        let result = tools
2857            .delete_file_with_link_rewrite_to_stale("doomed.md", None, "kill doomed")
2858            .await
2859            .unwrap();
2860        let mut updated = result.link_sources_updated.clone();
2861        updated.sort();
2862        assert_eq!(updated, vec!["a.md".to_string(), "b.md".to_string()]);
2863        // Target gone.
2864        assert!(!tmp.path().join("doomed.md").exists());
2865        // Sources wrapped.
2866        assert_eq!(
2867            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
2868            "see ~~[[doomed]]~~ for details\n"
2869        );
2870        assert_eq!(
2871            std::fs::read_to_string(tmp.path().join("b.md")).unwrap(),
2872            "another ref ~~![[doomed#Sec]]~~\n"
2873        );
2874        // Single commit.
2875        let head_after = head_oid(&tools).unwrap();
2876        assert_ne!(head_after, head_before);
2877        let repo = git2::Repository::open(&tools.vault_path).unwrap();
2878        let commit = repo.find_commit(head_after).unwrap();
2879        assert_eq!(commit.parent_count(), 1);
2880    }
2881
2882    /// turbovault-oz6: list_inbound_backlinks returns the linkers the
2883    /// MCP layer uses to decide whether to refuse, rewrite-stale, or
2884    /// force-delete.
2885    #[tokio::test]
2886    async fn list_inbound_backlinks_returns_linkers() {
2887        let (_tmp, tools) = setup().await;
2888        tools.write_file("doomed.md", "# Doomed").await.unwrap();
2889        tools
2890            .write_file("linker.md", "see [[doomed]]")
2891            .await
2892            .unwrap();
2893        tools
2894            .write_file("unrelated.md", "no links here")
2895            .await
2896            .unwrap();
2897        tools.manager.initialize().await.unwrap();
2898
2899        let mut bls = tools.list_inbound_backlinks("doomed.md").await.unwrap();
2900        bls.sort();
2901        assert_eq!(bls, vec!["linker.md".to_string()]);
2902    }
2903
2904    /// turbovault-lqr: a source modified between the read and the apply
2905    /// fails its expect_blob precondition, aborting the entire move —
2906    /// zero files change.
2907    #[tokio::test]
2908    async fn move_with_link_updates_aborts_on_stale_source() {
2909        let (tmp, tools) = setup().await;
2910        tools.write_file("old.md", "# Old\n").await.unwrap();
2911        tools
2912            .write_file("linker.md", "see [[old]]\n")
2913            .await
2914            .unwrap();
2915        tools.manager.initialize().await.unwrap();
2916
2917        // Simulate stale read: an external commit mutates linker.md
2918        // between the link-graph lookup and the substrate apply. We
2919        // approximate that by using a stale expected_hash on the
2920        // source — substrate aborts identically.
2921        // Compute a bogus oid to feed as expected_hash.
2922        let bogus_oid = VaultRepo::blob_oid_of(b"NEVER_HERE_LQR")
2923            .unwrap()
2924            .to_string();
2925        let head_before = head_oid(&tools).unwrap();
2926        let res = tools
2927            .move_file_with_link_updates("old.md", "new.md", Some(&bogus_oid), "should abort")
2928            .await;
2929        let err = res.unwrap_err();
2930        assert!(
2931            matches!(err, Error::ConcurrencyError { .. }),
2932            "expected ConcurrencyError, got: {err:?}"
2933        );
2934        // Working tree unchanged.
2935        assert!(tmp.path().join("old.md").exists());
2936        assert!(!tmp.path().join("new.md").exists());
2937        assert_eq!(
2938            std::fs::read_to_string(tmp.path().join("linker.md")).unwrap(),
2939            "see [[old]]\n"
2940        );
2941        assert_eq!(head_oid(&tools), Some(head_before), "no commit on abort");
2942    }
2943}