idgit/
repo.rs

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
119/// Internal manages everything that doesn't require history. This is so that
120/// actions on the history can mutably borrow something that doesn't contain the
121/// history itself.
122pub(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); // needed for rename/copy tracking
158        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        // Detect renames
165        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            // Don't keep them, since we only want for copy/rename tracking
173            // If we included them we'd have to search them in `diff_details`
174            .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            // NOTE: If we ask to stop once we get the target lines_cb isn't
213            // called, so we exit on the first subsequent delta.
214
215            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        // It appears we only have to fill out the path and mode
284        // See <https://github.com/libgit2/libgit2/blob/508361401fbb5d87118045eaeae3356a729131aa/tests/index/filemodes.c#L179>
285        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}