1use 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#[derive(Debug, Clone, serde::Serialize)]
30pub struct MoveWithLinksResult {
31 pub from: String,
32 pub to: String,
33 pub link_sources_updated: Vec<String>,
36}
37
38pub type CasCollisionFlush = Arc<dyn Fn() -> BoxFuture<'static, Result<()>> + Send + Sync>;
48
49pub type CachedRepo = Arc<std::sync::Mutex<VaultRepo>>;
59
60#[derive(Clone)]
69pub struct GitFileTools {
70 pub manager: Arc<VaultManager>,
71 pub vault_path: PathBuf,
72 pub commit_locks: Arc<CommitLocks>,
73 pub commit_hook: Option<CommitHook>,
79 pub flush_on_collision: Option<CasCollisionFlush>,
85 pub include_ignored: bool,
91 pub cached_repo: Option<CachedRepo>,
98}
99
100impl GitFileTools {
101 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 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 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 pub fn with_include_ignored(mut self, include_ignored: bool) -> Self {
166 self.include_ignored = include_ignored;
167 self
168 }
169
170 pub fn with_cached_repo(mut self, cached_repo: CachedRepo) -> Self {
177 self.cached_repo = Some(cached_repo);
178 self
179 }
180
181 pub async fn read_file(&self, path: &str) -> Result<String> {
186 self.manager.read_file(&PathBuf::from(path)).await
187 }
188
189 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 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 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 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 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 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 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 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 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 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(¤t, &blocks, dry_run)?;
314 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 pub async fn delete_file(&self, path: &str) -> Result<()> {
332 self.delete_file_with_hash(path, None).await
333 }
334
335 pub async fn delete_file_with_hash(
337 &self,
338 path: &str,
339 expected_hash: Option<&str>,
340 ) -> Result<()> {
341 self.delete_file_with_hash_and_message(
344 path,
345 expected_hash,
346 &format!("delete_file {}", path),
347 )
348 .await
349 }
350
351 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 pub async fn move_file(&self, from: &str, to: &str) -> Result<()> {
369 self.move_file_with_hash(from, to, None).await
370 }
371
372 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 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 txn = txn.expect_absent(to);
408 self.apply_txn(&txn).await
409 }
410
411 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(), link_sources_updated,
439 })
440 }
441
442 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 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 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 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 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 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 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 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 pub async fn batch_execute(&self, operations: Vec<BatchOperation>) -> Result<BatchResult> {
662 self.batch_execute_inner(operations, None).await
663 }
664
665 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 async fn translate_op(&self, txn: Changeset, op: &BatchOperation) -> Result<Changeset> {
679 Ok(match op {
685 BatchOperation::CreateNote {
686 path,
687 content,
688 force,
689 } => {
690 if force.unwrap_or(false) {
691 txn.upsert(path, content.as_bytes())
696 } else {
697 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 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 "force" => remove_expecting(txn, path, expected),
796 "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 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 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 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(¤t, &blocks, false)?;
865 upsert_expecting(txn, path, new_content.into_bytes(), expected_hash)
866 }
867
868 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 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 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 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 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 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 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 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 let txn = txn.clone();
1139 let include_ignored = self.include_ignored;
1140 let result = match &self.cached_repo {
1141 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 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 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
1200fn 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
1233fn 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
1250fn 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 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
1300fn 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
1320fn 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 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 #[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 tools.write_file("b.md", "B").await.unwrap();
1468 assert_eq!(tools.read_file("b.md").await.unwrap(), "B");
1469 }
1470
1471 #[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 #[tokio::test]
1498 async fn batch_abort_marks_records_not_applied() {
1499 let (tmp, tools) = setup().await;
1500 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 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 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 assert!(!tmp.path().join("ghost.md").exists(), "ghost not created");
1542 assert_eq!(tools.read_file("s1.md").await.unwrap(), "v1");
1543 }
1544
1545 #[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 #[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 assert_eq!(tools.read_file("src.md").await.unwrap(), "body");
1605 assert!(!tmp.path().join("dest.md").exists());
1606 }
1607
1608 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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, }];
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 #[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 #[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, }];
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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 #[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 assert!(tmp.path().join("new.md").exists());
2137 }
2138
2139 #[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 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 #[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 #[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 #[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 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 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 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 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 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 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 #[tokio::test]
2361 async fn cas_collision_flush_fires_before_concurrency_error_returns() {
2362 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 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 #[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 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 assert!(
2536 matches!(err, Error::ConcurrencyError { .. }),
2537 "got: {err:?}"
2538 );
2539 }
2540
2541 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 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 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 assert_eq!(
2770 std::fs::read_to_string(tmp.path().join("n.md")).unwrap(),
2771 after
2772 );
2773 }
2774
2775 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 #[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 external_commit_change(tmp.path(), "a.md", "EXTERNAL");
2818
2819 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 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 #[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 assert!(!tmp.path().join("doomed.md").exists());
2865 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 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 #[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 #[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 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 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}