1use chrono::{DateTime, TimeZone};
61use std::io;
62use std::io::Write;
63
64pub const GITHUB_BOT_AUTHOR: &str = "committer Bot <github-actions[bot]@users.noreply.github.com>";
66
67#[derive(Debug)]
71pub struct GitFastImporter<T: Write> {
72 output: T,
73 current_mark: usize,
74 branch: String,
75 initial_parent: Option<String>,
76 author: String,
77}
78
79pub struct CommitBuilder<'a, T: Write> {
85 importer: &'a mut GitFastImporter<T>,
86 message: String,
87 timestamp: String,
88 files: Vec<(usize, String)>,
89}
90
91impl<'a, T: Write> CommitBuilder<'a, T> {
92 pub fn add_file(&mut self, path: &str, data: &[u8]) -> io::Result<&mut Self> {
94 let mark = self.importer.write_blob(data)?;
95 self.files.push((mark, path.to_string()));
96 Ok(self)
97 }
98
99 pub fn finish(self) -> io::Result<()> {
101 self.importer
102 .write_commit(&self.message, &self.timestamp, self.files)
103 }
104}
105
106impl<T: Write> GitFastImporter<T> {
107 pub fn new(output: T, branch: String, initial_parent: Option<String>, author: String) -> Self {
116 GitFastImporter {
117 output,
118 current_mark: 0,
119 branch,
120 initial_parent,
121 author,
122 }
123 }
124
125 pub fn start_commit(
132 &mut self,
133 message: &str,
134 timestamp: DateTime<impl TimeZone<Offset: std::fmt::Display>>,
135 ) -> CommitBuilder<'_, T> {
136 CommitBuilder {
137 importer: self,
138 message: message.to_string(),
139 timestamp: timestamp.format("%s %z").to_string(),
140 files: Vec::new(),
141 }
142 }
143
144 pub fn finish(&mut self) -> io::Result<()> {
146 writeln!(self.output, "done")?;
147 Ok(())
148 }
149
150 fn write_blob(&mut self, data: &[u8]) -> io::Result<usize> {
151 self.current_mark += 1;
152 writeln!(self.output, "blob")?;
153 writeln!(self.output, "mark :{}", self.current_mark)?;
154 writeln!(self.output, "data {}", data.len())?;
155 self.output.write_all(data)?;
156 writeln!(self.output)?;
157 Ok(self.current_mark)
158 }
159
160 fn write_commit(
161 &mut self,
162 message: &str,
163 timestamp: &str,
164 paths_to_nodes: Vec<(usize, String)>,
165 ) -> io::Result<()> {
166 self.current_mark += 1;
167 writeln!(self.output, "commit refs/heads/{}", self.branch)?;
168 writeln!(self.output, "mark :{}", self.current_mark)?;
169 writeln!(self.output, "{} {}", self.author, timestamp)?;
170
171 writeln!(self.output, "data {}", message.len())?;
172 writeln!(self.output, "{}", message)?;
173
174 if let Some(parent) = self.initial_parent.take() {
175 writeln!(self.output, "from {parent}")?;
176 }
177
178 for (mark, path) in paths_to_nodes {
179 if path.is_empty() {
180 continue;
181 }
182 writeln!(self.output, "M 100644 :{mark} {path}")?;
183 }
184 writeln!(self.output)?;
185 Ok(())
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use chrono::Utc;
193
194 fn ts() -> DateTime<Utc> {
195 DateTime::UNIX_EPOCH
196 }
197
198 fn create_importer(output: &mut Vec<u8>) -> GitFastImporter<&mut Vec<u8>> {
199 GitFastImporter::new(
200 output,
201 "main".to_string(),
202 None,
203 "committer Test <test@example.com>".to_string(),
204 )
205 }
206
207 #[test]
208 fn test_single_commit_with_file() {
209 let mut output = Vec::new();
210 let mut importer = create_importer(&mut output);
211
212 let mut commit = importer.start_commit("Add test file", ts());
213 commit.add_file("test.txt", b"hello world").unwrap();
214 commit.finish().unwrap();
215 importer.finish().unwrap();
216
217 assert_eq!(
218 String::from_utf8(output).unwrap(),
219 "\
220blob
221mark :1
222data 11
223hello world
224commit refs/heads/main
225mark :2
226committer Test <test@example.com> 0 +0000
227data 13
228Add test file
229M 100644 :1 test.txt
230
231done
232"
233 );
234 }
235
236 #[test]
237 fn test_multiple_files_in_commit() {
238 let mut output = Vec::new();
239 let mut importer = create_importer(&mut output);
240
241 let mut commit = importer.start_commit("Add multiple files", ts());
242 commit.add_file("a.txt", b"aaa").unwrap();
243 commit.add_file("b.txt", b"bbb").unwrap();
244 commit.finish().unwrap();
245
246 assert_eq!(
247 String::from_utf8(output).unwrap(),
248 "\
249blob
250mark :1
251data 3
252aaa
253blob
254mark :2
255data 3
256bbb
257commit refs/heads/main
258mark :3
259committer Test <test@example.com> 0 +0000
260data 18
261Add multiple files
262M 100644 :1 a.txt
263M 100644 :2 b.txt
264
265"
266 );
267 }
268
269 #[test]
270 fn test_multiple_commits() {
271 let mut output = Vec::new();
272 let mut importer = create_importer(&mut output);
273
274 let mut commit1 = importer.start_commit("First commit", ts());
275 commit1.add_file("first.txt", b"first").unwrap();
276 commit1.finish().unwrap();
277
278 let mut commit2 = importer.start_commit("Second commit", ts());
279 commit2.add_file("second.txt", b"second").unwrap();
280 commit2.finish().unwrap();
281
282 assert_eq!(
283 String::from_utf8(output).unwrap(),
284 "\
285blob
286mark :1
287data 5
288first
289commit refs/heads/main
290mark :2
291committer Test <test@example.com> 0 +0000
292data 12
293First commit
294M 100644 :1 first.txt
295
296blob
297mark :3
298data 6
299second
300commit refs/heads/main
301mark :4
302committer Test <test@example.com> 0 +0000
303data 13
304Second commit
305M 100644 :3 second.txt
306
307"
308 );
309 }
310
311 #[test]
312 fn test_initial_parent() {
313 let mut output = Vec::new();
314 let mut importer = GitFastImporter::new(
315 &mut output,
316 "main".to_string(),
317 Some("refs/heads/existing".to_string()),
318 "committer Test <test@example.com>".to_string(),
319 );
320
321 let mut commit = importer.start_commit("Child commit", ts());
322 commit.add_file("file.txt", b"data").unwrap();
323 commit.finish().unwrap();
324
325 assert_eq!(
326 String::from_utf8(output).unwrap(),
327 "\
328blob
329mark :1
330data 4
331data
332commit refs/heads/main
333mark :2
334committer Test <test@example.com> 0 +0000
335data 12
336Child commit
337from refs/heads/existing
338M 100644 :1 file.txt
339
340"
341 );
342 }
343
344 #[test]
345 fn test_initial_parent_only_on_first_commit() {
346 let mut output = Vec::new();
347 let mut importer = GitFastImporter::new(
348 &mut output,
349 "main".to_string(),
350 Some("refs/heads/existing".to_string()),
351 "committer Test <test@example.com>".to_string(),
352 );
353
354 let mut commit1 = importer.start_commit("First", ts());
355 commit1.add_file("a.txt", b"a").unwrap();
356 commit1.finish().unwrap();
357
358 let mut commit2 = importer.start_commit("Second", ts());
359 commit2.add_file("b.txt", b"b").unwrap();
360 commit2.finish().unwrap();
361
362 assert_eq!(
363 String::from_utf8(output).unwrap(),
364 "\
365blob
366mark :1
367data 1
368a
369commit refs/heads/main
370mark :2
371committer Test <test@example.com> 0 +0000
372data 5
373First
374from refs/heads/existing
375M 100644 :1 a.txt
376
377blob
378mark :3
379data 1
380b
381commit refs/heads/main
382mark :4
383committer Test <test@example.com> 0 +0000
384data 6
385Second
386M 100644 :3 b.txt
387
388"
389 );
390 }
391
392 #[test]
393 fn test_empty_path_skipped() {
394 let mut output = Vec::new();
395 let mut importer = create_importer(&mut output);
396
397 let mut commit = importer.start_commit("Test", ts());
398 commit.add_file("", b"data").unwrap();
399 commit.add_file("real.txt", b"data").unwrap();
400 commit.finish().unwrap();
401
402 assert_eq!(
403 String::from_utf8(output).unwrap(),
404 "\
405blob
406mark :1
407data 4
408data
409blob
410mark :2
411data 4
412data
413commit refs/heads/main
414mark :3
415committer Test <test@example.com> 0 +0000
416data 4
417Test
418M 100644 :2 real.txt
419
420"
421 );
422 }
423
424 #[test]
425 fn test_dropped_commit_builder_no_commit() {
426 let mut output = Vec::new();
427 let mut importer = create_importer(&mut output);
428
429 {
430 let mut builder = importer.start_commit("Dropped commit", ts());
431 builder.add_file("file.txt", b"data").unwrap();
432 }
434
435 importer.finish().unwrap();
436
437 assert_eq!(
438 String::from_utf8(output).unwrap(),
439 "\
440blob
441mark :1
442data 4
443data
444done
445"
446 );
447 }
448}