Skip to main content

turbovault_tools/
write_tools.rs

1//! Backend-dispatching write surface (GWS.12).
2//!
3//! [`WriteTools`] wraps either the legacy [`FileTools`] + [`BatchTools`] pair
4//! or the git-backed [`GitFileTools`], chosen at construction from a vault's
5//! [`turbovault_core::config::WriteBackend`]. The MCP layer holds one
6//! `WriteTools` per vault and never branches on the backend itself.
7//!
8//! **Lifecycle:** this enum exists for the parallel window — Phase 2 of the
9//! git-substrate cutover (GWS.12 → GWS.15). At cutover (GWS.15) the `Legacy`
10//! arm is deleted, `WriteTools` collapses to bare `GitFileTools`, and the
11//! type either disappears or becomes a thin alias.
12
13use crate::batch_tools::BatchTools;
14use crate::file_tools::{FileTools, NoteInfo, WriteMode};
15use crate::git_file_tools::{CachedRepo, CasCollisionFlush, GitFileTools, MoveWithLinksResult};
16use std::path::PathBuf;
17use std::sync::Arc;
18use turbovault_batch::{BatchOperation, BatchResult};
19use turbovault_core::prelude::*;
20use turbovault_git::{CommitHook, CommitLocks};
21
22use turbovault_vault::{EditResult, VaultManager};
23
24/// Per-vault write surface. One dispatch site per method; the MCP layer is
25/// backend-agnostic.
26#[derive(Clone)]
27pub enum WriteTools {
28    /// Pre-cutover `VaultManager` mutators + `BatchExecutor`. Deletion target
29    /// at GWS.15.
30    Legacy { files: FileTools, batch: BatchTools },
31    /// `turbovault-git` substrate — every change is a commit.
32    Git(GitFileTools),
33}
34
35impl WriteTools {
36    /// Whether this dispatcher is backed by the atomic Git substrate.
37    pub fn is_git(&self) -> bool {
38        matches!(self, Self::Git(_))
39    }
40
41    /// Construct the legacy dispatch wrapping the existing `VaultManager`-backed
42    /// tools.
43    pub fn legacy(manager: Arc<VaultManager>) -> Self {
44        Self::Legacy {
45            files: FileTools::new(Arc::clone(&manager)),
46            batch: BatchTools::new(manager),
47        }
48    }
49
50    /// Construct the git-backed dispatch. `manager` is shared with the read
51    /// path; `vault_path` + `commit_locks` open a `VaultRepo` per call
52    /// (libgit2 is `!Sync`; see `GitFileTools` for why).
53    pub fn git(
54        manager: Arc<VaultManager>,
55        vault_path: PathBuf,
56        commit_locks: Arc<CommitLocks>,
57    ) -> Self {
58        Self::Git(GitFileTools::new(manager, vault_path, commit_locks))
59    }
60
61    /// Git-backed dispatch WITH a GWS.14 reindex hook installed on every
62    /// per-call `VaultRepo`. The MCP server uses this; bare `Self::git`
63    /// stays for tests / migrations that don't run the reindex stack.
64    pub fn git_with_hook(
65        manager: Arc<VaultManager>,
66        vault_path: PathBuf,
67        commit_locks: Arc<CommitLocks>,
68        commit_hook: CommitHook,
69    ) -> Self {
70        Self::Git(GitFileTools::new_with_hook(
71            manager,
72            vault_path,
73            commit_locks,
74            commit_hook,
75        ))
76    }
77
78    /// Git-backed dispatch with reindex hook AND CAS-collision flush
79    /// (GWS.14b). The flush runs before `apply_txn` returns a
80    /// `ConcurrencyError`, so the agent's re-read sees coherent derived
81    /// state.
82    pub fn git_with_hook_and_flush(
83        manager: Arc<VaultManager>,
84        vault_path: PathBuf,
85        commit_locks: Arc<CommitLocks>,
86        commit_hook: CommitHook,
87        flush_on_collision: CasCollisionFlush,
88    ) -> Self {
89        Self::Git(GitFileTools::new_with_hook_and_flush(
90            manager,
91            vault_path,
92            commit_locks,
93            commit_hook,
94            flush_on_collision,
95        ))
96    }
97
98    /// turbovault-lri: builder-style override for the underlying
99    /// [`GitFileTools::include_ignored`] policy. No-op on the legacy arm
100    /// (the legacy backend doesn't consult `.gitignore` at all). When
101    /// `false`, every mutation pre-checks each touched path against the
102    /// worktree's `.gitignore` matcher and refuses the changeset with
103    /// a typed error if any path would be ignored. Default `true`.
104    pub fn with_include_ignored(self, include_ignored: bool) -> Self {
105        match self {
106            Self::Git(g) => Self::Git(g.with_include_ignored(include_ignored)),
107            other => other,
108        }
109    }
110
111    /// turbovault-a0l (PERF-1): install the cached per-vault `VaultRepo` handle
112    /// on the git arm so writes reuse it instead of opening per call. No-op on
113    /// the legacy arm (no substrate handle).
114    pub fn with_cached_repo(self, cached_repo: CachedRepo) -> Self {
115        match self {
116            Self::Git(g) => Self::Git(g.with_cached_repo(cached_repo)),
117            other => other,
118        }
119    }
120
121    // -------- Reads (forwarded; both backends use working-tree bytes) --------
122
123    pub async fn read_file(&self, path: &str) -> Result<String> {
124        match self {
125            Self::Legacy { files, .. } => files.read_file(path).await,
126            Self::Git(g) => g.read_file(path).await,
127        }
128    }
129
130    pub async fn get_notes_info(&self, paths: &[String]) -> Result<Vec<NoteInfo>> {
131        match self {
132            Self::Legacy { files, .. } => files.get_notes_info(paths).await,
133            Self::Git(g) => g.get_notes_info(paths).await,
134        }
135    }
136
137    // -------- Writes --------
138
139    pub async fn write_file_with_mode(
140        &self,
141        path: &str,
142        content: &str,
143        mode: WriteMode,
144        expected_hash: Option<&str>,
145    ) -> Result<()> {
146        match self {
147            Self::Legacy { files, .. } => {
148                files
149                    .write_file_with_mode(path, content, mode, expected_hash)
150                    .await
151            }
152            Self::Git(g) => {
153                g.write_file_with_mode(path, content, mode, expected_hash)
154                    .await
155            }
156        }
157    }
158
159    pub async fn write_file(&self, path: &str, content: &str) -> Result<()> {
160        match self {
161            Self::Legacy { files, .. } => files.write_file(path, content).await,
162            Self::Git(g) => g.write_file(path, content).await,
163        }
164    }
165
166    /// Strict create (turbovault-947 / write-note CAS-by-default).
167    ///
168    /// **Git backend:** the substrate's `Changeset::create` carries an
169    /// `expect_absent` precondition — a concurrent winner makes the loser's
170    /// CAS fail loudly with `ConcurrencyError`. This is the safety the
171    /// MCP layer's pre-check cannot provide on its own (TOCTOU window).
172    ///
173    /// **Legacy backend:** delegates to `write_file` (best-effort; legacy
174    /// has no atomic create primitive). The MCP layer's pre-check is the
175    /// only protection — concurrent creates can still race. Known limit of
176    /// the legacy path; documented, not fixed (per the legacy-stays
177    /// direction).
178    pub async fn create_file(&self, path: &str, content: &str) -> Result<()> {
179        match self {
180            Self::Legacy { files, .. } => files.write_file(path, content).await,
181            Self::Git(g) => g.create_file(path, content).await,
182        }
183    }
184
185    // -------- turbovault-0bh: caller-supplied commit message variants --------
186    //
187    // Each `_with_message` method behaves identically to its base sibling
188    // except that on the git backend the caller's `message` becomes the
189    // commit subject (and body, when newline-separated). Legacy backend
190    // silently ignores `message` — legacy writes don't produce commits.
191
192    pub async fn write_file_with_mode_and_message(
193        &self,
194        path: &str,
195        content: &str,
196        mode: WriteMode,
197        expected_hash: Option<&str>,
198        message: &str,
199    ) -> Result<()> {
200        match self {
201            Self::Legacy { files, .. } => {
202                files
203                    .write_file_with_mode(path, content, mode, expected_hash)
204                    .await
205            }
206            Self::Git(g) => {
207                g.write_file_with_mode_and_message(path, content, mode, expected_hash, message)
208                    .await
209            }
210        }
211    }
212
213    pub async fn create_file_with_message(
214        &self,
215        path: &str,
216        content: &str,
217        message: &str,
218    ) -> Result<()> {
219        match self {
220            Self::Legacy { files, .. } => files.write_file(path, content).await,
221            Self::Git(g) => g.create_file_with_message(path, content, message).await,
222        }
223    }
224
225    pub async fn edit_file_with_message(
226        &self,
227        path: &str,
228        edits: &str,
229        expected_hash: Option<&str>,
230        dry_run: bool,
231        message: &str,
232    ) -> Result<EditResult> {
233        match self {
234            Self::Legacy { files, .. } => {
235                files.edit_file(path, edits, expected_hash, dry_run).await
236            }
237            Self::Git(g) => {
238                g.edit_file_with_message(path, edits, expected_hash, dry_run, message)
239                    .await
240            }
241        }
242    }
243
244    pub async fn delete_file_with_hash_and_message(
245        &self,
246        path: &str,
247        expected_hash: Option<&str>,
248        message: &str,
249    ) -> Result<()> {
250        match self {
251            Self::Legacy { files, .. } => files.delete_file_with_hash(path, expected_hash).await,
252            Self::Git(g) => {
253                g.delete_file_with_hash_and_message(path, expected_hash, message)
254                    .await
255            }
256        }
257    }
258
259    pub async fn move_file_with_hash_and_message(
260        &self,
261        from: &str,
262        to: &str,
263        expected_hash: Option<&str>,
264        message: &str,
265    ) -> Result<()> {
266        match self {
267            Self::Legacy { files, .. } => files.move_file_with_hash(from, to, expected_hash).await,
268            Self::Git(g) => {
269                g.move_file_with_hash_and_message(from, to, expected_hash, message)
270                    .await
271            }
272        }
273    }
274
275    /// turbovault-oz6: list inbound backlinks for a path. Both backends
276    /// resolve via the same in-memory link graph (kept coherent by the
277    /// substrate's CommitHook + drainer / external-ref listener for git;
278    /// kept manually-coherent by VaultManager mutators for legacy).
279    pub async fn list_inbound_backlinks(&self, path: &str) -> Result<Vec<String>> {
280        match self {
281            Self::Git(g) => g.list_inbound_backlinks(path).await,
282            Self::Legacy { files, .. } => {
283                let bls = files
284                    .manager
285                    .get_backlinks(std::path::Path::new(path))
286                    .await?;
287                let vault_root = files.manager.vault_path().clone();
288                let mut out = Vec::new();
289                for full in bls {
290                    let rel = full
291                        .strip_prefix(&vault_root)
292                        .map(|p| p.to_path_buf())
293                        .unwrap_or_else(|_| full.clone());
294                    if let Some(s) = rel.to_str() {
295                        out.push(s.to_string());
296                    }
297                }
298                Ok(out)
299            }
300        }
301    }
302
303    /// turbovault-oz6: atomic delete + inbound-wikilink wrap-as-stale.
304    /// **Git backend only** — legacy refuses loudly (no atomic multi-file
305    /// primitive).
306    pub async fn delete_file_with_link_rewrite_to_stale(
307        &self,
308        path: &str,
309        expected_hash: Option<&str>,
310        message: &str,
311    ) -> Result<MoveWithLinksResult> {
312        match self {
313            Self::Legacy { .. } => Err(Error::config_error(
314                "Atomic delete + wikilink wrap-as-stale requires write_backend=git. The legacy backend has no multi-file atomic primitive; use force=true on the legacy delete (rename-only — links will dangle) or switch to git.",
315            )),
316            Self::Git(g) => {
317                g.delete_file_with_link_rewrite_to_stale(path, expected_hash, message)
318                    .await
319            }
320        }
321    }
322
323    /// turbovault-lqr: atomic move + inbound-wikilink rewrite.
324    /// **Git backend only** — legacy refuses loudly (no atomic multi-file
325    /// primitive; the substrate's killer feature that the legacy path
326    /// cannot match).
327    pub async fn move_file_with_link_updates(
328        &self,
329        from: &str,
330        to: &str,
331        expected_hash: Option<&str>,
332        message: &str,
333    ) -> Result<MoveWithLinksResult> {
334        match self {
335            Self::Legacy { .. } => Err(Error::config_error(
336                "Atomic move + wikilink update requires write_backend=git. The legacy backend has no multi-file atomic primitive; use the legacy `move_file` flow (rename only; links will dangle) or switch to git.",
337            )),
338            Self::Git(g) => {
339                g.move_file_with_link_updates(from, to, expected_hash, message)
340                    .await
341            }
342        }
343    }
344
345    pub async fn batch_execute_with_message(
346        &self,
347        operations: Vec<BatchOperation>,
348        message: &str,
349    ) -> Result<BatchResult> {
350        match self {
351            Self::Legacy { batch, .. } => {
352                legacy_batch_refusal(&operations)?;
353                // Legacy doesn't commit; message ignored.
354                batch.batch_execute(operations).await
355            }
356            Self::Git(g) => g.batch_execute_with_message(operations, message).await,
357        }
358    }
359
360    pub async fn edit_file(
361        &self,
362        path: &str,
363        edits: &str,
364        expected_hash: Option<&str>,
365        dry_run: bool,
366    ) -> Result<EditResult> {
367        match self {
368            Self::Legacy { files, .. } => {
369                files.edit_file(path, edits, expected_hash, dry_run).await
370            }
371            Self::Git(g) => g.edit_file(path, edits, expected_hash, dry_run).await,
372        }
373    }
374
375    pub async fn delete_file(&self, path: &str) -> Result<()> {
376        match self {
377            Self::Legacy { files, .. } => files.delete_file(path).await,
378            Self::Git(g) => g.delete_file(path).await,
379        }
380    }
381
382    pub async fn delete_file_with_hash(
383        &self,
384        path: &str,
385        expected_hash: Option<&str>,
386    ) -> Result<()> {
387        match self {
388            Self::Legacy { files, .. } => files.delete_file_with_hash(path, expected_hash).await,
389            Self::Git(g) => g.delete_file_with_hash(path, expected_hash).await,
390        }
391    }
392
393    pub async fn move_file(&self, from: &str, to: &str) -> Result<()> {
394        match self {
395            Self::Legacy { files, .. } => files.move_file(from, to).await,
396            Self::Git(g) => g.move_file(from, to).await,
397        }
398    }
399
400    pub async fn move_file_with_hash(
401        &self,
402        from: &str,
403        to: &str,
404        expected_hash: Option<&str>,
405    ) -> Result<()> {
406        match self {
407            Self::Legacy { files, .. } => files.move_file_with_hash(from, to, expected_hash).await,
408            Self::Git(g) => g.move_file_with_hash(from, to, expected_hash).await,
409        }
410    }
411
412    pub async fn copy_file(&self, from: &str, to: &str) -> Result<()> {
413        match self {
414            Self::Legacy { files, .. } => files.copy_file(from, to).await,
415            Self::Git(g) => g.copy_file(from, to).await,
416        }
417    }
418
419    pub async fn batch_execute(&self, operations: Vec<BatchOperation>) -> Result<BatchResult> {
420        match self {
421            Self::Legacy { batch, .. } => {
422                // turbovault-c0e / 0g4: legacy backend has no per-op CAS
423                // primitive and no git-only ops (per the legacy-stays direction
424                // in turbovault-6fo.16). Refuse loudly rather than silently
425                // dropping the precondition or partially applying.
426                legacy_batch_refusal(&operations)?;
427                batch.batch_execute(operations).await
428            }
429            Self::Git(g) => g.batch_execute(operations).await,
430        }
431    }
432}
433
434/// turbovault-0g4: index + name of the first git-substrate-only op in a batch
435/// (one with no legacy executor equivalent — see
436/// [`turbovault_batch::BatchOperation::git_only_kind`]), or `None` if every op
437/// is legacy-capable.
438fn first_git_only_op(operations: &[BatchOperation]) -> Option<(usize, &'static str)> {
439    operations
440        .iter()
441        .enumerate()
442        .find_map(|(i, op)| op.git_only_kind().map(|kind| (i, kind)))
443}
444
445/// turbovault-0g4 + c0e: the two refusals the legacy batch dispatch performs
446/// upfront (zero side effects), in priority order:
447/// 1. git-substrate-only ops (no legacy equivalent), then
448/// 2. per-op CAS preconditions (no legacy batch-level CAS).
449///
450/// Refusing here — rather than letting the executor partially apply or return
451/// a softer `BatchResult { success: false }` — keeps `write_backend=legacy`
452/// behavior unchanged and the error shape consistent across both refusals.
453fn legacy_batch_refusal(operations: &[BatchOperation]) -> Result<()> {
454    if let Some((idx, kind)) = first_git_only_op(operations) {
455        return Err(Error::config_error(format!(
456            "BatchOperation at index {idx} ({kind}) requires write_backend=git; the legacy batch executor has no equivalent. Switch the vault to the git backend to use it."
457        )));
458    }
459    // The legacy executor performs its best-effort preflight validation for
460    // expected_hash values. It is not cross-process atomic, but preserving
461    // that compatibility is preferable to rejecting batches that worked
462    // before the Git backend was introduced.
463    Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use tempfile::TempDir;
470    use turbovault_core::config::{ServerConfig, VaultConfig};
471    use turbovault_vault::VaultManager;
472
473    fn test_server_config(vault_dir: &std::path::Path, name: &str) -> ServerConfig {
474        let mut cfg = ServerConfig::new();
475        cfg.vaults
476            .push(VaultConfig::builder(name, vault_dir).build().unwrap());
477        cfg
478    }
479
480    async fn legacy_tools(tmp: &TempDir) -> WriteTools {
481        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path(), "l")).unwrap());
482        WriteTools::legacy(manager)
483    }
484
485    async fn git_tools(tmp: &TempDir) -> WriteTools {
486        let mut opts = git2::RepositoryInitOptions::new();
487        opts.initial_head("main");
488        git2::Repository::init_opts(tmp.path(), &opts).unwrap();
489        let manager = Arc::new(VaultManager::new(test_server_config(tmp.path(), "g")).unwrap());
490        let locks = Arc::new(CommitLocks::new());
491        WriteTools::git(manager, tmp.path().to_path_buf(), locks)
492    }
493
494    #[tokio::test]
495    async fn legacy_dispatch_writes_and_reads_back() {
496        let tmp = TempDir::new().unwrap();
497        let tools = legacy_tools(&tmp).await;
498        tools.write_file("a.md", "alpha").await.unwrap();
499        assert_eq!(tools.read_file("a.md").await.unwrap(), "alpha");
500    }
501
502    #[tokio::test]
503    async fn git_dispatch_writes_and_reads_back() {
504        let tmp = TempDir::new().unwrap();
505        let tools = git_tools(&tmp).await;
506        tools.write_file("a.md", "alpha").await.unwrap();
507        assert_eq!(tools.read_file("a.md").await.unwrap(), "alpha");
508        // Git backend → commit landed (HEAD points somewhere).
509        let repo = git2::Repository::open(tmp.path()).unwrap();
510        assert!(repo.head().is_ok(), "HEAD now exists");
511        assert!(matches!(tools, WriteTools::Git(_)));
512    }
513
514    /// turbovault-947: git dispatch carries `expect_absent` on create — a
515    /// second writer for the same path loses with `ConcurrencyError`.
516    #[tokio::test]
517    async fn git_create_file_aborts_on_existing_path() {
518        let tmp = TempDir::new().unwrap();
519        let tools = git_tools(&tmp).await;
520        tools.write_file("dup.md", "v1").await.unwrap();
521        let err = tools.create_file("dup.md", "v2").await.unwrap_err();
522        assert!(
523            matches!(err, Error::ConcurrencyError { .. }),
524            "got: {err:?}"
525        );
526        assert_eq!(tools.read_file("dup.md").await.unwrap(), "v1");
527    }
528
529    /// Legacy retains its best-effort expected-hash preflight for backwards
530    /// compatibility. The Git backend is required for cross-process atomicity.
531    #[tokio::test]
532    async fn legacy_batch_honors_per_op_precondition_preflight() {
533        let tmp = TempDir::new().unwrap();
534        let tools = legacy_tools(&tmp).await;
535        let ops = vec![BatchOperation::WriteNote {
536            path: "a.md".into(),
537            content: "v".into(),
538            expected_hash: Some("0123456789abcdef0123456789abcdef01234567".into()),
539        }];
540        let result = tools.batch_execute(ops).await.unwrap();
541        assert!(!result.success);
542        assert!(!tmp.path().join("a.md").exists());
543    }
544
545    /// turbovault-0g4.1: a git-substrate-only op (EditNote) in a legacy batch
546    /// is refused with a clear write_backend=git message, and NO earlier op is
547    /// applied (validate() rejects upfront, zero side effects). Keeps the
548    /// legacy backend's behavior unchanged for users who never had these ops.
549    #[tokio::test]
550    async fn legacy_batch_refuses_git_only_edit_note() {
551        let tmp = TempDir::new().unwrap();
552        let tools = legacy_tools(&tmp).await;
553        let ops = vec![
554            BatchOperation::WriteNote {
555                path: "kept.md".into(),
556                content: "v".into(),
557                expected_hash: None,
558            },
559            BatchOperation::EditNote {
560                path: "kept.md".into(),
561                edits: "<<<<<<< SEARCH\nv\n=======\nw\n>>>>>>> REPLACE".into(),
562                expected_hash: None,
563            },
564        ];
565        let err = tools.batch_execute(ops).await.unwrap_err();
566        let msg = err.to_string();
567        assert!(
568            msg.contains("write_backend=git") && msg.contains("EditNote"),
569            "expected git-only refusal, got: {msg}"
570        );
571        // validate() refuses upfront: the earlier WriteNote never landed.
572        assert!(
573            !tmp.path().join("kept.md").exists(),
574            "no op applied on a refused legacy batch"
575        );
576    }
577
578    /// turbovault-c0e: precondition-FREE batches still pass through to the
579    /// legacy executor unchanged.
580    #[tokio::test]
581    async fn legacy_batch_passes_through_when_no_preconditions() {
582        let tmp = TempDir::new().unwrap();
583        let tools = legacy_tools(&tmp).await;
584        let ops = vec![BatchOperation::WriteNote {
585            path: "a.md".into(),
586            content: "v".into(),
587            expected_hash: None,
588        }];
589        let res = tools.batch_execute(ops).await.unwrap();
590        assert!(res.success);
591    }
592
593    /// turbovault-947: legacy dispatch has no atomic create primitive — the
594    /// fallback is `write_file` which blind-overwrites. Documented limit;
595    /// the MCP layer's pre-check is the only protection on legacy.
596    #[tokio::test]
597    async fn legacy_create_file_is_blind_fallback() {
598        let tmp = TempDir::new().unwrap();
599        let tools = legacy_tools(&tmp).await;
600        tools.write_file("dup.md", "v1").await.unwrap();
601        // Legacy intentionally allows this — known limit.
602        tools.create_file("dup.md", "v2").await.unwrap();
603        assert_eq!(tools.read_file("dup.md").await.unwrap(), "v2");
604    }
605
606    #[tokio::test]
607    async fn dispatch_observably_different_for_batch_atomicity() {
608        // Same failing batch: legacy leaves partial state, git leaves none.
609        // Trigger = MoveNote from a non-existent source — both backends fail
610        // on the read, but at different points in the apply pipeline.
611        let make_ops = || {
612            vec![
613                BatchOperation::WriteNote {
614                    path: "first.md".into(),
615                    content: "F".into(),
616                    expected_hash: None,
617                },
618                BatchOperation::MoveNote {
619                    from: "missing.md".into(),
620                    to: "anywhere.md".into(),
621                    expected_hash: None,
622                    update_backlinks: None,
623                },
624                BatchOperation::WriteNote {
625                    path: "third.md".into(),
626                    content: "T".into(),
627                    expected_hash: None,
628                },
629            ]
630        };
631
632        let l_tmp = TempDir::new().unwrap();
633        let l = legacy_tools(&l_tmp).await;
634        let l_res = l.batch_execute(make_ops()).await.unwrap();
635        assert!(!l_res.success);
636        // Legacy: `first.md` landed before the failed move -> partial state
637        // (the defect the substrate replaces).
638        assert!(
639            l_tmp.path().join("first.md").exists(),
640            "legacy leaves partial state behind"
641        );
642
643        let g_tmp = TempDir::new().unwrap();
644        let g = git_tools(&g_tmp).await;
645        let g_res = g.batch_execute(make_ops()).await.unwrap();
646        assert!(!g_res.success);
647        assert!(
648            !g_tmp.path().join("first.md").exists(),
649            "git substrate aborts atomically — no partial state"
650        );
651        assert!(!g_tmp.path().join("third.md").exists());
652    }
653}