pocket_cli/vcs/
repository.rs

1//! Repository management for Pocket VCS
2//!
3//! Handles creation, opening, and basic operations on repositories.
4
5use std::path::{Path, PathBuf};
6use std::fs;
7use chrono::Utc;
8use serde::{Serialize, Deserialize};
9use anyhow::{Result, anyhow};
10use thiserror::Error;
11use walkdir;
12use glob;
13
14use crate::vcs::{
15    ObjectId, ObjectStore, ShoveId,
16    RepoStatus,
17    objects::{Tree, TreeEntry, EntryType},
18    shove::Shove,
19    Author,
20};
21use crate::vcs::pile::Pile;
22use crate::vcs::timeline::Timeline;
23
24/// Error types specific to repository operations
25#[derive(Error, Debug)]
26pub enum RepositoryError {
27    #[error("Repository already exists at {0}")]
28    AlreadyExists(PathBuf),
29    
30    #[error("Not a valid Pocket repository: {0}")]
31    NotARepository(PathBuf),
32    
33    #[error("Configuration error: {0}")]
34    ConfigError(String),
35}
36
37/// Repository configuration
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct Config {
40    pub user: UserConfig,
41    pub core: CoreConfig,
42    pub remote: RemoteConfig,
43}
44
45#[derive(Debug, Serialize, Deserialize, Clone)]
46pub struct UserConfig {
47    pub name: String,
48    pub email: String,
49}
50
51#[derive(Debug, Serialize, Deserialize, Clone)]
52pub struct CoreConfig {
53    pub default_timeline: String,
54    pub ignore_patterns: Vec<String>,
55}
56
57#[derive(Debug, Serialize, Deserialize, Clone)]
58pub struct RemoteConfig {
59    pub default_remote: Option<String>,
60}
61
62impl Default for Config {
63    fn default() -> Self {
64        Self {
65            user: UserConfig {
66                name: "Unknown User".to_string(),
67                email: "user@example.com".to_string(),
68            },
69            core: CoreConfig {
70                default_timeline: "main".to_string(),
71                ignore_patterns: vec![".DS_Store".to_string(), "*.log".to_string()],
72            },
73            remote: RemoteConfig {
74                default_remote: None,
75            },
76        }
77    }
78}
79
80/// Represents a Pocket VCS repository
81pub struct Repository {
82    /// Path to the repository root
83    pub path: PathBuf,
84    
85    /// Repository configuration
86    pub config: Config,
87    
88    /// Current timeline (branch)
89    pub current_timeline: Timeline,
90    
91    /// Current pile (staging area)
92    pub pile: Pile,
93    
94    /// Object storage
95    pub object_store: ObjectStore,
96}
97
98impl Repository {
99    /// Create a new repository at the given path
100    pub fn new(path: &Path) -> Result<Self> {
101        let repo_path = path.join(".pocket");
102        
103        // Check if repository already exists
104        if repo_path.exists() {
105            return Err(RepositoryError::AlreadyExists(repo_path).into());
106        }
107        
108        // Create repository directory structure
109        fs::create_dir_all(&repo_path)?;
110        fs::create_dir_all(repo_path.join("objects"))?;
111        fs::create_dir_all(repo_path.join("shoves"))?;
112        fs::create_dir_all(repo_path.join("timelines"))?;
113        fs::create_dir_all(repo_path.join("piles"))?;
114        fs::create_dir_all(repo_path.join("snapshots"))?;
115        
116        // Create default configuration
117        let config = Config::default();
118        let config_path = repo_path.join("config.toml");
119        let config_str = toml::to_string_pretty(&config)?;
120        fs::write(config_path, config_str)?;
121        
122        // Create initial timeline (main)
123        let timeline_path = repo_path.join("timelines").join("main.toml");
124        let timeline = Timeline::new("main", None);
125        let timeline_str = toml::to_string_pretty(&timeline)?;
126        fs::write(timeline_path, timeline_str)?;
127        
128        // Create HEAD file pointing to main timeline
129        fs::write(repo_path.join("HEAD"), "timeline: main\n")?;
130        
131        // Create empty pile
132        let pile = Pile::new();
133        
134        // Create object store
135        let object_store = ObjectStore::new(repo_path.join("objects"));
136        
137        Ok(Self {
138            path: path.to_path_buf(),
139            config,
140            current_timeline: timeline,
141            pile,
142            object_store,
143        })
144    }
145    
146    /// Open an existing repository
147    pub fn open(path: &Path) -> Result<Self> {
148        // Find repository root by looking for .pocket directory
149        let repo_root = Self::find_repository_root(path)?;
150        let repo_path = repo_root.join(".pocket");
151        
152        if !repo_path.exists() {
153            return Err(RepositoryError::NotARepository(path.to_path_buf()).into());
154        }
155        
156        // Load configuration
157        let config_path = repo_path.join("config.toml");
158        let config_str = fs::read_to_string(config_path)?;
159        let config: Config = toml::from_str(&config_str)?;
160        
161        // Read HEAD to determine current timeline
162        let head_content = fs::read_to_string(repo_path.join("HEAD"))?;
163        let timeline_name = if head_content.starts_with("timeline: ") {
164            head_content.trim_start_matches("timeline: ").trim()
165        } else {
166            return Err(anyhow!("Invalid HEAD format"));
167        };
168        
169        // Load current timeline
170        let timeline_path = repo_path.join("timelines").join(format!("{}.toml", timeline_name));
171        let timeline_str = fs::read_to_string(timeline_path)?;
172        let current_timeline: Timeline = toml::from_str(&timeline_str)?;
173        
174        // Load current pile
175        let pile = Pile::load(&repo_path.join("piles").join("current.toml"))?;
176        
177        // Create object store
178        let object_store = ObjectStore::new(repo_path.join("objects"));
179        
180        Ok(Self {
181            path: repo_root,
182            config,
183            current_timeline,
184            pile,
185            object_store,
186        })
187    }
188    
189    /// Find the repository root by traversing up the directory tree
190    fn find_repository_root(start_path: &Path) -> Result<PathBuf> {
191        let mut current = start_path.to_path_buf();
192        
193        loop {
194            if current.join(".pocket").exists() {
195                return Ok(current);
196            }
197            
198            if !current.pop() {
199                return Err(RepositoryError::NotARepository(start_path.to_path_buf()).into());
200            }
201        }
202    }
203    
204    /// Get the current status of the repository
205    pub fn status(&self) -> Result<RepoStatus> {
206        let mut modified_files = Vec::new();
207        let mut untracked_files = Vec::new();
208        let mut conflicts = Vec::new();
209
210        // Get the current tree from HEAD
211        let head_tree = if let Some(head) = &self.current_timeline.head {
212            let shove = Shove::load(&self.path.join(".pocket").join("shoves").join(format!("{}.toml", head.as_str())))?;
213            let tree_path = self.path.join(".pocket").join("objects").join(shove.root_tree_id.as_str());
214            if tree_path.exists() {
215                let tree_content = fs::read_to_string(&tree_path)?;
216                Some(toml::from_str::<Tree>(&tree_content)?)
217            } else {
218                None
219            }
220        } else {
221            None
222        };
223
224        // Walk the working directory
225        let walker = walkdir::WalkDir::new(&self.path)
226            .follow_links(false)
227            .into_iter()
228            .filter_entry(|e| !self.is_ignored(e.path()));
229
230        for entry in walker.filter_map(|e| e.ok()) {
231            let path = entry.path();
232            
233            // Skip the .pocket directory
234            if path.starts_with(self.path.join(".pocket")) {
235                continue;
236            }
237
238            // Only process files
239            if !entry.file_type().is_file() {
240                continue;
241            }
242
243            let relative_path = path.strip_prefix(&self.path)?.to_path_buf();
244
245            // Check if file is in current pile
246            if self.pile.entries.contains_key(&relative_path) {
247                continue;
248            }
249
250            // Check if file is tracked (exists in HEAD tree)
251            if let Some(ref head_tree) = head_tree {
252                let entry_path = relative_path.to_string_lossy().to_string();
253                if let Some(head_entry) = head_tree.entries.iter().find(|e| e.name == entry_path) {
254                    // File is tracked, check if modified
255                    let current_content = fs::read(path)?;
256                    let current_id = ObjectId::from_content(&current_content);
257                    
258                    if current_id != head_entry.id {
259                        modified_files.push(relative_path);
260                    }
261                } else {
262                    // File is not in HEAD tree
263                    untracked_files.push(relative_path);
264                }
265            } else {
266                // No HEAD tree, all files are untracked
267                untracked_files.push(relative_path);
268            }
269        }
270
271        // Check for conflicts
272        let conflicts_dir = self.path.join(".pocket").join("conflicts");
273        if conflicts_dir.exists() {
274            for entry in fs::read_dir(conflicts_dir)? {
275                let entry = entry?;
276                if entry.file_type()?.is_file() {
277                    conflicts.push(PathBuf::from(entry.file_name()));
278                }
279            }
280        }
281
282        Ok(RepoStatus {
283            current_timeline: self.current_timeline.name.clone(),
284            head_shove: self.current_timeline.head.clone(),
285            piled_files: self.pile.entries.values().cloned().collect(),
286            modified_files,
287            untracked_files,
288            conflicts,
289        })
290    }
291    
292    /// Check if a path should be ignored
293    fn is_ignored(&self, path: &Path) -> bool {
294        // Always ignore .pocket directory
295        if path.starts_with(self.path.join(".pocket")) {
296            return true;
297        }
298
299        // Check if .pocketignore file exists and read patterns from it
300        let ignore_path = self.path.join(".pocketignore");
301        let ignore_patterns = if ignore_path.exists() {
302            match std::fs::read_to_string(&ignore_path) {
303                Ok(content) => {
304                    content.lines()
305                        .filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
306                        .map(|line| line.trim().to_string())
307                        .collect::<Vec<String>>()
308                },
309                Err(_) => self.config.core.ignore_patterns.clone(),
310            }
311        } else {
312            self.config.core.ignore_patterns.clone()
313        };
314
315        // Check against ignore patterns
316        for pattern in &ignore_patterns {
317            if let Ok(matcher) = glob::Pattern::new(pattern) {
318                if let Ok(relative_path) = path.strip_prefix(&self.path) {
319                    if matcher.matches_path(relative_path) {
320                        return true;
321                    }
322                }
323            }
324        }
325
326        false
327    }
328    
329    /// Create a new shove (commit) from the current pile
330    pub fn create_shove(&mut self, message: &str) -> Result<ShoveId> {
331        // Create a tree from the pile
332        let tree = self.create_tree_from_pile()?;
333        let tree_id = self.object_store.store_tree(&tree)?;
334        
335        // Get parent shove(s)
336        let parent_ids = if let Some(head) = &self.current_timeline.head {
337            vec![head.clone()]
338        } else {
339            vec![]
340        };
341        
342        // Create the shove
343        let author = Author {
344            name: self.config.user.name.clone(),
345            email: self.config.user.email.clone(),
346            timestamp: Utc::now(),
347        };
348        
349        let shove = Shove::new(&self.pile, parent_ids, author, message, tree_id);
350        let shove_id = shove.id.clone();
351        
352        // Save the shove
353        let shove_path = self.path.join(".pocket").join("shoves").join(format!("{}.toml", shove.id.as_str()));
354        shove.save(&shove_path)?;
355        
356        // Update the current timeline
357        self.current_timeline.update_head(shove_id.clone());
358        let timeline_path = self.path.join(".pocket").join("timelines").join(format!("{}.toml", self.current_timeline.name));
359        self.current_timeline.save(&timeline_path)?;
360        
361        Ok(shove_id)
362    }
363    
364    /// Create a tree object from the current pile
365    fn create_tree_from_pile(&self) -> Result<Tree> {
366        let mut entries = Vec::new();
367        
368        for (path, entry) in &self.pile.entries {
369            let name = path.file_name()
370                .ok_or_else(|| anyhow!("Invalid path: {}", path.display()))?
371                .to_string_lossy()
372                .into_owned();
373                
374            let tree_entry = TreeEntry {
375                name,
376                id: entry.object_id.clone(),
377                entry_type: EntryType::File,
378                permissions: 0o644, // Default file permissions
379            };
380            
381            entries.push(tree_entry);
382        }
383        
384        Ok(Tree { entries })
385    }
386    
387    // Additional methods would be implemented here:
388    // - pile_file: Add file to pile
389    // - unpile_file: Remove file from pile
390    // - create_shove: Create a new shove (commit)
391    // - switch_timeline: Switch to a different timeline
392    // - create_timeline: Create a new timeline
393    // - merge_timeline: Merge another timeline into current
394    // - etc.
395}