1use std::{fmt, path::Path};
2
3use crate::{
4 diff,
5 file::{self, File},
6 Error, Result,
7};
8#[allow(unused)]
9use tracing::{debug, error, info, instrument, span, warn};
10
11pub struct Repo {
12 pub(crate) internal: Internal,
13 history: undo::History<Change>,
14}
15
16impl Repo {
17 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
18 let internal = Internal::open(path)?;
19 let history = undo::History::new();
20 Ok(Self { internal, history })
21 }
22
23 pub fn can_undo(&self) -> bool {
24 self.history.can_undo()
25 }
26
27 pub fn can_redo(&self) -> bool {
28 self.history.can_redo()
29 }
30
31 pub fn undo(&mut self) -> Result<()> {
32 self.history
33 .undo(&mut self.internal)
34 .ok_or(Error::UndoEmpty)
35 .flatten()
36 }
37
38 pub fn redo(&mut self) -> Result<()> {
39 self.history
40 .redo(&mut self.internal)
41 .ok_or(Error::RedoEmpty)
42 .flatten()
43 }
44
45 pub fn path(&self) -> &Path {
46 self.internal.path()
47 }
48
49 pub fn uncommitted_files(&self) -> Result<Vec<diff::Meta>> {
50 self.internal.uncommitted_files()
51 }
52
53 pub fn diff_details(&self, diff: &diff::Meta) -> Result<diff::Details> {
54 self.internal.diff_details(diff)
55 }
56
57 pub fn stage_file(&mut self, file: File) -> Result<()> {
58 self.apply(Change::StageFile(file))
59 }
60
61 pub fn unstage_file(&mut self, file: File) -> Result<()> {
62 self.apply(Change::UnstageFile(file))
63 }
64
65 pub fn stage_contents(&mut self, file: File, contents: file::Contents) -> Result<()> {
66 self.apply(Change::StageContents(file, contents))
67 }
68
69 fn apply(&mut self, change: Change) -> Result<()> {
70 self.history.apply(&mut self.internal, change)
71 }
72}
73
74impl fmt::Debug for Repo {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 let history = format!("{}", self.history.display());
77 f.debug_struct("Repo")
78 .field("internal", &self.internal)
79 .field("history", &history)
80 .finish_non_exhaustive()
81 }
82}
83
84#[derive(Debug, Clone)]
85enum Change {
86 StageFile(File),
87 UnstageFile(File),
88 StageContents(File, file::Contents),
89}
90
91impl undo::Action for Change {
92 type Target = Internal;
93 type Output = ();
94 type Error = Error;
95
96 fn apply(&mut self, target: &mut Self::Target) -> undo::Result<Self> {
97 match self {
98 Change::StageFile(file) => target.stage_file(file),
99 Change::UnstageFile(file) => target.unstage_file(file),
100 Change::StageContents(file, contents) => target.stage_contents(file, contents),
101 }
102 }
103
104 fn undo(&mut self, target: &mut Self::Target) -> undo::Result<Self> {
105 match self {
106 Change::StageFile(file) => target.unstage_file(file),
107 Change::UnstageFile(file) => target.stage_file(file),
108 Change::StageContents(file, contents) => todo!(),
109 }
110 }
111}
112
113impl fmt::Display for Change {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 fmt::Debug::fmt(self, f)
116 }
117}
118
119pub(crate) struct Internal {
123 git: git2::Repository,
124}
125
126impl Internal {
127 fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
128 let git = git2::Repository::open(path)?;
129 Ok(Self { git })
130 }
131
132 pub(crate) fn path(&self) -> &Path {
133 let path = self.git.path();
134 if path.ends_with(".git") {
135 path.parent().expect("If non-bare has parent")
136 } else {
137 path
138 }
139 }
140
141 fn head_assuming_born(&self) -> std::result::Result<git2::Tree, git2::Error> {
142 self.git.head()?.peel_to_commit()?.tree()
143 }
144
145 fn head(&self) -> Result<Option<git2::Tree>> {
146 match self.head_assuming_born() {
147 Ok(head) => Ok(Some(head)),
148 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
149 Err(err) => Err(err.into()),
150 }
151 }
152
153 fn uncommitted_files(&self) -> Result<Vec<diff::Meta>> {
154 let head = self.head()?;
155
156 let mut opts = Self::uncommitted_opts();
157 opts.include_unmodified(true); opts.recurse_untracked_dirs(true);
159
160 let mut diff = self
161 .git
162 .diff_tree_to_workdir_with_index(head.as_ref(), Some(&mut opts))?;
163
164 let mut opts = git2::DiffFindOptions::new();
166 opts.renames(true)
167 .copies(true)
168 .copies_from_unmodified(true)
169 .for_untracked(true)
170 .ignore_whitespace(true)
171 .break_rewrites_for_renames_only(true)
172 .remove_unmodified(true);
175 diff.find_similar(Some(&mut opts))?;
176
177 diff.deltas()
178 .map(|delta| diff::Meta::from_git2(&delta))
179 .collect()
180 }
181
182 fn diff_details(&self, meta: &diff::Meta) -> Result<diff::Details> {
183 match meta {
184 crate::Meta::Added(f)
185 | crate::Meta::Deleted(f)
186 | crate::Meta::Modified { new: f, .. }
187 | crate::Meta::Renamed { new: f, .. }
188 | crate::Meta::Copied { new: f, .. }
189 | crate::Meta::Ignored(f)
190 | crate::Meta::Untracked(f)
191 | crate::Meta::Typechange { new: f, .. }
192 | crate::Meta::Unreadable(f)
193 | crate::Meta::Conflicted { new: f, .. } => self._diff_details(f),
194 }
195 }
196
197 fn _diff_details(&self, file: &File) -> Result<diff::Details> {
198 let head = self.head()?;
199
200 let mut opts = Self::uncommitted_opts();
201 opts.pathspec(file.rel_path());
202
203 let mut meta: Option<Result<diff::Meta>> = None;
204 let mut file_cb = |delta: git2::DiffDelta<'_>, _progress| {
205 if let Some(delta_path) = Self::delta_path(&delta) {
206 if delta_path == file.rel_path() {
207 meta = Some(diff::Meta::from_git2(&delta));
208 return true;
209 }
210 }
211
212 meta.is_none()
216 };
217
218 let mut lines = vec![];
219 let mut line_cb = |delta: git2::DiffDelta<'_>,
220 _hunk: Option<git2::DiffHunk<'_>>,
221 line: git2::DiffLine<'_>| {
222 if let Some(delta_path) = Self::delta_path(&delta) {
223 if delta_path == file.rel_path() {
224 let line = diff::Line::from_git2(&line);
225 lines.push(line);
226 }
227 }
228
229 true
230 };
231
232 match self
233 .git
234 .diff_tree_to_workdir_with_index(head.as_ref(), Some(&mut opts))?
235 .foreach(&mut file_cb, None, None, Some(&mut line_cb))
236 {
237 Ok(()) => (),
238 Err(err) if err.code() == git2::ErrorCode::User => (),
239 Err(err) => return Err(err.into()),
240 }
241
242 let meta = meta
243 .ok_or_else(|| Error::PathNotFound(file.clone()))
244 .flatten()?;
245
246 Ok(diff::Details::new(meta, lines))
247 }
248
249 fn delta_path<'a, 'b>(delta: &'a git2::DiffDelta<'b>) -> Option<&'b Path> {
250 delta
251 .new_file()
252 .path()
253 .map_or_else(|| delta.old_file().path(), |delta_path| Some(delta_path))
254 }
255
256 fn uncommitted_opts() -> git2::DiffOptions {
257 let mut opts = git2::DiffOptions::new();
258 opts.include_untracked(true)
259 .include_typechange(true)
260 .include_unmodified(false)
261 .include_unreadable(true)
262 .include_untracked(true)
263 .include_ignored(true);
264 opts
265 }
266
267 fn stage_file(&mut self, file: &File) -> Result<()> {
268 self.ensure_not_ignored(file)?;
269 self.git.index()?.add_path(file.rel_path())?;
270
271 Ok(())
272 }
273
274 fn unstage_file(&mut self, file: &File) -> Result<()> {
275 self.ensure_not_ignored(file)?;
276 self.git.index()?.remove_path(file.rel_path())?;
277 Ok(())
278 }
279
280 fn stage_contents(&self, file: &File, contents: &file::Contents) -> Result<()> {
281 self.ensure_not_ignored(file)?;
282
283 let entry = git2::IndexEntry {
286 ctime: git2::IndexTime::new(0, 0),
287 mtime: git2::IndexTime::new(0, 0),
288 dev: 0,
289 ino: 0,
290 mode: libgit2_sys::GIT_FILEMODE_BLOB,
291 uid: 0,
292 gid: 0,
293 file_size: 0,
294 id: git2::Oid::zero(),
295 flags: 0,
296 flags_extended: 0,
297 path: file.rel_path_bytes()?.into_bytes(),
298 };
299
300 let mut index = self.git.index()?;
301 index.add_frombuffer(&entry, contents.buffer())?;
302 index.write()?;
303
304 Ok(())
305 }
306
307 fn ensure_not_ignored(&self, file: &File) -> Result<()> {
308 if self.git.status_should_ignore(file.rel_path())? {
309 return Err(Error::Ignored(file.clone()));
310 }
311 Ok(())
312 }
313}
314
315impl fmt::Debug for Internal {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 f.debug_struct("RepoInternal")
318 .field("path", &self.git.path())
319 .finish_non_exhaustive()
320 }
321}