ephemeris_files/
repository.rs1use std::collections::BTreeSet;
2use std::io::Read as _;
3use std::os::unix::ffi::OsStrExt as _;
4use std::path::{Path, PathBuf};
5use std::{fs, io, str};
6
7use git2::{Repository as GitRepository, StatusOptions};
8
9use super::entry::{FileEntry, FileMode};
10use super::error::IoResultExt as _;
11use super::path::{RepositoryPath, RepositoryRoot};
12use super::planner::Operation;
13use crate::{FilesError, PathError};
14
15const LEADING_BYTES: u64 = 256;
17
18pub(crate) struct Repository {
19 git: GitRepository,
20}
21impl Repository {
22 pub(crate) fn open(root: &Path) -> Result<Self, FilesError> {
23 let git = GitRepository::open(root).map_err(|source| FilesError::Open {
24 path: root.to_path_buf(),
25 source,
26 })?;
27 Ok(Self { git })
28 }
29 fn stat(
32 root: &Path,
33 path: &RepositoryPath,
34 ) -> Result<Option<(PathBuf, fs::Metadata)>, FilesError> {
35 let full = root.join(path.as_path());
36 super::path::ensure_no_symlink_parent(root, &full)?;
37 match full.symlink_metadata() {
38 Ok(metadata) => Ok(Some((full, metadata))),
39 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
40 Err(error) => Err(error).at_path(full),
41 }
42 }
43
44 pub(crate) fn current_entry(
45 root: &Path,
46 path: &RepositoryPath,
47 ) -> Result<Option<FileEntry>, FilesError> {
48 let Some((full, metadata)) = Self::stat(root, path)? else {
49 return Ok(None);
50 };
51 if metadata.file_type().is_symlink() {
52 let target = fs::read_link(&full).at_path(&full)?;
53 return Ok(Some(FileEntry {
54 content: target.as_os_str().as_bytes().to_vec(),
55 mode: FileMode::Symlink,
56 }));
57 }
58 if !metadata.is_file() {
59 return Ok(None);
60 }
61 Ok(Some(FileEntry {
62 content: fs::read(&full).at_path(&full)?,
63 mode: FileMode::from_metadata(&metadata),
64 }))
65 }
66 pub(crate) fn tracked(&self) -> Result<BTreeSet<RepositoryPath>, FilesError> {
70 if self.git.workdir().is_none() {
71 return Err(FilesError::NoWorktree);
72 }
73 let index = self
74 .git
75 .index()
76 .map_err(|source| FilesError::ReadIndex { source })?;
77 index
78 .iter()
79 .map(|entry| Ok(Self::decode_path(&entry.path)?))
80 .collect()
81 }
82
83 pub(crate) fn leading_bytes(
86 root: &Path,
87 path: &RepositoryPath,
88 ) -> Result<Option<Vec<u8>>, FilesError> {
89 let Some((full, metadata)) = Self::stat(root, path)? else {
90 return Ok(None);
91 };
92 let mut leading = Vec::new();
93 if metadata.is_file() {
94 fs::File::open(&full)
95 .and_then(|file| file.take(LEADING_BYTES).read_to_end(&mut leading))
96 .at_path(&full)?;
97 }
98 Ok(Some(leading))
99 }
100
101 pub(crate) fn visible_files(&self) -> Result<BTreeSet<RepositoryPath>, FilesError> {
103 let root = self.git.workdir().ok_or(FilesError::NoWorktree)?;
104 let index = self
105 .git
106 .index()
107 .map_err(|source| FilesError::ReadIndex { source })?;
108 let mut candidates = index
109 .iter()
110 .filter(|entry| FileMode::from_git(entry.mode) != FileMode::Symlink)
111 .map(|entry| Self::decode_path(&entry.path))
112 .collect::<Result<BTreeSet<_>, _>>()?;
113 let mut options = StatusOptions::new();
114 options
115 .include_untracked(true)
116 .recurse_untracked_dirs(true)
117 .include_ignored(false);
118 let statuses = self
119 .git
120 .statuses(Some(&mut options))
121 .map_err(|source| FilesError::ReadStatus { source })?;
122 for entry in statuses.iter().filter(|entry| entry.status().is_wt_new()) {
123 candidates.insert(Self::decode_path(entry.path_bytes())?);
124 }
125 let mut files = BTreeSet::new();
126 for path in candidates {
127 let full = root.join(path.as_path());
128 if full
129 .symlink_metadata()
130 .is_ok_and(|metadata| metadata.is_file())
131 {
132 files.insert(path);
133 }
134 }
135 Ok(files)
136 }
137
138 pub(crate) fn stage(&self, operations: &[Operation]) -> Result<(), FilesError> {
139 let mut index = self
140 .git
141 .index()
142 .map_err(|source| FilesError::ReadIndex { source })?;
143 for operation in operations {
144 match operation {
145 Operation::Create { path, entry: _ } | Operation::Replace { path, entry: _ } => {
146 index.add_path(path.as_path())
147 }
148 Operation::Delete { path } => index.remove_path(path.as_path()),
149 }
150 .map_err(|source| FilesError::Stage {
151 path: operation.path().clone(),
152 source,
153 })?;
154 }
155 index
156 .write()
157 .map_err(|source| FilesError::WriteIndex { source })
158 }
159
160 pub(crate) fn ensure_recoverable(
161 &self,
162 path: &RepositoryPath,
163 current: &FileEntry,
164 ) -> Result<(), FilesError> {
165 if self.is_recoverable(path, current) {
166 Ok(())
167 } else {
168 Err(FilesError::Unsafe { path: path.clone() })
169 }
170 }
171 pub(crate) fn is_recoverable(&self, path: &RepositoryPath, current: &FileEntry) -> bool {
172 let index_match = self
173 .git
174 .index()
175 .ok()
176 .and_then(|index| index.get_path(path.as_path(), 0))
177 .is_some_and(|entry| {
178 self.git
179 .find_blob(entry.id)
180 .is_ok_and(|blob| blob.content() == current.content)
181 && FileMode::from_git(entry.mode) == current.mode
182 });
183 let head_match = self
184 .git
185 .head()
186 .ok()
187 .and_then(|head| head.peel_to_tree().ok())
188 .and_then(|tree| tree.get_path(path.as_path()).ok())
189 .is_some_and(|entry| {
190 self.git
191 .find_blob(entry.id())
192 .is_ok_and(|blob| blob.content() == current.content)
193 && u32::try_from(entry.filemode())
194 .is_ok_and(|raw| FileMode::from_git(raw) == current.mode)
195 });
196 index_match || head_match
197 }
198
199 fn decode_path(bytes: &[u8]) -> Result<RepositoryPath, PathError> {
202 match str::from_utf8(bytes) {
203 Ok(text) => RepositoryPath::try_from(text),
204 Err(_utf8_error) => Err(PathError::Invalid {
205 path: String::from_utf8_lossy(bytes).into_owned(),
206 }),
207 }
208 }
209}
210
211impl RepositoryRoot {
212 pub fn read(&self, path: &RepositoryPath) -> Result<Option<Vec<u8>>, FilesError> {
219 let Some((full, metadata)) = Repository::stat(self.as_path(), path)? else {
220 return Ok(None);
221 };
222 if metadata.file_type().is_symlink() {
223 return Err(PathError::Symlink(full).into());
224 }
225 match fs::read(&full) {
226 Ok(content) => Ok(Some(content)),
227 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
228 Err(error) => Err(error).at_path(full),
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use std::fs;
236 #[cfg(unix)]
237 use std::os::unix::fs::symlink;
238
239 use super::{RepositoryPath, RepositoryRoot};
240
241 #[test]
242 fn read_returns_the_content_or_none_for_a_missing_file() {
243 let directory = tempfile::tempdir().unwrap();
244 fs::write(directory.path().join("present"), "content").unwrap();
245 let root = RepositoryRoot::new(directory.path()).unwrap();
246 let read = |path: &str| root.read(&RepositoryPath::try_from(path).unwrap());
247 assert_eq!(read("present").unwrap(), Some(b"content".to_vec()));
248 assert_eq!(read("absent").unwrap(), None);
249 assert_eq!(read("absent/nested").unwrap(), None);
250 }
251
252 #[cfg(unix)]
253 #[test]
254 fn read_refuses_a_symlink() {
255 let directory = tempfile::tempdir().unwrap();
256 fs::write(directory.path().join("target"), "content").unwrap();
257 symlink("target", directory.path().join("link")).unwrap();
258 let root = RepositoryRoot::new(directory.path()).unwrap();
259 assert!(
260 root.read(&RepositoryPath::try_from("link").unwrap())
261 .is_err()
262 );
263 }
264}