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