| 1 | //! Trees and blobs at a commit. |
| 2 | |
| 3 | use std::ffi::OsStr; |
| 4 | use std::os::unix::ffi::OsStrExt; |
| 5 | use std::path::Path; |
| 6 | |
| 7 | use anyhow::Context; |
| 8 | use gix::bstr::{BString, ByteSlice}; |
| 9 | use gix::object::tree::EntryKind as GixKind; |
| 10 | |
| 11 | use super::ObjectId; |
| 12 | |
| 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 14 | pub enum EntryKind { |
| 15 | Tree, |
| 16 | Blob, |
| 17 | Executable, |
| 18 | Symlink, |
| 19 | Submodule, |
| 20 | } |
| 21 | |
| 22 | impl EntryKind { |
| 23 | pub(super) fn from_gix(kind: GixKind) -> Self { |
| 24 | match kind { |
| 25 | GixKind::Tree => EntryKind::Tree, |
| 26 | GixKind::Blob => EntryKind::Blob, |
| 27 | GixKind::BlobExecutable => EntryKind::Executable, |
| 28 | GixKind::Link => EntryKind::Symlink, |
| 29 | GixKind::Commit => EntryKind::Submodule, |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// Same kind of object, ignoring the executable bit. |
| 34 | pub fn same_type(self, other: EntryKind) -> bool { |
| 35 | let regular = |k| matches!(k, EntryKind::Blob | EntryKind::Executable); |
| 36 | self == other || (regular(self) && regular(other)) |
| 37 | } |
| 38 | |
| 39 | pub fn is_blob(self) -> bool { |
| 40 | matches!( |
| 41 | self, |
| 42 | EntryKind::Blob | EntryKind::Executable | EntryKind::Symlink |
| 43 | ) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | #[derive(Debug, Clone)] |
| 48 | pub struct TreeEntry { |
| 49 | pub name: BString, |
| 50 | pub kind: EntryKind, |
| 51 | pub oid: ObjectId, |
| 52 | /// Blob size in bytes (blobs and symlinks only). |
| 53 | pub size: Option<u64>, |
| 54 | } |
| 55 | |
| 56 | /// What a path inside a commit's tree points to. |
| 57 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 58 | pub enum PathTarget { |
| 59 | Tree(ObjectId), |
| 60 | Blob(ObjectId, EntryKind), |
| 61 | Submodule(ObjectId), |
| 62 | } |
| 63 | |
| 64 | fn as_path(bytes: &[u8]) -> &Path { |
| 65 | Path::new(OsStr::from_bytes(bytes)) |
| 66 | } |
| 67 | |
| 68 | /// Look up `path` (slash-separated, empty for the root) in `commit`'s tree. |
| 69 | pub fn lookup( |
| 70 | repo: &gix::Repository, |
| 71 | commit: ObjectId, |
| 72 | path: &[u8], |
| 73 | ) -> anyhow::Result<Option<PathTarget>> { |
| 74 | let tree = repo |
| 75 | .find_commit(commit) |
| 76 | .context("commit not found")? |
| 77 | .tree() |
| 78 | .context("commit has no tree")?; |
| 79 | if path.is_empty() { |
| 80 | return Ok(Some(PathTarget::Tree(tree.id))); |
| 81 | } |
| 82 | let Some(entry) = tree.lookup_entry_by_path(as_path(path))? else { |
| 83 | return Ok(None); |
| 84 | }; |
| 85 | let oid = entry.object_id(); |
| 86 | Ok(Some(match EntryKind::from_gix(entry.mode().kind()) { |
| 87 | EntryKind::Tree => PathTarget::Tree(oid), |
| 88 | EntryKind::Submodule => PathTarget::Submodule(oid), |
| 89 | kind => PathTarget::Blob(oid, kind), |
| 90 | })) |
| 91 | } |
| 92 | |
| 93 | /// Id and kind of whatever `path` names in `commit`'s tree. `None` when |
| 94 | /// the path doesn't exist, or the commit itself is missing (shallow clones). |
| 95 | pub fn entry_at( |
| 96 | repo: &gix::Repository, |
| 97 | commit: ObjectId, |
| 98 | path: &[u8], |
| 99 | ) -> anyhow::Result<Option<(ObjectId, EntryKind)>> { |
| 100 | let Ok(commit) = repo.find_commit(commit) else { |
| 101 | return Ok(None); |
| 102 | }; |
| 103 | let tree = commit.tree().context("commit has no tree")?; |
| 104 | Ok(tree |
| 105 | .lookup_entry_by_path(as_path(path))? |
| 106 | .map(|entry| (entry.object_id(), EntryKind::from_gix(entry.mode().kind())))) |
| 107 | } |
| 108 | |
| 109 | /// Entries of a tree: directories first, then files, each by name. |
| 110 | pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEntry>> { |
| 111 | let tree = repo.find_tree(tree).context("tree not found")?; |
| 112 | let mut entries = Vec::new(); |
| 113 | for entry in tree.iter() { |
| 114 | let entry = entry?; |
| 115 | let kind = EntryKind::from_gix(entry.mode().kind()); |
| 116 | let oid = entry.oid().to_owned(); |
| 117 | let size = kind |
| 118 | .is_blob() |
| 119 | .then(|| repo.find_header(oid).ok().map(|h| h.size())) |
| 120 | .flatten(); |
| 121 | entries.push(TreeEntry { |
| 122 | name: entry.filename().to_owned(), |
| 123 | kind, |
| 124 | oid, |
| 125 | size, |
| 126 | }); |
| 127 | } |
| 128 | entries.sort_by(|a, b| { |
| 129 | let dir = |e: &TreeEntry| e.kind != EntryKind::Tree; |
| 130 | dir(a) |
| 131 | .cmp(&dir(b)) |
| 132 | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) |
| 133 | .then_with(|| a.name.cmp(&b.name)) |
| 134 | }); |
| 135 | Ok(entries) |
| 136 | } |
| 137 | |
| 138 | /// Every file path under `tree` (blobs, symlinks and submodules), in |
| 139 | /// tree order, up to `limit` paths. The flag reports truncation. |
| 140 | pub fn all_paths( |
| 141 | repo: &gix::Repository, |
| 142 | tree: ObjectId, |
| 143 | limit: usize, |
| 144 | ) -> anyhow::Result<(Vec<String>, bool)> { |
| 145 | let mut out = Vec::new(); |
| 146 | let mut stack = vec![(tree, String::new())]; |
| 147 | while let Some((id, prefix)) = stack.pop() { |
| 148 | let tree = repo.find_tree(id).context("tree not found")?; |
| 149 | let mut subtrees = Vec::new(); |
| 150 | for entry in tree.iter() { |
| 151 | let entry = entry?; |
| 152 | let path = format!("{prefix}{}", entry.filename().to_str_lossy()); |
| 153 | if entry.mode().is_tree() { |
| 154 | subtrees.push((entry.oid().to_owned(), format!("{path}/"))); |
| 155 | } else { |
| 156 | if out.len() == limit { |
| 157 | return Ok((out, true)); |
| 158 | } |
| 159 | out.push(path); |
| 160 | } |
| 161 | } |
| 162 | // Depth first, in name order. |
| 163 | stack.extend(subtrees.into_iter().rev()); |
| 164 | } |
| 165 | Ok((out, false)) |
| 166 | } |
| 167 | |
| 168 | /// Walk the files under `tree` depth-first. `enter` gets each directory |
| 169 | /// path (with a trailing `/`) and decides whether to descend; `file` gets |
| 170 | /// every blob and symlink with its size, read from the object header. |
| 171 | pub fn walk( |
| 172 | repo: &gix::Repository, |
| 173 | tree: ObjectId, |
| 174 | enter: &mut dyn FnMut(&str) -> bool, |
| 175 | file: &mut dyn FnMut(&str, EntryKind, u64), |
| 176 | ) -> anyhow::Result<()> { |
| 177 | let mut stack = vec![(tree, String::new())]; |
| 178 | while let Some((id, prefix)) = stack.pop() { |
| 179 | let tree = repo.find_tree(id).context("tree not found")?; |
| 180 | for entry in tree.iter() { |
| 181 | let entry = entry?; |
| 182 | let path = format!("{prefix}{}", entry.filename().to_str_lossy()); |
| 183 | match EntryKind::from_gix(entry.mode().kind()) { |
| 184 | EntryKind::Tree => { |
| 185 | let dir = format!("{path}/"); |
| 186 | if enter(&dir) { |
| 187 | stack.push((entry.oid().to_owned(), dir)); |
| 188 | } |
| 189 | } |
| 190 | EntryKind::Submodule => {} |
| 191 | kind => { |
| 192 | let size = repo.find_header(entry.oid().to_owned())?.size(); |
| 193 | file(&path, kind, size); |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | Ok(()) |
| 199 | } |
| 200 | |
| 201 | /// Full contents of a blob. |
| 202 | pub fn blob(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<Vec<u8>> { |
| 203 | let mut blob = repo.find_blob(oid).context("blob not found")?; |
| 204 | Ok(blob.take_data()) |
| 205 | } |
| 206 | |
| 207 | /// Size of an object without reading its contents. |
| 208 | pub fn size(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<u64> { |
| 209 | Ok(repo.find_header(oid)?.size()) |
| 210 | } |