Skip to main content

git_fast_import/
lib.rs

1//! A library for generating [`git fast-import`] streams.
2//!
3//! This crate provides a simple, type-safe API for creating git commits programmatically
4//! by generating a stream that can be piped to `git fast-import`.
5//!
6//! # Example
7//!
8//! Write to a buffer:
9//!
10//! ```no_run
11//! use git_fast_import::{GitFastImporter, GITHUB_BOT_AUTHOR};
12//!
13//! let mut output = Vec::new();
14//! let mut importer = GitFastImporter::new(
15//!     &mut output,
16//!     "main".to_string(),
17//!     None,
18//!     GITHUB_BOT_AUTHOR.to_string(),
19//! );
20//!
21//! let mut commit = importer.start_commit("Initial commit", chrono::Utc::now());
22//! commit.add_file("hello.txt", b"Hello, World!").unwrap();
23//! commit.finish().unwrap();
24//! importer.finish().unwrap();
25//!
26//! // `output` now contains a valid git fast-import stream
27//! ```
28//!
29//! Pipe directly to `git fast-import`:
30//!
31//! ```no_run
32//! use git_fast_import::{GitFastImporter, GITHUB_BOT_AUTHOR};
33//! use std::process::{Command, Stdio};
34//!
35//! let mut child = Command::new("git")
36//!     .args(["fast-import", "--quiet"])
37//!     .stdin(Stdio::piped())
38//!     .spawn()
39//!     .expect("failed to spawn git fast-import");
40//!
41//! let stdin = child.stdin.take().unwrap();
42//! let mut importer = GitFastImporter::new(
43//!     stdin,
44//!     "main".to_string(),
45//!     None,
46//!     GITHUB_BOT_AUTHOR.to_string(),
47//! );
48//!
49//! let mut commit = importer.start_commit("Initial commit", chrono::Utc::now());
50//! commit.add_file("src/lib.rs", b"// new file").unwrap();
51//! commit.finish().unwrap();
52//! importer.finish().unwrap();
53//!
54//! let status = child.wait().expect("failed to wait on git fast-import");
55//! assert!(status.success());
56//! ```
57//!
58//! [`git fast-import`]: https://git-scm.com/docs/git-fast-import
59
60use chrono::{DateTime, TimeZone};
61use std::io;
62use std::io::Write;
63
64/// A committer string for GitHub Actions bot.
65pub const GITHUB_BOT_AUTHOR: &str = "committer Bot <github-actions[bot]@users.noreply.github.com>";
66
67/// Generates a git fast-import stream.
68///
69/// Write the stream to any [`Write`] implementor, then pipe it to `git fast-import`.
70#[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
79/// Builder for constructing a single commit.
80///
81/// Created by [`GitFastImporter::start_commit`]. Add files with [`add_file`](Self::add_file),
82/// then call [`finish`](Self::finish) to write the commit. Dropping without calling `finish`
83/// discards the commit (but any blobs written remain in the stream).
84pub 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    /// Adds a file to this commit.
93    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    /// Writes the commit to the output stream.
100    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    /// Creates a new importer that writes to the given output.
108    ///
109    /// # Arguments
110    ///
111    /// * `output` - The writer to output the fast-import stream to
112    /// * `branch` - The branch name to create commits on (e.g., `"main"`)
113    /// * `initial_parent` - Optional parent ref for the first commit (e.g., `"refs/heads/main"`)
114    /// * `author` - The committer string (e.g., [`GITHUB_BOT_AUTHOR`])
115    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    /// Starts building a new commit.
126    ///
127    /// # Arguments
128    ///
129    /// * `message` - The commit message
130    /// * `timestamp` - The commit timestamp (e.g., `Utc::now()`)
131    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    /// Writes the `done` command to finalize the stream.
145    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            // Drop without calling finish()
433        }
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}