Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 |
| 7 | use sha1::{Digest, Sha1}; |
| 8 |
| 9 | use crate::tree::{self, EntryKind, TreeEntry}; |
| 10 | use crate::{Error, Id, Object, Result, chunk, corrupt}; |
| 11 |
| 12 | pub type GitId = [u8; 20]; |
| 13 |
| 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 15 | pub enum GitKind { |
| 16 | Commit, |
| 17 | Tree, |
| 18 | Blob, |
| 19 | Tag, |
| 20 | } |
| 21 |
| 22 | impl GitKind { |
| 23 | fn from_code(code: u8) -> Option<GitKind> { |
| 24 | Some(match code { |
| 25 | 1 => GitKind::Commit, |
| 26 | 2 => GitKind::Tree, |
| 27 | 3 => GitKind::Blob, |
| 28 | 4 => GitKind::Tag, |
| 29 | _ => return None, |
| 30 | }) |
| 31 | } |
| 32 |
| 33 | fn name(self) -> &'static str { |
| 34 | match self { |
| 35 | GitKind::Commit => "commit", |
| 36 | GitKind::Tree => "tree", |
| 37 | GitKind::Blob => "blob", |
| 38 | GitKind::Tag => "tag", |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 |
| 43 | /// A git object's ID: SHA-1 of its header and content. |
| 44 | pub fn object_id(kind: GitKind, data: &[u8]) -> GitId { |
| 45 | let mut h = Sha1::new(); |
| 46 | h.update(format!("{} {}\0", kind.name(), data.len())); |
| 47 | h.update(data); |
| 48 | h.finalize().into() |
| 49 | } |
| 50 |
| 51 | pub fn hex(id: &GitId) -> String { |
| 52 | id.iter().map(|b| format!("{b:02x}")).collect() |
| 53 | } |
| 54 |
| 55 | pub fn parse_id(text: &str) -> Option<GitId> { |
| 56 | let t = text.trim(); |
| 57 | if t.len() != 40 { |
| 58 | return None; |
| 59 | } |
| 60 | let mut out = [0u8; 20]; |
| 61 | for (i, b) in out.iter_mut().enumerate() { |
| 62 | *b = u8::from_str_radix(t.get(i * 2..i * 2 + 2)?, 16).ok()?; |
| 63 | } |
| 64 | Some(out) |
| 65 | } |
| 66 |
| 67 | /// Every object in a pack, deltas applied. |
| 68 | #[derive(Default)] |
| 69 | pub struct Pack { |
| 70 | pub objects: HashMap<GitId, (GitKind, Vec<u8>)>, |
| 71 | } |
| 72 |
| 73 | enum Raw { |
| 74 | Whole(GitKind, Vec<u8>), |
| 75 | /// A delta on the object at this offset in the pack. |
| 76 | OnOffset(usize, Vec<u8>), |
| 77 | /// A delta on the object with this ID. |
| 78 | OnId(GitId, Vec<u8>), |
| 79 | } |
| 80 |
| 81 | /// Reads a pack. It has to carry every object its deltas are made on (a |
| 82 | /// thin pack doesn't), and its objects can add up to at most `max_bytes`. |
| 83 | pub fn read_pack(bytes: &[u8], max_bytes: usize) -> Result<Pack> { |
| 84 | if bytes.len() < 32 || &bytes[..4] != b"PACK" { |
| 85 | return Err(corrupt("not a git pack")); |
| 86 | } |
| 87 | let version = u32::from_be_bytes(bytes[4..8].try_into().unwrap()); |
| 88 | if version != 2 && version != 3 { |
| 89 | return Err(corrupt(format!("git pack version {version}"))); |
| 90 | } |
| 91 | let count = u32::from_be_bytes(bytes[8..12].try_into().unwrap()) as usize; |
| 92 | let end = bytes.len() - 20; |
| 93 | if Sha1::digest(&bytes[..end]).as_slice() != &bytes[end..] { |
| 94 | return Err(corrupt("git pack checksum doesn't match")); |
| 95 | } |
| 96 | let byte = |at: usize| bytes.get(at).copied().filter(|_| at < end).ok_or_else(|| corrupt("git pack ends early")); |
| 97 | let mut inflater = flate2::Decompress::new(true); |
| 98 | let mut raws: Vec<Raw> = Vec::with_capacity(count.min(1 << 20)); |
| 99 | let mut at_offset: HashMap<usize, usize> = HashMap::with_capacity(count.min(1 << 20)); |
| 100 | let mut total = 0usize; |
| 101 | let mut at = 12; |
| 102 | for i in 0..count { |
| 103 | let start = at; |
| 104 | let mut c = byte(at)?; |
| 105 | at += 1; |
| 106 | let code = (c >> 4) & 7; |
| 107 | let mut size = (c & 15) as usize; |
| 108 | let mut shift = 4; |
| 109 | while c & 0x80 != 0 { |
| 110 | c = byte(at)?; |
| 111 | at += 1; |
| 112 | if shift > 60 { |
| 113 | return Err(corrupt("bad size in git pack")); |
| 114 | } |
| 115 | size |= ((c & 0x7f) as usize) << shift; |
| 116 | shift += 7; |
| 117 | } |
| 118 | total = total.saturating_add(size); |
| 119 | if total > max_bytes { |
| 120 | return Err(Error::Limit(format!("git objects over {max_bytes} bytes"))); |
| 121 | } |
| 122 | let raw = match code { |
| 123 | 1..=4 => { |
| 124 | let (data, used) = inflate(&mut inflater, &bytes[at..end], size)?; |
| 125 | at += used; |
| 126 | Raw::Whole(GitKind::from_code(code).unwrap(), data) |
| 127 | } |
| 128 | 6 => { |
| 129 | let mut c = byte(at)?; |
| 130 | at += 1; |
| 131 | let mut back = (c & 0x7f) as usize; |
| 132 | while c & 0x80 != 0 { |
| 133 | c = byte(at)?; |
| 134 | at += 1; |
| 135 | back = back.checked_add(1).and_then(|b| b.checked_mul(128)).ok_or_else(|| corrupt("bad delta offset"))? | (c & 0x7f) as usize; |
| 136 | } |
| 137 | let base = start.checked_sub(back).ok_or_else(|| corrupt("delta before the pack"))?; |
| 138 | let (data, used) = inflate(&mut inflater, &bytes[at..end], size)?; |
| 139 | at += used; |
| 140 | Raw::OnOffset(base, data) |
| 141 | } |
| 142 | 7 => { |
| 143 | let base: GitId = bytes.get(at..at + 20).filter(|_| at + 20 <= end).ok_or_else(|| corrupt("git pack ends early"))?.try_into().unwrap(); |
| 144 | at += 20; |
| 145 | let (data, used) = inflate(&mut inflater, &bytes[at..end], size)?; |
| 146 | at += used; |
| 147 | Raw::OnId(base, data) |
| 148 | } |
| 149 | _ => return Err(corrupt(format!("git object type {code}"))), |
| 150 | }; |
| 151 | at_offset.insert(start, i); |
| 152 | raws.push(raw); |
| 153 | } |
| 154 | // Deltas on earlier objects resolve in one pass; deltas on later ones |
| 155 | // (allowed, by ID) take more. |
| 156 | let mut done: Vec<Option<(GitKind, GitId)>> = vec![None; raws.len()]; |
| 157 | let mut pack = Pack::default(); |
| 158 | let mut made = 0usize; |
| 159 | let mut left = raws.len(); |
| 160 | while left > 0 { |
| 161 | let before = left; |
| 162 | for i in 0..raws.len() { |
| 163 | if done[i].is_some() { |
| 164 | continue; |
| 165 | } |
| 166 | let resolved = match &mut raws[i] { |
| 167 | Raw::Whole(kind, data) => Some((*kind, std::mem::take(data))), |
| 168 | Raw::OnOffset(base, delta) => { |
| 169 | let b = *at_offset.get(base).ok_or_else(|| corrupt("delta on a missing object"))?; |
| 170 | match done[b] { |
| 171 | Some((kind, id)) => Some((kind, apply_delta(&pack.objects[&id].1, delta)?)), |
| 172 | None => None, |
| 173 | } |
| 174 | } |
| 175 | Raw::OnId(base, delta) => match pack.objects.get(base) { |
| 176 | Some((kind, data)) => Some((*kind, apply_delta(data, delta)?)), |
| 177 | None => None, |
| 178 | }, |
| 179 | }; |
| 180 | if let Some((kind, data)) = resolved { |
| 181 | made = made.saturating_add(data.len()); |
| 182 | if made > max_bytes { |
| 183 | return Err(Error::Limit(format!("git objects over {max_bytes} bytes"))); |
| 184 | } |
| 185 | // The delta isn't needed once applied. |
| 186 | raws[i] = Raw::Whole(kind, Vec::new()); |
| 187 | let id = object_id(kind, &data); |
| 188 | pack.objects.insert(id, (kind, data)); |
| 189 | done[i] = Some((kind, id)); |
| 190 | left -= 1; |
| 191 | } |
| 192 | } |
| 193 | if left == before { |
| 194 | return Err(corrupt("a delta's base isn't in the pack")); |
| 195 | } |
| 196 | } |
| 197 | Ok(pack) |
| 198 | } |
| 199 |
| 200 | fn inflate(z: &mut flate2::Decompress, input: &[u8], size: usize) -> Result<(Vec<u8>, usize)> { |
| 201 | z.reset(true); |
| 202 | let mut out = Vec::with_capacity(size + 64); |
| 203 | loop { |
| 204 | let used = z.total_in() as usize; |
| 205 | let status = z.decompress_vec(&input[used..], &mut out, flate2::FlushDecompress::Finish).map_err(|e| corrupt(format!("git pack: {e}")))?; |
| 206 | if status == flate2::Status::StreamEnd { |
| 207 | break; |
| 208 | } |
| 209 | if out.len() > size { |
| 210 | return Err(corrupt("git object bigger than its header says")); |
| 211 | } |
| 212 | if z.total_in() as usize == input.len() { |
| 213 | return Err(corrupt("git pack ends early")); |
| 214 | } |
| 215 | out.reserve(64); |
| 216 | } |
| 217 | if out.len() != size { |
| 218 | return Err(corrupt("git object's size doesn't match its header")); |
| 219 | } |
| 220 | Ok((out, z.total_in() as usize)) |
| 221 | } |
| 222 |
| 223 | fn varint(data: &[u8], at: &mut usize) -> Result<usize> { |
| 224 | let mut value = 0usize; |
| 225 | let mut shift = 0; |
| 226 | loop { |
| 227 | let b = *data.get(*at).ok_or_else(|| corrupt("bad delta"))?; |
| 228 | *at += 1; |
| 229 | if shift > 60 { |
| 230 | return Err(corrupt("bad delta")); |
| 231 | } |
| 232 | value |= ((b & 0x7f) as usize) << shift; |
| 233 | shift += 7; |
| 234 | if b & 0x80 == 0 { |
| 235 | return Ok(value); |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 |
| 240 | /// Git's delta format: copies from the base and inserted bytes. |
| 241 | pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result<Vec<u8>> { |
| 242 | let mut at = 0; |
| 243 | if varint(delta, &mut at)? != base.len() { |
| 244 | return Err(corrupt("delta made on another object")); |
| 245 | } |
| 246 | let size = varint(delta, &mut at)?; |
| 247 | let mut out = Vec::with_capacity(size); |
| 248 | while at < delta.len() { |
| 249 | let op = delta[at]; |
| 250 | at += 1; |
| 251 | if op & 0x80 != 0 { |
| 252 | let mut off = 0usize; |
| 253 | let mut len = 0usize; |
| 254 | for i in 0..4 { |
| 255 | if op & (1 << i) != 0 { |
| 256 | off |= (*delta.get(at).ok_or_else(|| corrupt("bad delta"))? as usize) << (8 * i); |
| 257 | at += 1; |
| 258 | } |
| 259 | } |
| 260 | for i in 0..3 { |
| 261 | if op & (0x10 << i) != 0 { |
| 262 | len |= (*delta.get(at).ok_or_else(|| corrupt("bad delta"))? as usize) << (8 * i); |
| 263 | at += 1; |
| 264 | } |
| 265 | } |
| 266 | if len == 0 { |
| 267 | len = 0x10000; |
| 268 | } |
| 269 | out.extend_from_slice(base.get(off..off.checked_add(len).ok_or_else(|| corrupt("bad delta"))?).ok_or_else(|| corrupt("delta copies past its base"))?); |
| 270 | } else if op != 0 { |
| 271 | out.extend_from_slice(delta.get(at..at + op as usize).ok_or_else(|| corrupt("bad delta"))?); |
| 272 | at += op as usize; |
| 273 | } else { |
| 274 | return Err(corrupt("bad delta")); |
| 275 | } |
| 276 | if out.len() > size { |
| 277 | return Err(corrupt("delta makes more than it says")); |
| 278 | } |
| 279 | } |
| 280 | if out.len() != size { |
| 281 | return Err(corrupt("delta makes less than it says")); |
| 282 | } |
| 283 | Ok(out) |
| 284 | } |
| 285 |
| 286 | #[derive(Debug, Clone, PartialEq)] |
| 287 | pub struct Commit { |
| 288 | pub tree: GitId, |
| 289 | pub parents: Vec<GitId>, |
| 290 | /// "Name <email>" |
| 291 | pub author: String, |
| 292 | pub time: i64, |
| 293 | pub title: String, |
| 294 | pub body: String, |
| 295 | } |
| 296 |
| 297 | impl Commit { |
| 298 | pub fn parse(data: &[u8]) -> Result<Commit> { |
| 299 | let text = String::from_utf8_lossy(data); |
| 300 | let (head, message) = text.split_once("\n\n").unwrap_or((&text, "")); |
| 301 | let mut tree = None; |
| 302 | let mut parents = Vec::new(); |
| 303 | let mut author = String::new(); |
| 304 | let mut time = 0; |
| 305 | for line in head.lines() { |
| 306 | if let Some(t) = line.strip_prefix("tree ") { |
| 307 | tree = parse_id(t); |
| 308 | } else if let Some(p) = line.strip_prefix("parent ") { |
| 309 | parents.push(parse_id(p).ok_or_else(|| corrupt("bad parent in a git commit"))?); |
| 310 | } else if let Some(a) = line.strip_prefix("author ") { |
| 311 | // "Name <email> 1700000000 +0000" |
| 312 | let mut parts = a.rsplitn(3, ' '); |
| 313 | let _zone = parts.next(); |
| 314 | time = parts.next().and_then(|t| t.parse().ok()).unwrap_or(0); |
| 315 | author = parts.next().unwrap_or("").to_string(); |
| 316 | } |
| 317 | } |
| 318 | let mut lines = message.trim().splitn(2, '\n'); |
| 319 | Ok(Commit { |
| 320 | tree: tree.ok_or_else(|| corrupt("git commit without a tree"))?, |
| 321 | parents, |
| 322 | author, |
| 323 | time, |
| 324 | title: lines.next().unwrap_or("").trim().to_string(), |
| 325 | body: lines.next().unwrap_or("").trim().to_string(), |
| 326 | }) |
| 327 | } |
| 328 | } |
| 329 |
| 330 | /// A git tree's entries: (mode, name, id). |
| 331 | pub fn tree_entries(data: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>, GitId)>> { |
| 332 | let mut out = Vec::new(); |
| 333 | let mut at = 0; |
| 334 | while at < data.len() { |
| 335 | let space = data[at..].iter().position(|&b| b == b' ').ok_or_else(|| corrupt("bad git tree"))? + at; |
| 336 | let nul = data[space + 1..].iter().position(|&b| b == 0).ok_or_else(|| corrupt("bad git tree"))? + space + 1; |
| 337 | let id: GitId = data.get(nul + 1..nul + 21).ok_or_else(|| corrupt("bad git tree"))?.try_into().unwrap(); |
| 338 | out.push((data[at..space].to_vec(), data[space + 1..nul].to_vec(), id)); |
| 339 | at = nul + 21; |
| 340 | } |
| 341 | Ok(out) |
| 342 | } |
| 343 |
| 344 | /// The commits from `to` back to `from` on the first-parent line, oldest |
| 345 | /// first. None when `from` isn't on that line. |
| 346 | pub fn first_parents(pack: &Pack, from: &GitId, to: &GitId, limit: usize) -> Result<Option<Vec<(GitId, Commit)>>> { |
| 347 | let mut out = Vec::new(); |
| 348 | let mut at = *to; |
| 349 | while at != *from { |
| 350 | let Some((GitKind::Commit, data)) = pack.objects.get(&at) else { return Ok(None) }; |
| 351 | if out.len() >= limit { |
| 352 | return Ok(None); |
| 353 | } |
| 354 | let commit = Commit::parse(data)?; |
| 355 | let next = commit.parents.first().copied(); |
| 356 | out.push((at, commit)); |
| 357 | match next { |
| 358 | Some(p) => at = p, |
| 359 | None => return Ok(None), |
| 360 | } |
| 361 | } |
| 362 | out.reverse(); |
| 363 | Ok(Some(out)) |
| 364 | } |
| 365 |
| 366 | /// Whether `to` has `from` in its history. The pack holds every commit |
| 367 | /// `to` has that `from` doesn't, so a line that leaves the pack anywhere |
| 368 | /// but at `from` reached history `from` already had. |
| 369 | pub fn descends(pack: &Pack, from: &GitId, to: &GitId) -> Result<bool> { |
| 370 | let mut stack = vec![*to]; |
| 371 | let mut seen = std::collections::HashSet::new(); |
| 372 | while let Some(c) = stack.pop() { |
| 373 | if c == *from { |
| 374 | return Ok(true); |
| 375 | } |
| 376 | if !seen.insert(c) { |
| 377 | continue; |
| 378 | } |
| 379 | if let Some((GitKind::Commit, data)) = pack.objects.get(&c) { |
| 380 | stack.extend(Commit::parse(data)?.parents); |
| 381 | } |
| 382 | } |
| 383 | Ok(false) |
| 384 | } |
| 385 |
| 386 | /// Git trees as vcs trees. Trees and files outside the pack come from |
| 387 | /// `known` (git ID → vcs ID); ones in neither are listed in `unknown`. |
| 388 | pub struct ToVcs<'a> { |
| 389 | pack: &'a Pack, |
| 390 | known: &'a HashMap<GitId, Id>, |
| 391 | trees: HashMap<GitId, Option<Id>>, |
| 392 | files: HashMap<GitId, Id>, |
| 393 | /// New vcs objects. |
| 394 | pub objects: Vec<Object>, |
| 395 | /// vcs ID → git ID of what was converted, for the git view. |
| 396 | pub ids: Vec<(Id, GitId)>, |
| 397 | pub unknown: Vec<GitId>, |
| 398 | } |
| 399 |
| 400 | impl<'a> ToVcs<'a> { |
| 401 | pub fn new(pack: &'a Pack, known: &'a HashMap<GitId, Id>) -> ToVcs<'a> { |
| 402 | ToVcs { pack, known, trees: HashMap::new(), files: HashMap::new(), objects: Vec::new(), ids: Vec::new(), unknown: Vec::new() } |
| 403 | } |
| 404 |
| 405 | /// A git tree as a vcs tree (None: nothing left in it, for a folder |
| 406 | /// other than the root). Names that aren't UTF-8 and a root `.vcs` are |
| 407 | /// left out, as `vcs import` does. |
| 408 | pub fn tree(&mut self, sha: GitId, root: bool) -> Result<Option<Id>> { |
| 409 | if let Some(t) = self.trees.get(&sha) { |
| 410 | return Ok(*t); |
| 411 | } |
| 412 | let data = match self.pack.objects.get(&sha) { |
| 413 | Some((GitKind::Tree, data)) => data, |
| 414 | Some(_) => return Err(corrupt(format!("git object {} isn't a tree", hex(&sha)))), |
| 415 | None => { |
| 416 | return Ok(match self.known.get(&sha) { |
| 417 | Some(id) => Some(*id), |
| 418 | None => { |
| 419 | self.unknown.push(sha); |
| 420 | None |
| 421 | } |
| 422 | }); |
| 423 | } |
| 424 | }; |
| 425 | let mut entries = Vec::new(); |
| 426 | for (mode, name, id) in tree_entries(data)? { |
| 427 | let Ok(name) = String::from_utf8(name) else { continue }; |
| 428 | if root && name == ".vcs" { |
| 429 | continue; |
| 430 | } |
| 431 | let (kind, id) = match mode.as_slice() { |
| 432 | b"40000" | b"040000" => match self.tree(id, false)? { |
| 433 | Some(t) => (EntryKind::Tree, t), |
| 434 | None => continue, |
| 435 | }, |
| 436 | // A submodule: the commit it points at, kept as it is. |
| 437 | b"160000" => (EntryKind::Gitlink, self.content(hex(&id).as_bytes())), |
| 438 | b"120000" => (EntryKind::Link, self.file(id)), |
| 439 | m if m.ends_with(b"755") => (EntryKind::Exec, self.file(id)), |
| 440 | _ => (EntryKind::File, self.file(id)), |
| 441 | }; |
| 442 | entries.push(TreeEntry { name, kind, id }); |
| 443 | } |
| 444 | let out = if entries.is_empty() && !root { |
| 445 | None |
| 446 | } else { |
| 447 | let object = Object::new(crate::Kind::Tree, tree::encode(&entries)); |
| 448 | let id = object.id(); |
| 449 | self.objects.push(object); |
| 450 | self.ids.push((id, sha)); |
| 451 | Some(id) |
| 452 | }; |
| 453 | self.trees.insert(sha, out); |
| 454 | Ok(out) |
| 455 | } |
| 456 |
| 457 | fn file(&mut self, sha: GitId) -> Id { |
| 458 | if let Some(f) = self.files.get(&sha) { |
| 459 | return *f; |
| 460 | } |
| 461 | let id = match self.pack.objects.get(&sha) { |
| 462 | Some((_, data)) => { |
| 463 | let data = data.clone(); |
| 464 | let id = self.content(&data); |
| 465 | self.ids.push((id, sha)); |
| 466 | id |
| 467 | } |
| 468 | None => match self.known.get(&sha) { |
| 469 | Some(id) => *id, |
| 470 | None => { |
| 471 | self.unknown.push(sha); |
| 472 | tree::empty_id() |
| 473 | } |
| 474 | }, |
| 475 | }; |
| 476 | self.files.insert(sha, id); |
| 477 | id |
| 478 | } |
| 479 |
| 480 | fn content(&mut self, data: &[u8]) -> Id { |
| 481 | let parts = chunk::file_objects(data); |
| 482 | let id = parts[0].id(); |
| 483 | self.objects.extend(parts); |
| 484 | id |
| 485 | } |
| 486 | } |
| 487 |
| 488 | #[cfg(test)] |
| 489 | mod tests { |
| 490 | use super::*; |
| 491 | use std::io::Write; |
| 492 |
| 493 | fn deflate(data: &[u8]) -> Vec<u8> { |
| 494 | let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); |
| 495 | z.write_all(data).unwrap(); |
| 496 | z.finish().unwrap() |
| 497 | } |
| 498 |
| 499 | fn header(code: u8, size: usize) -> Vec<u8> { |
| 500 | let mut out = Vec::new(); |
| 501 | let mut b = (code << 4) | (size & 15) as u8; |
| 502 | let mut rest = size >> 4; |
| 503 | while rest > 0 { |
| 504 | out.push(b | 0x80); |
| 505 | b = (rest & 0x7f) as u8; |
| 506 | rest >>= 7; |
| 507 | } |
| 508 | out.push(b); |
| 509 | out |
| 510 | } |
| 511 |
| 512 | fn finish(mut pack: Vec<u8>, count: u32) -> Vec<u8> { |
| 513 | pack[8..12].copy_from_slice(&count.to_be_bytes()); |
| 514 | let sum = Sha1::digest(&pack); |
| 515 | pack.extend_from_slice(&sum); |
| 516 | pack |
| 517 | } |
| 518 |
| 519 | #[test] |
| 520 | fn reads_whole_objects_and_deltas() { |
| 521 | let base = b"hello world, hello git\n".repeat(10); |
| 522 | let target = [&base[..], b"one more line\n"].concat(); |
| 523 | // A delta: copy all of base, then insert the new line. |
| 524 | let mut delta = vec![base.len() as u8 | 0x80, (base.len() >> 7) as u8, target.len() as u8 | 0x80, (target.len() >> 7) as u8]; |
| 525 | delta.extend_from_slice(&[0x80 | 0x01 | 0x10, 0, base.len() as u8]); |
| 526 | delta.push(14); |
| 527 | delta.extend_from_slice(b"one more line\n"); |
| 528 | assert_eq!(apply_delta(&base, &delta).unwrap(), target); |
| 529 |
| 530 | let mut pack = b"PACK\0\0\0\x02\0\0\0\0".to_vec(); |
| 531 | let base_at = pack.len(); |
| 532 | pack.extend(header(3, base.len())); |
| 533 | pack.extend(deflate(&base)); |
| 534 | let delta_at = pack.len(); |
| 535 | pack.extend(header(6, delta.len())); |
| 536 | let back = delta_at - base_at; |
| 537 | assert!(back < 128); |
| 538 | pack.push(back as u8); |
| 539 | pack.extend(deflate(&delta)); |
| 540 | pack.extend(header(7, delta.len())); |
| 541 | pack.extend(object_id(GitKind::Blob, &base)); |
| 542 | pack.extend(deflate(&delta)); |
| 543 | let pack = finish(pack, 3); |
| 544 | let read = read_pack(&pack, 1 << 20).unwrap(); |
| 545 | assert_eq!(read.objects.len(), 2); |
| 546 | assert_eq!(read.objects[&object_id(GitKind::Blob, &target)].1, target); |
| 547 | assert!(read_pack(&pack, 100).is_err()); |
| 548 | let mut bad = pack.clone(); |
| 549 | bad[20] ^= 1; |
| 550 | assert!(read_pack(&bad, 1 << 20).is_err()); |
| 551 | } |
| 552 |
| 553 | /// A pack git itself makes, deltas and all, reads back to every object. |
| 554 | #[test] |
| 555 | fn reads_what_git_packs() { |
| 556 | use std::process::Command; |
| 557 | let dir = std::env::temp_dir().join(format!("vcs-git-pack-{}", std::process::id())); |
| 558 | let _ = std::fs::remove_dir_all(&dir); |
| 559 | std::fs::create_dir_all(&dir).unwrap(); |
| 560 | let git = |args: &[&str]| Command::new("git").args(["-c", "user.name=T", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]).args(args).current_dir(&dir).output(); |
| 561 | if git(&["init", "-q"]).is_err() { |
| 562 | return; // No git here. |
| 563 | } |
| 564 | let mut text = String::new(); |
| 565 | for i in 0..40 { |
| 566 | text.push_str(&format!("line {i} of a file that changes a little each time\n")); |
| 567 | std::fs::write(dir.join("a.txt"), &text).unwrap(); |
| 568 | std::fs::write(dir.join(format!("f{}.txt", i % 5)), format!("{i}\n")).unwrap(); |
| 569 | git(&["add", "-A"]).unwrap(); |
| 570 | git(&["commit", "-qm", &format!("Commit {i}")]).unwrap(); |
| 571 | } |
| 572 | let pack = Command::new("sh").arg("-c").arg("echo HEAD | git pack-objects --stdout --revs --delta-base-offset").current_dir(&dir).output().unwrap().stdout; |
| 573 | let all = String::from_utf8(git(&["rev-list", "--objects", "--all"]).unwrap().stdout).unwrap(); |
| 574 | let read = read_pack(&pack, 1 << 24).unwrap(); |
| 575 | assert_eq!(read.objects.len(), all.lines().count()); |
| 576 | for line in all.lines() { |
| 577 | assert!(read.objects.contains_key(&parse_id(&line[..40]).unwrap()), "{line}"); |
| 578 | } |
| 579 | let head = parse_id(&String::from_utf8(git(&["rev-parse", "HEAD"]).unwrap().stdout).unwrap()).unwrap(); |
| 580 | let line = first_parents(&read, &[0; 20], &head, 100).unwrap(); |
| 581 | assert!(line.is_none(), "the root commit has no parent to stop at"); |
| 582 | let root = parse_id(&String::from_utf8(git(&["rev-list", "--max-parents=0", "HEAD"]).unwrap().stdout).unwrap()).unwrap(); |
| 583 | let line = first_parents(&read, &root, &head, 100).unwrap().unwrap(); |
| 584 | assert_eq!(line.len(), 39); |
| 585 | assert_eq!(line.last().unwrap().1.title, "Commit 39"); |
| 586 | assert!(descends(&read, &root, &head).unwrap()); |
| 587 | let _ = std::fs::remove_dir_all(&dir); |
| 588 | } |
| 589 |
| 590 | #[test] |
| 591 | fn commits_and_trees_become_vcs_trees() { |
| 592 | let blob = b"fn main() {}\n".to_vec(); |
| 593 | let blob_id = object_id(GitKind::Blob, &blob); |
| 594 | let mut sub = Vec::new(); |
| 595 | sub.extend_from_slice(b"100755 run.sh\0"); |
| 596 | sub.extend_from_slice(&blob_id); |
| 597 | let sub_id = object_id(GitKind::Tree, &sub); |
| 598 | let outside: GitId = [7; 20]; |
| 599 | let mut root = Vec::new(); |
| 600 | root.extend_from_slice(b"100644 main.rs\0"); |
| 601 | root.extend_from_slice(&blob_id); |
| 602 | root.extend_from_slice(b"40000 bin\0"); |
| 603 | root.extend_from_slice(&sub_id); |
| 604 | root.extend_from_slice(b"160000 lib\0"); |
| 605 | root.extend_from_slice(&[9; 20]); |
| 606 | root.extend_from_slice(b"100644 old.txt\0"); |
| 607 | root.extend_from_slice(&outside); |
| 608 | let root_id = object_id(GitKind::Tree, &root); |
| 609 | let commit = format!("tree {}\nparent {}\nauthor Ada <ada@example.com> 1700000000 +0100\ncommitter Ada <ada@example.com> 1700000000 +0100\n\nFix the build\n\nIt was broken.\n", hex(&root_id), hex(&[1; 20])); |
| 610 | let parsed = Commit::parse(commit.as_bytes()).unwrap(); |
| 611 | assert_eq!((parsed.tree, parsed.parents.clone(), parsed.author.as_str(), parsed.time), (root_id, vec![[1; 20]], "Ada <ada@example.com>", 1_700_000_000)); |
| 612 | assert_eq!((parsed.title.as_str(), parsed.body.as_str()), ("Fix the build", "It was broken.")); |
| 613 |
| 614 | let mut pack = Pack::default(); |
| 615 | for (kind, data) in [(GitKind::Blob, blob.clone()), (GitKind::Tree, sub), (GitKind::Tree, root)] { |
| 616 | pack.objects.insert(object_id(kind, &data), (kind, data)); |
| 617 | } |
| 618 | let none = HashMap::new(); |
| 619 | let mut first = ToVcs::new(&pack, &none); |
| 620 | first.tree(root_id, true).unwrap(); |
| 621 | assert_eq!(first.unknown, vec![outside]); |
| 622 |
| 623 | let old = Object::blob(b"old\n".to_vec()).id(); |
| 624 | let known = HashMap::from([(outside, old)]); |
| 625 | let mut to = ToVcs::new(&pack, &known); |
| 626 | let out = to.tree(root_id, true).unwrap().unwrap(); |
| 627 | assert!(to.unknown.is_empty()); |
| 628 | let mut src = crate::MemSource::default(); |
| 629 | for o in to.objects.drain(..) { |
| 630 | src.put(o); |
| 631 | } |
| 632 | src.put(Object::blob(b"old\n".to_vec())); |
| 633 | let files = tree::flatten(&src, &out).unwrap(); |
| 634 | assert_eq!(files.keys().cloned().collect::<Vec<_>>(), vec!["bin/run.sh", "lib", "main.rs", "old.txt"]); |
| 635 | assert_eq!(files["bin/run.sh"].kind, EntryKind::Exec); |
| 636 | assert_eq!(files["lib"].kind, EntryKind::Gitlink); |
| 637 | assert_eq!(chunk::read_file(&src, &files["lib"].id).unwrap(), hex(&[9; 20]).as_bytes()); |
| 638 | assert_eq!(files["main.rs"].id, Object::blob(blob).id()); |
| 639 | assert!(to.ids.contains(&(out, root_id))); |
| 640 | } |
| 641 | } |
| 642 |