1use 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#[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#[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
80pub struct Repository {
82 pub path: PathBuf,
84
85 pub config: Config,
87
88 pub current_timeline: Timeline,
90
91 pub pile: Pile,
93
94 pub object_store: ObjectStore,
96}
97
98impl Repository {
99 pub fn new(path: &Path) -> Result<Self> {
101 let repo_path = path.join(".pocket");
102
103 if repo_path.exists() {
105 return Err(RepositoryError::AlreadyExists(repo_path).into());
106 }
107
108 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 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 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 fs::write(repo_path.join("HEAD"), "timeline: main\n")?;
130
131 let pile = Pile::new();
133
134 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 pub fn open(path: &Path) -> Result<Self> {
148 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 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 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 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 let pile = Pile::load(&repo_path.join("piles").join("current.toml"))?;
176
177 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 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 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 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 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 if path.starts_with(self.path.join(".pocket")) {
235 continue;
236 }
237
238 if !entry.file_type().is_file() {
240 continue;
241 }
242
243 let relative_path = path.strip_prefix(&self.path)?.to_path_buf();
244
245 if self.pile.entries.contains_key(&relative_path) {
247 continue;
248 }
249
250 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 let current_content = fs::read(path)?;
256 let current_id = ObjectId::from_content(¤t_content);
257
258 if current_id != head_entry.id {
259 modified_files.push(relative_path);
260 }
261 } else {
262 untracked_files.push(relative_path);
264 }
265 } else {
266 untracked_files.push(relative_path);
268 }
269 }
270
271 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 fn is_ignored(&self, path: &Path) -> bool {
294 if path.starts_with(self.path.join(".pocket")) {
296 return true;
297 }
298
299 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 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 pub fn create_shove(&mut self, message: &str) -> Result<ShoveId> {
331 let tree = self.create_tree_from_pile()?;
333 let tree_id = self.object_store.store_tree(&tree)?;
334
335 let parent_ids = if let Some(head) = &self.current_timeline.head {
337 vec![head.clone()]
338 } else {
339 vec![]
340 };
341
342 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 let shove_path = self.path.join(".pocket").join("shoves").join(format!("{}.toml", shove.id.as_str()));
354 shove.save(&shove_path)?;
355
356 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 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, };
380
381 entries.push(tree_entry);
382 }
383
384 Ok(Tree { entries })
385 }
386
387 }