Skip to main content

nap_core/
repository.rs

1//! Universe repository — filesystem layout and manifest CRUD.
2//!
3//! A NAP repository represents a single fictional universe.
4//! Repository structure:
5//!
6//! ```text
7//! starwars/               ← universe root (Git repo)
8//! ├── .nap/               ← NAP metadata
9//! │   └── config.yaml     ← repository config
10//! ├── universe.yaml       ← world manifest (root-level)
11//! ├── characters/
12//! │   ├── lukeskywalker.yaml
13//! │   └── darthvader.yaml
14//! ├── locations/
15//! │   └── tatooine.yaml
16//! ├── scenes/
17//! │   └── cantina-scene.yaml
18//! └── props/
19//! ```
20
21use std::path::{Path, PathBuf};
22
23use tracing::{debug, info};
24
25use crate::commit::{Change, Commit};
26use crate::error::NapError;
27use crate::manifest::Manifest;
28use crate::types::EntityType;
29use crate::uri::NapUri;
30use crate::vcs::VcsBackend;
31
32/// NAP metadata directory name.
33const NAP_DIR: &str = ".nap";
34
35/// A NAP universe repository.
36pub struct Repository {
37    /// Filesystem path to the repository root.
38    pub root: PathBuf,
39    /// The universe name (derived from directory name).
40    pub universe: String,
41    /// The VCS backend (Git, Fossil, etc.).
42    vcs: Box<dyn VcsBackend>,
43}
44
45impl Repository {
46    /// Open an existing NAP repository at the given path.
47    pub fn open(path: &Path, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
48        let nap_dir = path.join(NAP_DIR);
49        if !nap_dir.exists() {
50            return Err(NapError::RepositoryNotFound(path.display().to_string()));
51        }
52        let universe = path
53            .file_name()
54            .and_then(|n| n.to_str())
55            .unwrap_or("unknown")
56            .to_string();
57
58        debug!(
59            path = %path.display(),
60            universe = %universe,
61            "opened NAP repository"
62        );
63
64        Ok(Self {
65            root: path.to_path_buf(),
66            universe,
67            vcs,
68        })
69    }
70
71    /// Initialize a new NAP repository.
72    pub fn init(path: &Path, universe: &str, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
73        let repo_root = path.join(universe);
74        if repo_root.join(NAP_DIR).exists() {
75            return Err(NapError::RepositoryAlreadyExists(
76                repo_root.display().to_string(),
77            ));
78        }
79
80        info!(
81            path = %repo_root.display(),
82            universe = %universe,
83            "initializing NAP repository"
84        );
85
86        // Create directory structure
87        std::fs::create_dir_all(&repo_root)?;
88        std::fs::create_dir_all(repo_root.join(NAP_DIR))?;
89
90        // Create entity type subdirectories
91        for entity_type in EntityType::subdirectory_types() {
92            std::fs::create_dir_all(repo_root.join(entity_type.directory_name()))?;
93        }
94
95        // Create .nap/config.yaml
96        let config = format!(
97            "# NAP Repository Configuration\nuniverse: {universe}\nprotocol_version: \"0.1.0\"\n"
98        );
99        std::fs::write(repo_root.join(NAP_DIR).join("config.yaml"), config)?;
100
101        // Create universe.yaml (world manifest)
102        let world_manifest = Manifest::new(
103            universe,
104            EntityType::World,
105            universe,
106            &format!("{universe} Universe"),
107        );
108        world_manifest.to_file(&repo_root.join("universe.yaml"))?;
109
110        // Initialize VCS
111        vcs.init(&repo_root)?;
112
113        // Initial commit
114        vcs.commit(
115            &repo_root,
116            &format!("Initialize {universe} universe"),
117            "nap-init",
118        )?;
119
120        info!(
121            path = %repo_root.display(),
122            universe = %universe,
123            "NAP repository initialized successfully"
124        );
125
126        Ok(Self {
127            root: repo_root,
128            universe: universe.to_string(),
129            vcs,
130        })
131    }
132
133    /// Get the full filesystem path to an entity's manifest file.
134    pub fn manifest_path(&self, entity_type: EntityType, entity_id: &str) -> PathBuf {
135        let uri = NapUri::new(&self.universe, entity_type, entity_id);
136        self.root.join(uri.manifest_path())
137    }
138
139    /// Read a manifest from the repository.
140    pub fn read_manifest(
141        &self,
142        entity_type: EntityType,
143        entity_id: &str,
144    ) -> Result<Manifest, NapError> {
145        let path = self.manifest_path(entity_type, entity_id);
146        debug!(
147            path = %path.display(),
148            entity_type = %entity_type,
149            entity_id = %entity_id,
150            "reading manifest"
151        );
152        Manifest::from_file(&path)
153    }
154
155    /// Read a manifest at a specific VCS ref (commit, branch, tag).
156    pub fn read_manifest_at_ref(
157        &self,
158        entity_type: EntityType,
159        entity_id: &str,
160        reference: &str,
161    ) -> Result<Manifest, NapError> {
162        let uri = NapUri::new(&self.universe, entity_type, entity_id);
163        let file_path = uri.manifest_path();
164
165        debug!(
166            file_path = %file_path,
167            reference = %reference,
168            "reading manifest at ref"
169        );
170
171        let content = self
172            .vcs
173            .read_file_at_ref(&self.root, &file_path, Some(reference))?;
174        Manifest::from_yaml(&content)
175    }
176
177    /// Write a manifest to the repository (does NOT commit).
178    pub fn write_manifest(&self, manifest: &Manifest) -> Result<PathBuf, NapError> {
179        let uri: NapUri = manifest.id.parse()?;
180        let path = self.root.join(uri.manifest_path());
181
182        debug!(
183            path = %path.display(),
184            manifest_id = %manifest.id,
185            "writing manifest"
186        );
187
188        manifest.to_file(&path)?;
189        Ok(path)
190    }
191
192    /// Create a new entity manifest and commit it.
193    pub fn create_entity(
194        &self,
195        entity_type: EntityType,
196        entity_id: &str,
197        name: &str,
198        author: &str,
199    ) -> Result<(Manifest, String), NapError> {
200        let mut manifest = Manifest::new(&self.universe, entity_type, entity_id, name);
201
202        // Validate against schema before writing
203        crate::schema::validate_manifest(&manifest)
204            .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
205
206        // Write the manifest
207        self.write_manifest(&manifest)?;
208
209        // Commit via VCS
210        let commit_message = format!("Create {entity_type} '{name}' ({entity_id})");
211        let commit_hash = self.vcs.commit(&self.root, &commit_message, author)?;
212
213        // Update manifest with head pointer
214        manifest.head = Some(commit_hash.clone());
215        manifest.bump_version();
216        self.write_manifest(&manifest)?;
217
218        info!(
219            manifest_id = %manifest.id,
220            commit_hash = %commit_hash,
221            "created entity"
222        );
223
224        Ok((manifest, commit_hash))
225    }
226
227    /// Update an existing manifest and commit the changes.
228    pub fn commit_manifest(
229        &self,
230        manifest: &mut Manifest,
231        message: &str,
232        author: &str,
233        changes: Vec<Change>,
234    ) -> Result<Commit, NapError> {
235        // Validate against schema before writing
236        crate::schema::validate_manifest(manifest)
237            .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
238
239        // Bump version
240        manifest.bump_version();
241
242        // Write updated manifest (without new head — we don't know it yet)
243        self.write_manifest(manifest)?;
244
245        // Compute manifest hash
246        let manifest_hash = manifest.content_hash()?.as_str().to_string();
247
248        // VCS commit — produces the new HEAD hash
249        let vcs_hash = self.vcs.commit(&self.root, message, author)?;
250
251        // Create NAP commit object with the now-known VCS hash
252        let nap_commit = Commit::new(
253            manifest.head.clone(),
254            author,
255            message,
256            &manifest_hash,
257            changes,
258        );
259
260        // Update head pointer and write again (leaves working tree dirty,
261        // same pattern as create_entity)
262        manifest.head = Some(vcs_hash.clone());
263        self.write_manifest(manifest)?;
264
265        debug!(
266            manifest_id = %manifest.id,
267            version = manifest.version,
268            nap_commit_id = %nap_commit.id,
269            vcs_hash = %vcs_hash,
270            "manifest committed"
271        );
272
273        Ok(nap_commit)
274    }
275
276    /// Get the commit history for a specific entity.
277    pub fn history(
278        &self,
279        entity_type: EntityType,
280        entity_id: &str,
281        limit: usize,
282    ) -> Result<Vec<crate::vcs::CommitInfo>, NapError> {
283        let uri = NapUri::new(&self.universe, entity_type, entity_id);
284        let file_path = uri.manifest_path();
285        self.vcs.log(&self.root, Some(&file_path), limit)
286    }
287
288    /// List all entity IDs of a given type in the repository.
289    pub fn list_entities(&self, entity_type: EntityType) -> Result<Vec<String>, NapError> {
290        let dir = self.root.join(entity_type.directory_name());
291        if !dir.exists() {
292            return Ok(vec![]);
293        }
294
295        let mut entities = Vec::new();
296        for entry in std::fs::read_dir(&dir)? {
297            let entry = entry?;
298            let path = entry.path();
299            if path.extension().and_then(|e| e.to_str()) == Some("yaml")
300                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
301            {
302                entities.push(stem.to_string());
303            }
304        }
305        entities.sort();
306        Ok(entities)
307    }
308
309    /// Delete an entity manifest and commit the deletion.
310    pub fn delete_entity(
311        &self,
312        entity_type: EntityType,
313        entity_id: &str,
314        author: &str,
315    ) -> Result<String, NapError> {
316        let path = self.manifest_path(entity_type, entity_id);
317        if !path.exists() {
318            return Err(NapError::ManifestNotFound(path.display().to_string()));
319        }
320
321        std::fs::remove_file(&path)?;
322
323        let message = format!("Delete {entity_type} '{entity_id}'");
324        let hash = self.vcs.commit(&self.root, &message, author)?;
325        info!(entity_type = %entity_type, entity_id = %entity_id, "deleted entity");
326        Ok(hash)
327    }
328
329    /// Create a branch in the underlying VCS.
330    pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
331        self.vcs.create_branch(&self.root, name)
332    }
333
334    /// Switch to a branch.
335    pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
336        self.vcs.switch_branch(&self.root, name)
337    }
338
339    /// Create a tag.
340    pub fn create_tag(&self, name: &str) -> Result<(), NapError> {
341        self.vcs.create_tag(&self.root, name)
342    }
343
344    /// List branches.
345    pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
346        self.vcs.list_branches(&self.root)
347    }
348
349    /// List tags.
350    pub fn list_tags(&self) -> Result<Vec<String>, NapError> {
351        self.vcs.list_tags(&self.root)
352    }
353
354    /// Revert a commit by creating a new VCS commit that undoes the specified one.
355    ///
356    /// The revert is a universe-level operation (not entity-scoped) — a single
357    /// Git commit can touch multiple files across multiple entity types.  After
358    /// reverting, working-tree files are restored to their pre-commit content
359    /// and a new revert commit is created in VCS history.
360    pub fn revert_commit(&self, commit_hash: &str, author: &str) -> Result<String, NapError> {
361        let new_hash = self.vcs.revert(&self.root, commit_hash)?;
362
363        // Re-read all entity manifests and update their `head` pointer
364        // so manifests are consistent with the new VCS state.
365        for et in EntityType::subdirectory_types() {
366            if let Ok(ids) = self.list_entities(*et) {
367                for id in &ids {
368                    if let Ok(mut manifest) = self.read_manifest(*et, id) {
369                        manifest.head = Some(new_hash.clone());
370                        self.write_manifest(&manifest).ok();
371                    }
372                }
373            }
374        }
375
376        info!(
377            commit = %commit_hash,
378            revert = %new_hash,
379            author = %author,
380            "commit reverted"
381        );
382
383        Ok(new_hash)
384    }
385
386    /// Get current HEAD hash.
387    pub fn head_hash(&self) -> Result<String, NapError> {
388        self.vcs.head_hash(&self.root)
389    }
390
391    /// Resolve the most recent commit hash on a given branch.
392    pub fn resolve_branch_head(&self, branch: &str) -> Result<String, NapError> {
393        self.vcs.resolve_branch_head(&self.root, branch)
394    }
395
396    // ── Remote operations ─────────────────────────────────────────
397
398    /// Add a remote to the repository.
399    pub fn add_remote(&self, name: &str, url: &str) -> Result<(), NapError> {
400        self.vcs.add_remote(&self.root, name, url)
401    }
402
403    /// Remove a remote from the repository.
404    pub fn remove_remote(&self, name: &str) -> Result<(), NapError> {
405        self.vcs.remove_remote(&self.root, name)
406    }
407
408    /// List remotes as `(name, url)` pairs.
409    pub fn list_remotes(&self) -> Result<Vec<(String, String)>, NapError> {
410        self.vcs.list_remotes(&self.root)
411    }
412
413    /// Push the current branch to a remote.
414    pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
415        self.vcs.push(&self.root, remote, branch)
416    }
417
418    /// Pull the current branch from a remote.
419    pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
420        self.vcs.pull(&self.root, remote, branch)
421    }
422
423    /// Access the VCS backend (for the resolver to read files at specific refs).
424    pub fn vcs(&self) -> &dyn VcsBackend {
425        self.vcs.as_ref()
426    }
427}
428
429// ── In-memory mock VcsBackend for testing ──────────────────────────────
430
431#[cfg(test)]
432mod mock_backend {
433    use std::collections::HashMap;
434    use std::path::Path;
435    use std::sync::Mutex;
436    use std::sync::atomic::{AtomicU64, Ordering};
437
438    use crate::error::NapError;
439    use crate::vcs::{CommitInfo, VcsBackend};
440
441    /// A VcsBackend that simulates VCS operations in memory without shelling
442    /// out.  Used by the Repository unit tests so they don't require a real
443    /// `lore` binary.
444    pub(crate) struct MockBackend {
445        /// Incrementing counter for commit hashes.
446        counter: AtomicU64,
447        /// Stored commit metadata keyed by "hash".
448        commits: Mutex<Vec<CommitInfo>>,
449        /// Remote tracking.
450        remotes: Mutex<HashMap<String, String>>,
451        /// Branches.
452        branches: Mutex<Vec<String>>,
453        /// Current branch.
454        current_branch: Mutex<String>,
455        /// Tags.
456        tags: Mutex<Vec<String>>,
457    }
458
459    impl MockBackend {
460        pub fn new() -> Self {
461            Self {
462                counter: AtomicU64::new(1),
463                commits: Mutex::new(Vec::new()),
464                remotes: Mutex::new(HashMap::new()),
465                branches: Mutex::new(vec!["main".to_string()]),
466                current_branch: Mutex::new("main".to_string()),
467                tags: Mutex::new(Vec::new()),
468            }
469        }
470    }
471
472    impl VcsBackend for MockBackend {
473        fn init(&self, _path: &Path) -> Result<(), NapError> {
474            Ok(())
475        }
476
477        fn commit(&self, _path: &Path, message: &str, author: &str) -> Result<String, NapError> {
478            let n = self.counter.fetch_add(1, Ordering::SeqCst);
479            // Use a valid 40-char hex hash so manifest schema validation passes.
480            let hash = format!("{:064x}", n);
481            let mut commits = self.commits.lock().unwrap();
482            let parent = commits.last().map(|c| c.id.clone());
483            let info = CommitInfo {
484                id: hash.clone(),
485                parent,
486                author: author.to_string(),
487                message: message.to_string(),
488                timestamp: chrono::Utc::now().to_rfc3339(),
489            };
490            commits.push(info);
491            Ok(hash)
492        }
493
494        fn read_file_at_ref(
495            &self,
496            repo_path: &Path,
497            file_path: &str,
498            _reference: Option<&str>,
499        ) -> Result<String, NapError> {
500            let full_path = repo_path.join(file_path);
501            std::fs::read_to_string(&full_path)
502                .map_err(|e| NapError::Other(format!("mock: read failed: {}", e)))
503        }
504
505        fn log(
506            &self,
507            _path: &Path,
508            _file: Option<&str>,
509            _limit: usize,
510        ) -> Result<Vec<CommitInfo>, NapError> {
511            let commits = self.commits.lock().unwrap();
512            Ok(commits.clone())
513        }
514
515        fn create_branch(&self, _path: &Path, name: &str) -> Result<(), NapError> {
516            self.branches.lock().unwrap().push(name.to_string());
517            Ok(())
518        }
519
520        fn switch_branch(&self, _path: &Path, name: &str) -> Result<(), NapError> {
521            *self.current_branch.lock().unwrap() = name.to_string();
522            Ok(())
523        }
524
525        fn create_tag(&self, _path: &Path, name: &str) -> Result<(), NapError> {
526            let mut tags = self.tags.lock().unwrap();
527            if !tags.contains(&name.to_string()) {
528                tags.push(name.to_string());
529            }
530            Ok(())
531        }
532
533        fn current_branch(&self, _path: &Path) -> Result<String, NapError> {
534            Ok(self.current_branch.lock().unwrap().clone())
535        }
536
537        fn head_hash(&self, _path: &Path) -> Result<String, NapError> {
538            let commits = self.commits.lock().unwrap();
539            commits
540                .last()
541                .map(|c| c.id.clone())
542                .ok_or_else(|| NapError::VcsError("no commits".to_string()))
543        }
544
545        fn resolve_branch_head(&self, _path: &Path, _branch: &str) -> Result<String, NapError> {
546            // MockBackend stores commits in a flat list — use the last one
547            // regardless of branch name for test simplicity.
548            let commits = self.commits.lock().unwrap();
549            commits
550                .last()
551                .map(|c| c.id.clone())
552                .ok_or_else(|| NapError::VcsError("no commits on branch".to_string()))
553        }
554
555        fn revert(&self, _repo_path: &Path, commit_hash: &str) -> Result<String, NapError> {
556            let n = self.counter.fetch_add(1, Ordering::SeqCst);
557            let hash = format!("{:064x}", n);
558            let info = CommitInfo {
559                id: hash.clone(),
560                parent: Some(commit_hash.to_string()),
561                author: "revert".to_string(),
562                message: format!("revert {}", commit_hash),
563                timestamp: chrono::Utc::now().to_rfc3339(),
564            };
565            self.commits.lock().unwrap().push(info);
566            Ok(hash)
567        }
568
569        fn list_branches(&self, _path: &Path) -> Result<Vec<String>, NapError> {
570            Ok(self.branches.lock().unwrap().clone())
571        }
572
573        fn list_tags(&self, _path: &Path) -> Result<Vec<String>, NapError> {
574            Ok(self.tags.lock().unwrap().clone())
575        }
576
577        fn add_remote(&self, _path: &Path, name: &str, url: &str) -> Result<(), NapError> {
578            self.remotes
579                .lock()
580                .unwrap()
581                .insert(name.to_string(), url.to_string());
582            Ok(())
583        }
584
585        fn remove_remote(&self, _path: &Path, name: &str) -> Result<(), NapError> {
586            self.remotes.lock().unwrap().remove(name);
587            Ok(())
588        }
589
590        fn list_remotes(&self, _path: &Path) -> Result<Vec<(String, String)>, NapError> {
591            let remotes = self.remotes.lock().unwrap();
592            Ok(remotes
593                .iter()
594                .map(|(k, v)| (k.clone(), v.clone()))
595                .collect())
596        }
597
598        fn push(
599            &self,
600            _path: &Path,
601            _remote: Option<&str>,
602            _branch: Option<&str>,
603        ) -> Result<(), NapError> {
604            Ok(())
605        }
606
607        fn pull(
608            &self,
609            _path: &Path,
610            _remote: Option<&str>,
611            _branch: Option<&str>,
612        ) -> Result<(), NapError> {
613            Ok(())
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use mock_backend::MockBackend;
622    use tempfile::TempDir;
623
624    fn make_repo(tmp: &TempDir) -> Repository {
625        Repository::init(tmp.path(), "testverse", Box::new(MockBackend::new())).unwrap()
626    }
627
628    #[test]
629    fn test_init_creates_structure() {
630        let tmp = TempDir::new().unwrap();
631        let repo = make_repo(&tmp);
632
633        assert!(repo.root.join(".nap").exists());
634        assert!(repo.root.join("universe.yaml").exists());
635        assert!(repo.root.join("characters").exists());
636        assert!(repo.root.join("locations").exists());
637        assert!(repo.root.join("scenes").exists());
638        assert!(repo.root.join("props").exists());
639    }
640
641    #[test]
642    fn test_create_and_read_entity() {
643        let tmp = TempDir::new().unwrap();
644        let repo = make_repo(&tmp);
645
646        let (manifest, _hash) = repo
647            .create_entity(EntityType::Character, "hero", "The Hero", "test-author")
648            .unwrap();
649
650        assert_eq!(manifest.name, "The Hero");
651        assert_eq!(manifest.entity_type, EntityType::Character);
652
653        // Read it back
654        let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
655        assert_eq!(read_back.name, "The Hero");
656    }
657
658    #[test]
659    fn test_list_entities() {
660        let tmp = TempDir::new().unwrap();
661        let repo = make_repo(&tmp);
662
663        repo.create_entity(EntityType::Character, "alice", "Alice", "author")
664            .unwrap();
665        repo.create_entity(EntityType::Character, "bob", "Bob", "author")
666            .unwrap();
667
668        let chars = repo.list_entities(EntityType::Character).unwrap();
669        assert_eq!(chars, vec!["alice", "bob"]);
670    }
671
672    #[test]
673    fn test_commit_manifest_updates() {
674        let tmp = TempDir::new().unwrap();
675        let repo = make_repo(&tmp);
676
677        let (mut manifest, _) = repo
678            .create_entity(EntityType::Character, "hero", "The Hero", "author")
679            .unwrap();
680
681        // Modify and commit
682        manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
683        let changes = vec![Change::set("properties.species", None, "elf".to_string())];
684        let commit = repo
685            .commit_manifest(&mut manifest, "set species to elf", "author", changes)
686            .unwrap();
687
688        assert!(!commit.id.is_empty());
689        assert_eq!(commit.message, "set species to elf");
690
691        // Verify version incremented
692        let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
693        assert!(read_back.version >= 2);
694    }
695
696    #[test]
697    fn test_history() {
698        let tmp = TempDir::new().unwrap();
699        let repo = make_repo(&tmp);
700
701        let (mut manifest, _) = repo
702            .create_entity(EntityType::Character, "hero", "The Hero", "author")
703            .unwrap();
704
705        manifest.set_property(
706            "name",
707            serde_yaml::Value::String("Updated Hero".to_string()),
708        );
709        repo.commit_manifest(&mut manifest, "update name", "author", vec![])
710            .unwrap();
711
712        let hist = repo.history(EntityType::Character, "hero", 10).unwrap();
713        assert!(hist.len() >= 2);
714    }
715
716    #[test]
717    fn test_revert_commit() {
718        let tmp = TempDir::new().unwrap();
719        let repo = make_repo(&tmp);
720
721        // Create entity and note its name
722        let (mut manifest, _) = repo
723            .create_entity(EntityType::Character, "hero", "The Hero", "author")
724            .unwrap();
725        assert_eq!(manifest.name, "The Hero");
726
727        // Modify and commit
728        manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
729        let changes = vec![Change::set("properties.species", None, "elf".to_string())];
730        let _commit = repo
731            .commit_manifest(&mut manifest, "set species to elf", "author", changes)
732            .unwrap();
733
734        // Verify the property was set
735        let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
736        assert_eq!(
737            read_back.properties.get("species").and_then(|v| v.as_str()),
738            Some("elf")
739        );
740
741        // Get the VCS commit hash from the manifest's head pointer.
742        // After commit_manifest, this is the single VCS commit containing
743        // the property change (the head pointer update is left dirty).
744        let vcs_hash = read_back
745            .head
746            .as_ref()
747            .expect("head should be set after commit");
748
749        // Revert that VCS commit
750        let revert_hash = repo.revert_commit(vcs_hash, "author").unwrap();
751        assert!(!revert_hash.is_empty());
752
753        // Verify the manifest head was updated to the revert commit
754        let after_revert = repo.read_manifest(EntityType::Character, "hero").unwrap();
755        assert_eq!(after_revert.head.as_deref(), Some(revert_hash.as_str()));
756
757        // Verify the revert appears in history
758        let hist = repo.history(EntityType::Character, "hero", 10).unwrap();
759        assert!(hist.iter().any(|c| c.id == revert_hash));
760    }
761
762    #[test]
763    fn test_remote_operations() {
764        let tmp = TempDir::new().unwrap();
765        let repo = make_repo(&tmp);
766
767        repo.add_remote("origin", "git@github.com:user/repo.git")
768            .unwrap();
769        let remotes = repo.list_remotes().unwrap();
770        assert_eq!(remotes.len(), 1);
771        assert_eq!(remotes[0].0, "origin");
772
773        repo.remove_remote("origin").unwrap();
774        let remotes = repo.list_remotes().unwrap();
775        assert!(remotes.is_empty());
776    }
777}