Skip to main content

grit_lib/
fast_import.rs

1//! Minimal [`git fast-import`](https://git-scm.com/docs/git-fast-import) stream support.
2//!
3//! Handles the subset of commands used by upstream tests: `blob` (with optional
4//! `mark`), `commit` (with `author`/`committer`, `data` in byte-count or `<<delim>`
5//! form, optional `from`, `deleteall`, `M` / `D` file commands, `M ... inline`
6//! with a following `data` command, and `N` / `N inline` on `refs/notes/*`),
7//! `reset`, `done`, and comment lines.
8
9use std::collections::HashMap;
10use std::io::BufRead;
11
12use crate::check_ref_format::{check_refname_format, RefNameOptions};
13use crate::config::ConfigSet;
14use crate::diff::zero_oid;
15use crate::error::{Error, Result};
16use crate::index::{Index, IndexEntry, MODE_GITLINK, MODE_REGULAR, MODE_TREE};
17use crate::objects::{
18    parse_commit, serialize_commit, serialize_tag, CommitData, ObjectId, ObjectKind, TagData,
19};
20use crate::refs::{
21    append_reflog, read_head, resolve_ref, should_autocreate_reflog_for_mode, write_ref,
22    LogRefsConfig,
23};
24use crate::repo::Repository;
25use crate::rev_parse::resolve_revision;
26use crate::write_tree::write_tree_from_index;
27
28/// Options for [`import_stream`].
29#[derive(Debug, Clone, Copy, Default)]
30pub struct FastImportOptions {
31    /// When true, allow updating refs that would otherwise be rejected as non-fast-forward
32    /// (Git's `feature force` / `--force`).
33    pub force: bool,
34}
35
36/// Import objects and refs from a fast-import stream read from `reader`.
37///
38/// # Errors
39///
40/// Returns [`Error`] variants for I/O, corrupt stream input, or missing marks/refs.
41pub fn import_stream(repo: &Repository, reader: impl BufRead) -> Result<()> {
42    import_stream_with_options(repo, reader, FastImportOptions::default())
43}
44
45/// Import with explicit options (e.g. `--force`).
46pub fn import_stream_with_options(
47    repo: &Repository,
48    mut reader: impl BufRead,
49    options: FastImportOptions,
50) -> Result<()> {
51    let log_refs = ConfigSet::load(Some(&repo.git_dir), true)
52        .map(|c| c.effective_log_refs_config(&repo.git_dir))
53        .unwrap_or_else(|_| crate::refs::effective_log_refs_config(&repo.git_dir));
54    let mut imp = Importer {
55        repo,
56        log_refs,
57        marks: HashMap::new(),
58        branch_tips: HashMap::new(),
59        feature_done: false,
60        stashed_line: None,
61        pending_byte: None,
62        force: options.force,
63        reader: &mut reader,
64    };
65    imp.run()
66}
67
68struct Importer<'a, R: BufRead> {
69    repo: &'a Repository,
70    log_refs: LogRefsConfig,
71    marks: HashMap<u32, ObjectId>,
72    branch_tips: HashMap<String, ObjectId>,
73    /// When set, a terminating `done` command is required before EOF.
74    feature_done: bool,
75    /// Line read too far while parsing a `commit` or `reset`; next top-level command.
76    stashed_line: Option<String>,
77    /// Byte read while handling optional `LF` after a `data` block; must precede next line.
78    pending_byte: Option<u8>,
79    force: bool,
80    reader: &'a mut R,
81}
82
83impl<'a, R: BufRead> Importer<'a, R> {
84    fn fast_import_reflog_identity_from_env() -> String {
85        let name = std::env::var("GIT_COMMITTER_NAME").unwrap_or_else(|_| "Unknown".to_owned());
86        let email = std::env::var("GIT_COMMITTER_EMAIL").unwrap_or_default();
87        let date = std::env::var("GIT_COMMITTER_DATE").unwrap_or_else(|_| {
88            let now = std::time::SystemTime::now()
89                .duration_since(std::time::UNIX_EPOCH)
90                .map(|d| d.as_secs())
91                .unwrap_or(0);
92            format!("{now} +0000")
93        });
94        format!("{name} <{email}> {date}")
95    }
96
97    /// Update a ref and append a reflog line (matches `git fast-import` ref transactions).
98    fn update_ref_with_reflog(
99        &self,
100        refname: &str,
101        new_oid: &ObjectId,
102        identity: &str,
103        message: &str,
104    ) -> Result<()> {
105        let old_oid = resolve_ref(&self.repo.git_dir, refname).unwrap_or_else(|_| zero_oid());
106        write_ref(&self.repo.git_dir, refname, new_oid)?;
107        if should_autocreate_reflog_for_mode(refname, self.log_refs) {
108            let _ = append_reflog(
109                &self.repo.git_dir,
110                refname,
111                &old_oid,
112                new_oid,
113                identity,
114                message,
115                false,
116            );
117        }
118        Ok(())
119    }
120
121    /// Apply a fast-import update for `refname`, treating symbolic `HEAD` like Git's harness.
122    ///
123    /// When `refname` is `HEAD` and `HEAD` points at `refs/heads/<branch>`, update that branch
124    /// and keep `HEAD` symbolic. Writing a raw OID into `HEAD` would detach it and break helpers
125    /// such as `test_commit_bulk` followed by `git branch -M` (e.g. t5327 with
126    /// `GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME`).
127    fn update_ref_for_fast_import(
128        &self,
129        refname: &str,
130        new_oid: &ObjectId,
131        identity: &str,
132        message: &str,
133    ) -> Result<()> {
134        if refname == "HEAD" {
135            if let Some(target) = read_head(&self.repo.git_dir)? {
136                if target.starts_with("refs/heads/") {
137                    return self.update_ref_with_reflog(&target, new_oid, identity, message);
138                }
139            }
140        }
141        self.update_ref_with_reflog(refname, new_oid, identity, message)
142    }
143
144    /// Read a `data` command body: either `data <n>` (exact bytes) or `data <<delim>` (line-delimited).
145    fn read_data_payload(&mut self, data_line_trimmed: &str) -> Result<Vec<u8>> {
146        let rest = data_line_trimmed.strip_prefix("data ").ok_or_else(|| {
147            Error::IndexError(format!(
148                "fast-import: expected data line, got: {data_line_trimmed}"
149            ))
150        })?;
151        if let Some(delim) = rest.strip_prefix("<<") {
152            let delim = delim.trim_end();
153            if delim.is_empty() {
154                return Err(Error::IndexError(
155                    "fast-import: empty data delimiter".to_owned(),
156                ));
157            }
158            return self.read_data_delimited(delim);
159        }
160        let size: usize = rest
161            .trim_end()
162            .parse()
163            .map_err(|_| Error::IndexError(format!("fast-import: invalid data size: {rest}")))?;
164        let mut payload = vec![0u8; size];
165        self.reader
166            .read_exact(&mut payload)
167            .map_err(|_| Error::IndexError("fast-import: truncated data".to_owned()))?;
168        self.consume_optional_lf_after_data()?;
169        Ok(payload)
170    }
171
172    /// Delimited `data` format: raw lines until a line equal to `delim` (see git-fast-import).
173    fn read_data_delimited(&mut self, delim: &str) -> Result<Vec<u8>> {
174        let mut out = Vec::new();
175        loop {
176            let line = self.read_line_any()?.ok_or_else(|| {
177                Error::IndexError(format!(
178                    "fast-import: EOF in data (terminator '{delim}' not found)"
179                ))
180            })?;
181            if line.trim_end() == delim {
182                break;
183            }
184            out.extend_from_slice(line.as_bytes());
185        }
186        self.consume_optional_lf_after_data()?;
187        Ok(out)
188    }
189
190    fn run(&mut self) -> Result<()> {
191        loop {
192            let line = match self.next_command_line()? {
193                Some(l) => l,
194                None => break,
195            };
196            let trimmed = line.trim_end();
197            if trimmed.is_empty() {
198                continue;
199            }
200            if trimmed == "done" {
201                break;
202            }
203            if let Some(rest) = trimmed.strip_prefix("feature ") {
204                let name = rest.trim();
205                if name == "force" {
206                    self.force = true;
207                } else if name == "done" {
208                    self.feature_done = true;
209                }
210                continue;
211            }
212            if trimmed.starts_with('#') {
213                continue;
214            }
215            if trimmed == "blob" {
216                self.read_blob()?;
217                continue;
218            }
219            if let Some(rest) = trimmed.strip_prefix("commit ") {
220                let refname = rest.trim().to_string();
221                validate_fast_import_refname(&refname)?;
222                self.read_commit(&refname)?;
223                continue;
224            }
225            if let Some(rest) = trimmed.strip_prefix("reset ") {
226                let refname = rest.trim().to_string();
227                validate_fast_import_refname(&refname)?;
228                self.read_reset(&refname)?;
229                continue;
230            }
231            if trimmed.starts_with("tag ") {
232                let name = trimmed["tag ".len()..].trim().to_string();
233                self.read_tag(&name)?;
234                continue;
235            }
236            return Err(Error::IndexError(format!(
237                "fast-import: unsupported command: {trimmed}"
238            )));
239        }
240        if self.feature_done {
241            return Err(Error::IndexError(
242                "fast-import: stream ended before required \"done\" command".to_owned(),
243            ));
244        }
245        Ok(())
246    }
247
248    fn next_command_line(&mut self) -> Result<Option<String>> {
249        if let Some(l) = self.stashed_line.take() {
250            return Ok(Some(l));
251        }
252        self.read_line_nonempty()
253    }
254
255    fn read_line_nonempty(&mut self) -> Result<Option<String>> {
256        let mut buf = String::new();
257        loop {
258            buf.clear();
259            let n = self.read_line_into(&mut buf)?;
260            if n == 0 {
261                return Ok(None);
262            }
263            if !buf.trim().is_empty() {
264                return Ok(Some(buf));
265            }
266        }
267    }
268
269    fn read_line_any(&mut self) -> Result<Option<String>> {
270        let mut buf = String::new();
271        let n = self.read_line_into(&mut buf)?;
272        if n == 0 {
273            return Ok(None);
274        }
275        Ok(Some(buf))
276    }
277
278    fn read_line_into(&mut self, buf: &mut String) -> Result<usize> {
279        buf.clear();
280        if let Some(b) = self.pending_byte.take() {
281            if b == b'\n' {
282                buf.push('\n');
283                return Ok(1);
284            }
285            buf.push(char::from(b));
286        }
287        let prev = buf.len();
288        let n = self.reader.read_line(buf).map_err(Error::Io)?;
289        Ok(prev + n)
290    }
291
292    fn read_blob(&mut self) -> Result<()> {
293        let mut mark: Option<u32> = None;
294        loop {
295            let line = self.read_line_nonempty()?.ok_or_else(|| {
296                Error::IndexError("fast-import: unexpected EOF in blob".to_owned())
297            })?;
298            let t = line.trim_end();
299            if let Some(id) = t.strip_prefix("mark :") {
300                mark = Some(
301                    id.parse()
302                        .map_err(|_| Error::IndexError(format!("fast-import: bad mark: {t}")))?,
303                );
304                continue;
305            }
306            if t.starts_with("original-oid ") {
307                continue;
308            }
309            let payload = self.read_data_payload(t)?;
310            let oid = self.repo.odb.write(ObjectKind::Blob, &payload)?;
311            if let Some(m) = mark {
312                self.marks.insert(m, oid);
313            }
314            return Ok(());
315        }
316    }
317
318    /// After `data` payload, an extra LF is optional (see git-fast-import docs).
319    fn consume_optional_lf_after_data(&mut self) -> Result<()> {
320        let mut one = [0u8; 1];
321        match self.reader.read(&mut one) {
322            Ok(0) => Ok(()),
323            Ok(1) => {
324                if one[0] != b'\n' {
325                    self.pending_byte = Some(one[0]);
326                }
327                Ok(())
328            }
329            Ok(_) => unreachable!(),
330            Err(e) => Err(Error::Io(e)),
331        }
332    }
333
334    fn read_commit(&mut self, refname: &str) -> Result<()> {
335        let mut mark: Option<u32> = None;
336        let mut author: Option<String> = None;
337        let mut committer: Option<String> = None;
338
339        loop {
340            let line = self.read_line_nonempty()?.ok_or_else(|| {
341                Error::IndexError("fast-import: unexpected EOF in commit".to_owned())
342            })?;
343            let t = line.trim_end();
344            if let Some(id) = t.strip_prefix("mark :") {
345                mark = Some(
346                    id.parse()
347                        .map_err(|_| Error::IndexError(format!("fast-import: bad mark: {t}")))?,
348                );
349                continue;
350            }
351            if t.starts_with("original-oid ") {
352                continue;
353            }
354            if let Some(rest) = t.strip_prefix("author ") {
355                author = Some(rest.to_owned());
356                continue;
357            }
358            if let Some(rest) = t.strip_prefix("committer ") {
359                committer = Some(rest.to_owned());
360                continue;
361            }
362            if t.starts_with("gpgsig ") || t.starts_with("encoding ") {
363                return Err(Error::IndexError(format!(
364                    "fast-import: unsupported commit header: {t}"
365                )));
366            }
367            if t.starts_with("data ") {
368                let message = self.read_data_payload(t)?;
369                let committer = committer.ok_or_else(|| {
370                    Error::IndexError("fast-import: commit missing committer".to_owned())
371                })?;
372                let author = author.unwrap_or_else(|| committer.clone());
373                self.finish_commit(refname, mark, author, committer, message)?;
374                return Ok(());
375            }
376            return Err(Error::IndexError(format!(
377                "fast-import: unexpected in commit before message: {t}"
378            )));
379        }
380    }
381
382    fn finish_commit(
383        &mut self,
384        refname: &str,
385        mark: Option<u32>,
386        author: String,
387        committer: String,
388        message: Vec<u8>,
389    ) -> Result<()> {
390        #[derive(Debug)]
391        enum FileChangeOp {
392            DeleteAll,
393            Delete(Vec<u8>),
394            Modify {
395                mode: u32,
396                blob_oid: ObjectId,
397                path: Vec<u8>,
398            },
399            NoteModify {
400                blob_oid: ObjectId,
401                target_commit: ObjectId,
402            },
403            Rename(Vec<u8>, Vec<u8>),
404            Copy(Vec<u8>, Vec<u8>),
405        }
406
407        let mut from_oid: Option<ObjectId> = None;
408        let mut merge_oids: Vec<ObjectId> = Vec::new();
409        let mut ops: Vec<FileChangeOp> = Vec::new();
410        let mut pending_inline: Option<(u32, Vec<u8>)> = None;
411        let notes_ref = refname.starts_with("refs/notes/");
412
413        loop {
414            let Some(line) = self.read_line_any()? else {
415                break;
416            };
417            let t = line.trim_end();
418            if t.is_empty() {
419                continue;
420            }
421            if let Some((mode, path)) = pending_inline.take() {
422                if !t.starts_with("data ") {
423                    return Err(Error::IndexError(format!(
424                        "fast-import: expected data after M ... inline, got: {t}"
425                    )));
426                }
427                let payload = self.read_data_payload(t)?;
428                let blob_oid = self.repo.odb.write(ObjectKind::Blob, &payload)?;
429                ops.push(FileChangeOp::Modify {
430                    mode,
431                    blob_oid,
432                    path,
433                });
434                continue;
435            }
436            if t.starts_with("from ") {
437                let spec = t["from ".len()..].trim();
438                from_oid = Some(self.resolve_commit_ish(spec)?);
439                continue;
440            }
441            if t.starts_with("merge ") {
442                let spec = t["merge ".len()..].trim();
443                merge_oids.push(self.resolve_commit_ish(spec)?);
444                continue;
445            }
446            if t == "deleteall" {
447                ops.push(FileChangeOp::DeleteAll);
448                continue;
449            }
450            if let Some(rest) = t.strip_prefix("M ") {
451                let parts: Vec<&str> = rest.split_whitespace().collect();
452                if parts.len() < 3 {
453                    return Err(Error::IndexError(format!("fast-import: bad M line: {t}")));
454                }
455                let mode = u32::from_str_radix(parts[0], 8).map_err(|_| {
456                    Error::IndexError(format!("fast-import: bad file mode: {}", parts[0]))
457                })?;
458                let blob_ref = parts[1];
459                if parts.len() != 3 {
460                    return Err(Error::IndexError(format!("fast-import: bad M line: {t}")));
461                }
462                let path = parts[2].as_bytes().to_vec();
463                if blob_ref == "inline" {
464                    pending_inline = Some((mode, path));
465                    continue;
466                }
467                let blob_oid = self.resolve_blob_ref(blob_ref)?;
468                ops.push(FileChangeOp::Modify {
469                    mode,
470                    blob_oid,
471                    path,
472                });
473                continue;
474            }
475            if let Some(rest) = t.strip_prefix("D ") {
476                ops.push(FileChangeOp::Delete(rest.as_bytes().to_vec()));
477                continue;
478            }
479            if let Some(rest) = t.strip_prefix("N ") {
480                if !notes_ref {
481                    return Err(Error::IndexError(format!(
482                        "fast-import: N (notemodify) only allowed on refs/notes/*, not {refname}"
483                    )));
484                }
485                let (data_ref, commit_spec) = parse_notemodify_operands(rest)?;
486                let target_commit = self.resolve_note_target_commit(commit_spec)?;
487                let blob_oid = match data_ref {
488                    NoteBlobSpec::Inline => {
489                        let next = self.read_line_nonempty()?.ok_or_else(|| {
490                            Error::IndexError(
491                                "fast-import: expected data after N inline".to_owned(),
492                            )
493                        })?;
494                        let nt = next.trim_end();
495                        if !nt.starts_with("data ") {
496                            return Err(Error::IndexError(format!(
497                                "fast-import: expected data after N inline, got: {nt}"
498                            )));
499                        }
500                        let payload = self.read_data_payload(nt)?;
501                        self.repo.odb.write(ObjectKind::Blob, &payload)?
502                    }
503                    NoteBlobSpec::Mark(id) => *self.marks.get(&id).ok_or_else(|| {
504                        Error::IndexError(format!("fast-import: unknown mark :{id}"))
505                    })?,
506                    NoteBlobSpec::Oid(oid) => {
507                        if oid.is_zero() {
508                            ObjectId::zero()
509                        } else {
510                            let obj = self.repo.odb.read(&oid)?;
511                            if obj.kind != ObjectKind::Blob {
512                                return Err(Error::IndexError(format!(
513                                    "fast-import: N dataref {oid} is not a blob"
514                                )));
515                            }
516                            oid
517                        }
518                    }
519                };
520                ops.push(FileChangeOp::NoteModify {
521                    blob_oid,
522                    target_commit,
523                });
524                continue;
525            }
526            if let Some(rest) = t.strip_prefix("R ") {
527                let parts: Vec<&str> = rest.split_whitespace().collect();
528                if parts.len() != 2 {
529                    return Err(Error::IndexError(format!("fast-import: bad R line: {t}")));
530                }
531                ops.push(FileChangeOp::Rename(
532                    parts[0].as_bytes().to_vec(),
533                    parts[1].as_bytes().to_vec(),
534                ));
535                continue;
536            }
537            if let Some(rest) = t.strip_prefix("C ") {
538                let parts: Vec<&str> = rest.split_whitespace().collect();
539                if parts.len() != 2 {
540                    return Err(Error::IndexError(format!("fast-import: bad C line: {t}")));
541                }
542                ops.push(FileChangeOp::Copy(
543                    parts[0].as_bytes().to_vec(),
544                    parts[1].as_bytes().to_vec(),
545                ));
546                continue;
547            }
548            self.stashed_line = Some(line);
549            break;
550        }
551
552        if pending_inline.is_some() {
553            return Err(Error::IndexError(
554                "fast-import: unterminated M ... inline (missing data)".to_owned(),
555            ));
556        }
557
558        let empty_tree: ObjectId = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
559            .parse()
560            .map_err(|_| Error::IndexError("fast-import: empty tree oid".to_owned()))?;
561
562        let mut parents: Vec<ObjectId> = Vec::new();
563        if let Some(oid) = from_oid {
564            parents.push(oid);
565        }
566        parents.extend(merge_oids);
567
568        let (parent_tree, parents_for_commit) = if let Some(&first_parent) = parents.first() {
569            let obj = self.repo.odb.read(&first_parent)?;
570            if obj.kind != ObjectKind::Commit {
571                return Err(Error::IndexError(format!(
572                    "fast-import: parent {first_parent} is not a commit"
573                )));
574            }
575            let c = parse_commit(&obj.data)?;
576            (c.tree, parents)
577        } else if let Some(tip) = self.branch_tips.get(refname).copied() {
578            let obj = self.repo.odb.read(&tip)?;
579            if obj.kind != ObjectKind::Commit {
580                return Err(Error::IndexError(format!(
581                    "fast-import: branch tip {tip} is not a commit"
582                )));
583            }
584            let c = parse_commit(&obj.data)?;
585            (c.tree, vec![tip])
586        } else {
587            (empty_tree, Vec::new())
588        };
589
590        let mut index = tree_to_index(&self.repo.odb, &parent_tree)?;
591        for op in ops {
592            match op {
593                FileChangeOp::DeleteAll => index.entries.clear(),
594                FileChangeOp::Delete(path) => {
595                    index.entries.retain(|e| e.path != path);
596                }
597                FileChangeOp::Modify {
598                    mode,
599                    blob_oid,
600                    path,
601                } => {
602                    let mode = normalize_mode(mode)?;
603                    index.add_or_replace(index_entry(path, mode, blob_oid));
604                }
605                FileChangeOp::NoteModify {
606                    blob_oid,
607                    target_commit,
608                } => {
609                    remove_note_entries_for_target(&mut index, &target_commit);
610                    if !blob_oid.is_zero() {
611                        let after_remove = count_notes_in_index(&index);
612                        let fanout = notes_fanout_for_count(after_remove.saturating_add(1));
613                        let note_path = construct_note_path_with_fanout(&target_commit, fanout);
614                        index.add_or_replace(index_entry(note_path, MODE_REGULAR, blob_oid));
615                    }
616                }
617                FileChangeOp::Rename(src, dst) => {
618                    let Some(pos) = index.entries.iter().position(|e| e.path == src) else {
619                        return Err(Error::IndexError(format!(
620                            "fast-import: filerename source missing: {}",
621                            String::from_utf8_lossy(&src)
622                        )));
623                    };
624                    let mut ent = index.entries.remove(pos);
625                    ent.path = dst;
626                    index.add_or_replace(ent);
627                }
628                FileChangeOp::Copy(src, dst) => {
629                    let Some(ent) = index.entries.iter().find(|e| e.path == src).cloned() else {
630                        return Err(Error::IndexError(format!(
631                            "fast-import: filecopy source missing: {}",
632                            String::from_utf8_lossy(&src)
633                        )));
634                    };
635                    let mut copy_ent = ent;
636                    copy_ent.path = dst;
637                    index.add_or_replace(copy_ent);
638                }
639            }
640        }
641
642        if notes_ref && count_notes_in_index(&index) > 0 {
643            let n = count_notes_in_index(&index);
644            rewrite_notes_fanout_in_index(&self.repo.odb, &mut index, notes_fanout_for_count(n))?;
645        }
646
647        let tree_oid = write_tree_from_index(&self.repo.odb, &index, "")?;
648
649        let message_str = String::from_utf8_lossy(&message).into_owned();
650        let raw_message = (!message.is_empty() && std::str::from_utf8(&message).is_err())
651            .then_some(message.clone());
652        let reflog_identity = committer.clone();
653
654        let commit = CommitData {
655            tree: tree_oid,
656            parents: parents_for_commit,
657            author,
658            committer,
659            author_raw: Vec::new(),
660            committer_raw: Vec::new(),
661            encoding: None,
662            message: message_str,
663            raw_message,
664        };
665        let bytes = serialize_commit(&commit);
666        let commit_oid = self.repo.odb.write(ObjectKind::Commit, &bytes)?;
667
668        if let Some(m) = mark {
669            self.marks.insert(m, commit_oid);
670        }
671        self.branch_tips.insert(refname.to_string(), commit_oid);
672        if !self.force {
673            if let Ok(old) = crate::refs::resolve_ref(&self.repo.git_dir, refname) {
674                if old != commit_oid {
675                    let is_ancestor =
676                        crate::merge_base::is_ancestor(self.repo, old, commit_oid).unwrap_or(false);
677                    if !is_ancestor {
678                        return Err(Error::IndexError(format!(
679                            "fast-import: refusing non-fast-forward update of {refname} (use feature force or --force)"
680                        )));
681                    }
682                }
683            }
684        }
685        self.update_ref_for_fast_import(refname, &commit_oid, &reflog_identity, "fast-import")?;
686        Ok(())
687    }
688
689    fn resolve_commit_ish(&self, spec: &str) -> Result<ObjectId> {
690        if let Some(rest) = spec.strip_prefix(':') {
691            let id: u32 = rest
692                .parse()
693                .map_err(|_| Error::IndexError(format!("fast-import: bad mark ref: {spec}")))?;
694            return self
695                .marks
696                .get(&id)
697                .copied()
698                .ok_or_else(|| Error::IndexError(format!("fast-import: unknown mark :{id}")));
699        }
700        if spec.len() == 40 && spec.chars().all(|c| c.is_ascii_hexdigit()) {
701            return spec.parse();
702        }
703        resolve_revision(self.repo, spec)
704    }
705
706    fn resolve_blob_ref(&self, spec: &str) -> Result<ObjectId> {
707        if let Some(rest) = spec.strip_prefix(':') {
708            let id: u32 = rest
709                .parse()
710                .map_err(|_| Error::IndexError(format!("fast-import: bad mark ref: {spec}")))?;
711            return self
712                .marks
713                .get(&id)
714                .copied()
715                .ok_or_else(|| Error::IndexError(format!("fast-import: unknown mark :{id}")));
716        }
717        if spec.len() == 40 && spec.chars().all(|c| c.is_ascii_hexdigit()) {
718            return spec.parse();
719        }
720        Err(Error::IndexError(format!(
721            "fast-import: unsupported blob ref: {spec}"
722        )))
723    }
724
725    fn read_tag(&mut self, short_name: &str) -> Result<()> {
726        let mut mark: Option<u32> = None;
727        let mut from_oid: Option<ObjectId> = None;
728        let mut tagger: Option<String> = None;
729
730        loop {
731            let line = self.read_line_nonempty()?.ok_or_else(|| {
732                Error::IndexError("fast-import: unexpected EOF in tag".to_owned())
733            })?;
734            let t = line.trim_end();
735            if let Some(id) = t.strip_prefix("mark :") {
736                mark = Some(
737                    id.parse()
738                        .map_err(|_| Error::IndexError(format!("fast-import: bad mark: {t}")))?,
739                );
740                continue;
741            }
742            if t.starts_with("original-oid ") {
743                continue;
744            }
745            if let Some(rest) = t.strip_prefix("from ") {
746                let spec = rest.trim();
747                from_oid = Some(self.resolve_commit_ish(spec)?);
748                continue;
749            }
750            if let Some(rest) = t.strip_prefix("tagger ") {
751                tagger = Some(rest.to_owned());
752                continue;
753            }
754            if t.starts_with("data ") {
755                let message = self.read_data_payload(t)?;
756
757                let target = from_oid
758                    .ok_or_else(|| Error::IndexError("fast-import: tag missing from".to_owned()))?;
759                let target_obj = self.repo.odb.read(&target)?;
760                let object_type = target_obj.kind.as_str().to_owned();
761                let msg_str = String::from_utf8_lossy(&message).into_owned();
762
763                let reflog_ident = tagger
764                    .clone()
765                    .unwrap_or_else(Self::fast_import_reflog_identity_from_env);
766                let tag_data = TagData {
767                    object: target,
768                    object_type,
769                    tag: short_name.to_owned(),
770                    tagger,
771                    message: msg_str,
772                };
773                let bytes = serialize_tag(&tag_data);
774                let tag_oid = self.repo.odb.write(ObjectKind::Tag, &bytes)?;
775
776                if let Some(m) = mark {
777                    self.marks.insert(m, tag_oid);
778                }
779
780                let full_ref = format!("refs/tags/{short_name}");
781                self.update_ref_with_reflog(&full_ref, &tag_oid, &reflog_ident, "fast-import")?;
782                return Ok(());
783            }
784            return Err(Error::IndexError(format!(
785                "fast-import: unexpected in tag: {t}"
786            )));
787        }
788    }
789
790    fn read_reset(&mut self, refname: &str) -> Result<()> {
791        let Some(line) = self.read_line_any()? else {
792            return Ok(());
793        };
794        let t = line.trim_end();
795        if t.is_empty() {
796            return Ok(());
797        }
798        if let Some(spec) = t.strip_prefix("from ") {
799            let oid = self.resolve_commit_ish(spec.trim())?;
800            self.branch_tips.insert(refname.to_string(), oid);
801            if !self.force {
802                if let Ok(old) = crate::refs::resolve_ref(&self.repo.git_dir, refname) {
803                    if old != oid {
804                        let is_ancestor =
805                            crate::merge_base::is_ancestor(self.repo, old, oid).unwrap_or(false);
806                        if !is_ancestor {
807                            return Err(Error::IndexError(format!(
808                                "fast-import: refusing non-fast-forward reset of {refname}"
809                            )));
810                        }
811                    }
812                }
813            }
814            let ident = Self::fast_import_reflog_identity_from_env();
815            self.update_ref_for_fast_import(refname, &oid, &ident, "fast-import")?;
816            return Ok(());
817        }
818        self.stashed_line = Some(line);
819        Ok(())
820    }
821
822    /// Resolve the commit a `notemodify` annotates (branch tip, mark, rev, or full hex).
823    fn resolve_note_target_commit(&self, spec: &str) -> Result<ObjectId> {
824        let oid = if let Some(tip) = self.branch_tips.get(spec) {
825            *tip
826        } else {
827            self.resolve_commit_ish(spec)?
828        };
829        let obj = self.repo.odb.read(&oid)?;
830        if obj.kind != ObjectKind::Commit {
831            return Err(Error::IndexError(format!(
832                "fast-import: notemodify target {spec} is not a commit"
833            )));
834        }
835        Ok(oid)
836    }
837}
838
839fn validate_fast_import_refname(refname: &str) -> Result<()> {
840    if refname == "HEAD" {
841        return Ok(());
842    }
843    let full = if refname.starts_with("refs/") {
844        refname.to_owned()
845    } else {
846        format!("refs/heads/{refname}")
847    };
848    check_refname_format(&full, &RefNameOptions::default())
849        .map(|_| ())
850        .map_err(|e| Error::IndexError(format!("fast-import: invalid ref name '{refname}': {e}")))
851}
852
853/// Blob payload source in a `N` (notemodify) command.
854enum NoteBlobSpec {
855    Inline,
856    Mark(u32),
857    Oid(ObjectId),
858}
859
860fn parse_notemodify_operands(rest: &str) -> Result<(NoteBlobSpec, &str)> {
861    let s = rest.trim();
862    if let Some(commit_spec) = s.strip_prefix("inline ") {
863        return Ok((NoteBlobSpec::Inline, commit_spec.trim()));
864    }
865    if let Some(after_colon) = s.strip_prefix(':') {
866        let space = after_colon
867            .find(' ')
868            .ok_or_else(|| Error::IndexError("fast-import: bad N line (mark)".to_owned()))?;
869        let id: u32 = after_colon[..space]
870            .parse()
871            .map_err(|_| Error::IndexError(format!("fast-import: bad mark in N line: {s}")))?;
872        return Ok((NoteBlobSpec::Mark(id), after_colon[space + 1..].trim()));
873    }
874    if s.len() < 41 {
875        return Err(Error::IndexError(format!(
876            "fast-import: bad N line (expected oid + commit-ish): {s}"
877        )));
878    }
879    let head = &s[..40];
880    if !head.chars().all(|c| c.is_ascii_hexdigit()) {
881        return Err(Error::IndexError(format!(
882            "fast-import: bad N line (invalid blob oid): {s}"
883        )));
884    }
885    if s.as_bytes().get(40) != Some(&b' ') {
886        return Err(Error::IndexError(format!(
887            "fast-import: bad N line (missing space after blob oid): {s}"
888        )));
889    }
890    let oid: ObjectId = head
891        .parse()
892        .map_err(|_| Error::IndexError(format!("fast-import: bad blob oid in N line: {s}")))?;
893    Ok((NoteBlobSpec::Oid(oid), s[41..].trim()))
894}
895
896fn is_note_index_path(path: &[u8]) -> bool {
897    let compact: Vec<u8> = path.iter().copied().filter(|b| *b != b'/').collect();
898    compact.len() == 40 && compact.iter().all(u8::is_ascii_hexdigit)
899}
900
901fn compact_hex_from_note_path(path: &[u8]) -> Option<String> {
902    if !is_note_index_path(path) {
903        return None;
904    }
905    let s: String = path
906        .iter()
907        .copied()
908        .filter(|b| *b != b'/')
909        .map(|b| char::from(b).to_ascii_lowercase())
910        .collect();
911    Some(s)
912}
913
914fn count_notes_in_index(index: &crate::index::Index) -> usize {
915    index
916        .entries
917        .iter()
918        .filter(|e| is_note_index_path(&e.path))
919        .count()
920}
921
922fn notes_fanout_for_count(mut n: usize) -> usize {
923    let mut fanout = 0usize;
924    while n > 0xff {
925        n >>= 8;
926        fanout += 1;
927    }
928    fanout
929}
930
931fn construct_note_path_with_fanout(commit: &ObjectId, fanout: usize) -> Vec<u8> {
932    let hex = commit.to_hex();
933    let bytes = hex.as_bytes();
934    let split = fanout.min(bytes.len() / 2);
935    let mut out = Vec::with_capacity(hex.len() + split);
936    for i in 0..split {
937        let start = i * 2;
938        out.extend_from_slice(&bytes[start..start + 2]);
939        out.push(b'/');
940    }
941    out.extend_from_slice(&bytes[split * 2..]);
942    out
943}
944
945fn remove_note_entries_for_target(index: &mut crate::index::Index, target: &ObjectId) {
946    let want = target.to_hex();
947    index.entries.retain(|e| {
948        if !is_note_index_path(&e.path) {
949            return true;
950        }
951        compact_hex_from_note_path(&e.path).as_deref() != Some(want.as_str())
952    });
953}
954
955fn rewrite_notes_fanout_in_index(
956    odb: &crate::odb::Odb,
957    index: &mut crate::index::Index,
958    fanout: usize,
959) -> Result<()> {
960    let mut notes: Vec<(ObjectId, ObjectId, u32)> = Vec::new();
961    let mut kept = Vec::new();
962    for e in index.entries.drain(..) {
963        if is_note_index_path(&e.path) {
964            let Some(compact) = compact_hex_from_note_path(&e.path) else {
965                continue;
966            };
967            let commit_oid = compact
968                .parse()
969                .map_err(|_| Error::IndexError("fast-import: bad note path in index".to_owned()))?;
970            notes.push((commit_oid, e.oid, e.mode));
971        } else {
972            kept.push(e);
973        }
974    }
975    index.entries = kept;
976    let mut by_commit: std::collections::BTreeMap<ObjectId, (ObjectId, u32)> =
977        std::collections::BTreeMap::new();
978    for (commit_oid, blob_oid, mode) in notes {
979        if let Some((existing_oid, existing_mode)) = by_commit.get_mut(&commit_oid) {
980            if *existing_oid != blob_oid {
981                let existing = odb.read(existing_oid)?;
982                let incoming = odb.read(&blob_oid)?;
983                let mut existing_len = existing.data.len();
984                if existing_len > 0 && existing.data[existing_len - 1] == b'\n' {
985                    existing_len -= 1;
986                }
987                let mut data = Vec::with_capacity(existing_len + 2 + incoming.data.len());
988                data.extend_from_slice(&existing.data[..existing_len]);
989                data.push(b'\n');
990                data.push(b'\n');
991                data.extend_from_slice(&incoming.data);
992                *existing_oid = odb.write(ObjectKind::Blob, &data)?;
993            }
994            *existing_mode = mode;
995        } else {
996            by_commit.insert(commit_oid, (blob_oid, mode));
997        }
998    }
999    for (commit_oid, (blob_oid, mode)) in by_commit {
1000        let path = construct_note_path_with_fanout(&commit_oid, fanout);
1001        index.add_or_replace(index_entry(path, mode, blob_oid));
1002    }
1003    Ok(())
1004}
1005
1006fn normalize_mode(mode: u32) -> Result<u32> {
1007    match mode {
1008        0o100644 | 0o644 => Ok(MODE_REGULAR),
1009        0o100755 | 0o755 => Ok(crate::index::MODE_EXECUTABLE),
1010        0o120000 => Ok(crate::index::MODE_SYMLINK),
1011        0o160000 => Ok(MODE_GITLINK),
1012        0o040000 => Ok(MODE_TREE),
1013        _ => Err(Error::IndexError(format!(
1014            "fast-import: unsupported mode {mode:o}"
1015        ))),
1016    }
1017}
1018
1019fn index_entry(path: Vec<u8>, mode: u32, oid: ObjectId) -> IndexEntry {
1020    let path_len = path.len().min(0xFFF) as u16;
1021    IndexEntry {
1022        ctime_sec: 0,
1023        ctime_nsec: 0,
1024        mtime_sec: 0,
1025        mtime_nsec: 0,
1026        dev: 0,
1027        ino: 0,
1028        mode,
1029        uid: 0,
1030        gid: 0,
1031        size: 0,
1032        oid,
1033        flags: path_len,
1034        flags_extended: Some(0),
1035        path,
1036        base_index_pos: 0,
1037    }
1038}
1039
1040fn tree_to_index(odb: &crate::odb::Odb, tree_oid: &ObjectId) -> Result<Index> {
1041    let obj = odb.read(tree_oid)?;
1042    if obj.kind != ObjectKind::Tree {
1043        return Err(Error::IndexError(format!("expected tree at {tree_oid}")));
1044    }
1045    let entries = crate::objects::parse_tree(&obj.data)?;
1046    let mut index = Index::new();
1047    for te in entries {
1048        let path = te.name;
1049        if te.mode == MODE_TREE {
1050            let sub = tree_to_index(odb, &te.oid)?;
1051            for mut e in sub.entries {
1052                let mut full = path.clone();
1053                full.push(b'/');
1054                full.extend_from_slice(&e.path);
1055                e.path = full;
1056                let pl = e.path.len().min(0xFFF) as u16;
1057                e.flags = pl;
1058                index.add_or_replace(e);
1059            }
1060        } else {
1061            index.add_or_replace(index_entry(path, te.mode, te.oid));
1062        }
1063    }
1064    Ok(index)
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use crate::refs::resolve_ref;
1071    use crate::repo::init_repository;
1072    use std::io::Cursor;
1073    use tempfile::tempdir;
1074
1075    #[test]
1076    fn fast_import_delimited_data_m_inline_and_note() -> Result<()> {
1077        let dir =
1078            tempdir().map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
1079        let repo = init_repository(dir.path(), false, "main", None, "files")?;
1080
1081        let setup = r#"commit refs/heads/main
1082committer T <t@e> 1000000000 +0000
1083data <<COMMIT
1084m1
1085COMMIT
1086
1087M 644 inline f
1088data <<EOF
1089a
1090EOF
1091
1092commit refs/heads/main
1093committer T <t@e> 1000000001 +0000
1094data <<COMMIT
1095m2
1096COMMIT
1097
1098M 644 inline f
1099data <<EOF
1100b
1101EOF
1102
1103"#;
1104        import_stream(&repo, Cursor::new(setup.as_bytes()))?;
1105
1106        let c2 = resolve_ref(&repo.git_dir, "refs/heads/main")?;
1107        let c2_obj = repo.odb.read(&c2)?;
1108        let c2_parsed = parse_commit(&c2_obj.data)?;
1109        let c1 = c2_parsed
1110            .parents
1111            .first()
1112            .copied()
1113            .ok_or_else(|| Error::IndexError("test: expected parent commit".to_owned()))?;
1114
1115        let notes = format!(
1116            r#"commit refs/notes/commits
1117committer T <t@e> 1000000002 +0000
1118data <<COMMIT
1119n1
1120COMMIT
1121
1122N inline {c1}
1123data <<EOF
1124note1
1125EOF
1126
1127N inline {c2}
1128data <<EOF
1129note2
1130EOF
1131
1132commit refs/notes/commits
1133committer T <t@e> 1000000003 +0000
1134data <<COMMIT
1135n2
1136COMMIT
1137
1138M 644 inline foobar/x.txt
1139data <<EOF
1140non-note
1141EOF
1142
1143N inline {c2}
1144data <<EOF
1145edited
1146EOF
1147
1148"#
1149        );
1150        import_stream(&repo, Cursor::new(notes.as_bytes()))?;
1151
1152        let notes_tip = resolve_ref(&repo.git_dir, "refs/notes/commits")?;
1153        let commit_obj = repo.odb.read(&notes_tip)?;
1154        let parsed = parse_commit(&commit_obj.data)?;
1155        let tree = tree_to_index(&repo.odb, &parsed.tree)?;
1156        assert!(
1157            tree.entries.iter().any(|e| e.path == b"foobar/x.txt"),
1158            "expected non-note path preserved"
1159        );
1160        let mut found_edit = false;
1161        for e in &tree.entries {
1162            if is_note_index_path(&e.path) {
1163                let compact = compact_hex_from_note_path(&e.path).expect("note path");
1164                if compact == c2.to_hex() {
1165                    let blob = repo.odb.read(&e.oid)?;
1166                    assert_eq!(blob.data, b"edited\n");
1167                    found_edit = true;
1168                }
1169            }
1170        }
1171        assert!(found_edit, "expected edited note for second commit");
1172        Ok(())
1173    }
1174}