Skip to main content

mkit_cli/commands/
add.rs

1//! `mkit add <path>` / `mkit add .` — stage a file (or the whole
2//! worktree) into `.mkit/index`. `add -p` additionally stages individual
3//! hunks interactively (see `run_patch`).
4
5use std::collections::HashSet;
6use std::io::{BufRead, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use clap::Parser;
11use mkit_core::hash::{Hash, ZERO};
12use mkit_core::ignore::{self, IgnoreList};
13use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
14use mkit_core::layout::RepoLayout;
15use mkit_core::object::{Blob, Object};
16use mkit_core::ops::{HunkLineKind, PatchHunk, apply_hunks_subset, enumerate_hunks};
17use mkit_core::serialize;
18use mkit_core::store::{ObjectSink, ObjectStore};
19use mkit_core::worktree;
20
21use crate::clap_shim;
22use crate::exit;
23
24#[derive(Debug, Parser)]
25#[command(
26    name = "mkit add",
27    about = "Stage files (paths, `.`, `-A`, or `-u`) into the index."
28)]
29// CLI flag struct: each bool is an independent clap switch, not a state
30// machine begging to be an enum.
31#[allow(clippy::struct_excessive_bools)]
32struct AddOpts {
33    /// Stage every change in the worktree, including deletions of
34    /// tracked files. Equivalent to `mkit add .` plus deletion
35    /// detection; takes no path arguments.
36    #[arg(short = 'A', long)]
37    all: bool,
38
39    /// Restage only files already tracked in the index: update modified
40    /// ones and record deletions, without adding untracked paths. Takes
41    /// no path arguments.
42    #[arg(short = 'u', long)]
43    update: bool,
44
45    /// Allow staging an explicitly-named path that is ignored by
46    /// `.gitignore`/`.mkitignore` (git refuses these without `-f`).
47    #[arg(short = 'f', long)]
48    force: bool,
49
50    /// Interactively choose hunks to stage from each named file (like
51    /// `git add -p`). Prompts per hunk: `y` stage, `n` skip, `a` stage
52    /// the rest of the file, `d` skip the rest, `q` quit. Regular text
53    /// files only: binary files are skipped (the command still succeeds),
54    /// while symlinks and directories are refused. Requires explicit path
55    /// arguments.
56    #[arg(short = 'p', long)]
57    patch: bool,
58
59    /// Paths to stage. Pass `.` to stage every non-ignored file under
60    /// the current directory. Multiple paths may be given.
61    paths: Vec<String>,
62}
63
64/// Refresh already-tracked index entries from the worktree.
65///
66/// This backs `mkit commit -a`: it mirrors Git's tracked-only shortcut
67/// by updating modified tracked files and staging tracked deletions,
68/// without adding untracked paths.
69pub(super) fn stage_tracked_changes(
70    layout: &RepoLayout,
71    store: &ObjectStore,
72) -> Result<(), String> {
73    let root = layout.worktree_root();
74    let mut idx = super::read_or_seed_index_from_head(layout, store)?;
75
76    let previous = idx.clone();
77
78    // One durability batch for every restaged object; committed below,
79    // before the index write that references them.
80    let batch = store.batch();
81
82    for entry in &mut idx.entries {
83        if entry.status == EntryStatus::Removed {
84            continue;
85        }
86        if !index::validate_index_path(&entry.path) {
87            return Err(format!("invalid index path: {}", entry.path));
88        }
89
90        let abs = root.join(&entry.path);
91        let meta = match abs.symlink_metadata() {
92            Ok(meta) => meta,
93            Err(e)
94                if matches!(
95                    e.kind(),
96                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
97                ) =>
98            {
99                entry.status = EntryStatus::Removed;
100                entry.object_hash = ZERO;
101                continue;
102            }
103            Err(e) => return Err(format!("metadata {}: {e}", abs.display())),
104        };
105
106        // Stat cache: an unchanged tracked file (mtime+size+exec class
107        // all match what was observed at staging time) keeps its entry
108        // untouched — no read, no hash, no store. O(stat) restage.
109        if worktree::stat_matches(entry, &meta) {
110            continue;
111        }
112
113        // Regular files route through `store_file_object` so large
114        // (> CHUNK_THRESHOLD) content lands as a ChunkedBlob, matching
115        // `worktree::{build_tree,hash_file}` and keeping commit/status/rm
116        // hashes consistent (#203). Symlinks are always a single Blob of
117        // their target path.
118        let (status, h, stat) = if meta.file_type().is_file() {
119            let (h, opened_meta) = worktree::hash_file_with_metadata(&batch, &abs)
120                .map_err(|e| format!("read/store {}: {e}", abs.display()))?;
121            let stat = worktree::stat_cache_fields(&opened_meta);
122            (file_status_from_meta(&opened_meta, entry.status), h, stat)
123        } else if meta.file_type().is_symlink() {
124            let target = std::fs::read_link(&abs)
125                .map_err(|e| format!("read link {}: {e}", abs.display()))?;
126            let target_str = target
127                .to_str()
128                .ok_or_else(|| "symlink target is not valid UTF-8".to_string())?;
129            if !worktree::validate_symlink_target(target_str) {
130                return Err(format!("invalid symlink target: {target_str}"));
131            }
132            let blob = Object::Blob(Blob {
133                data: target_str.as_bytes().to_vec(),
134            });
135            let ser = serialize::serialize(&blob).map_err(|e| format!("serialize: {e}"))?;
136            let h = batch.put(&ser).map_err(|e| format!("store: {e}"))?;
137            // Symlinks never stat-match (see worktree::stat_matches).
138            (EntryStatus::Symlink, h, (0, 0, 0, 0))
139        } else {
140            entry.status = EntryStatus::Removed;
141            entry.object_hash = ZERO;
142            continue;
143        };
144
145        entry.status = status;
146        entry.object_hash = h;
147        entry.mtime_ns = stat.0;
148        entry.size = stat.1;
149        entry.ino = stat.2;
150        entry.ctime_ns = stat.3;
151    }
152
153    // Durability ordering: objects first, then the index that
154    // references them.
155    batch.commit().map_err(|e| format!("store: {e}"))?;
156    retain_content_identities(store, &previous, &mut idx)?;
157    index::write_index(layout, &idx).map_err(|e| format!("write index: {e}"))
158}
159
160#[cfg(unix)]
161fn file_status_from_meta(meta: &std::fs::Metadata, _previous: EntryStatus) -> EntryStatus {
162    use std::os::unix::fs::PermissionsExt;
163
164    if meta.permissions().mode() & 0o111 != 0 {
165        EntryStatus::Executable
166    } else {
167        EntryStatus::Blob
168    }
169}
170
171#[cfg(not(unix))]
172fn file_status_from_meta(_meta: &std::fs::Metadata, previous: EntryStatus) -> EntryStatus {
173    if previous == EntryStatus::Executable {
174        EntryStatus::Executable
175    } else {
176        EntryStatus::Blob
177    }
178}
179
180/// Map a [`worktree::WorktreeError`] from `hash_file_with_metadata` to a
181/// sysexits-style code, preserving the read-vs-write distinction the
182/// two-step `read_regular_file_bounded` + `store_file_object` call used
183/// to make explicit (`NOINPUT` vs `CANTCREAT`) now that both steps are
184/// folded into one streaming call.
185fn worktree_err_exit_code(e: &worktree::WorktreeError) -> u8 {
186    match e {
187        worktree::WorktreeError::Io(_) | worktree::WorktreeError::FileTooLarge(_) => exit::NOINPUT,
188        worktree::WorktreeError::Object(_) | worktree::WorktreeError::Store(_) => exit::CANTCREAT,
189        worktree::WorktreeError::InvalidSymlinkTarget(_) | worktree::WorktreeError::InvalidUtf8 => {
190            exit::DATAERR
191        }
192        // Contract violation between mkit-core and its `hash_chunks`
193        // caller, never user-triggerable — see the variant's own doc.
194        worktree::WorktreeError::ChunkBatchLengthMismatch { .. } => exit::SOFTWARE,
195    }
196}
197
198#[must_use]
199pub fn run(args: &[String]) -> u8 {
200    let opts = match clap_shim::parse::<AddOpts>("mkit add", args) {
201        Ok(o) => o,
202        Err(code) => return code,
203    };
204    let cwd = match std::env::current_dir() {
205        Ok(p) => p,
206        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
207    };
208    let layout = match super::resolve_layout(&cwd) {
209        Ok(layout) => layout,
210        Err(code) => return code,
211    };
212    let store = match super::open_store_configured(&layout) {
213        Ok(s) => s,
214        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
215    };
216    let _lock = match super::acquire_worktree_lock(&layout) {
217        Ok(l) => l,
218        Err(code) => return code,
219    };
220
221    // Interactive hunk staging. Incompatible with the bulk modes and
222    // requires explicit file paths (no `.` / `-A` / `-u`).
223    if opts.patch {
224        if opts.all || opts.update {
225            return emit_err(
226                "-p/--patch cannot be combined with -A/--all or -u/--update",
227                exit::USAGE,
228            );
229        }
230        if opts.paths.is_empty() {
231            return emit_err("-p/--patch requires one or more file paths", exit::USAGE);
232        }
233        return run_patch(&layout, &store, &opts.paths, opts.force);
234    }
235
236    // Mode selection. `-A` and `-u` are mutually exclusive with each
237    // other and with positional paths.
238    if opts.all && opts.update {
239        return emit_err("cannot combine -A/--all with -u/--update", exit::USAGE);
240    }
241    if (opts.all || opts.update) && !opts.paths.is_empty() {
242        return emit_err(
243            "-A/--all and -u/--update take no path arguments",
244            exit::USAGE,
245        );
246    }
247
248    if opts.update {
249        // Tracked-only restage, reusing the shared helper that backs
250        // `commit -a`.
251        return match stage_tracked_changes(&layout, &store) {
252            Ok(()) => exit::OK,
253            Err(e) => emit_err(&e, exit::GENERAL_ERROR),
254        };
255    }
256
257    let mut idx = match super::read_or_seed_index_from_head(&layout, &store) {
258        Ok(i) => i,
259        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
260    };
261
262    let previous = idx.clone();
263
264    // One durability batch for the whole command: every staged object
265    // costs zero full flushes until the single commit() below, which
266    // runs before the index write that references them.
267    let batch = store.batch();
268
269    if opts.all {
270        // Stage everything under cwd, then record deletions of tracked
271        // files that vanished from the worktree.
272        if let Err(code) = add_whole_worktree(&cwd, &batch, &mut idx) {
273            return code;
274        }
275    } else if opts.paths.is_empty() {
276        return emit_err(
277            "no paths given (use `.`, -A, -u, or one or more paths)",
278            exit::USAGE,
279        );
280    } else {
281        // Explicit paths are checked against the ignore list (git refuses an
282        // ignored path unless `-f`). Loaded once and shared across paths.
283        let ignores = match ignore::load(&cwd) {
284            Ok(i) => i,
285            Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
286        };
287        for target in &opts.paths {
288            if target == "." {
289                if let Err(code) = add_whole_worktree(&cwd, &batch, &mut idx) {
290                    return code;
291                }
292            } else {
293                // Reject an explicit path that escapes the repo through a
294                // symlinked parent before reading/staging it (the bulk `.`/`-A`
295                // walk can't reach outside, so it is exempt).
296                let p = Path::new(target);
297                let abs = if p.is_absolute() {
298                    p.to_path_buf()
299                } else {
300                    cwd.join(p)
301                };
302                if let Err(e) = ensure_within_repo(&cwd, &abs) {
303                    return emit_err(&e, exit::DATAERR);
304                }
305                match add_one(&cwd, p, &batch, &mut idx, &ignores, opts.force) {
306                    Ok(_) => {}
307                    Err(code) => return code,
308                }
309            }
310        }
311    }
312
313    // Objects become durable before the index that references them.
314    if let Err(e) = batch.commit() {
315        return emit_err(&format!("store: {e}"), exit::CANTCREAT);
316    }
317    if let Err(e) = retain_content_identities(&store, &previous, &mut idx) {
318        return emit_err(&e, exit::DATAERR);
319    }
320    match index::write_index(&layout, &idx) {
321        Ok(()) => exit::OK,
322        Err(e) => emit_err(&format!("write index: {e}"), exit::CANTCREAT),
323    }
324}
325
326/// Preserve an existing staged representation when restaging identical content.
327/// Called after the object batch is durable, so comparison reads the exact bytes
328/// just hashed (never a second, potentially raced worktree read).
329fn retain_content_identities(
330    store: &ObjectStore,
331    previous: &Index,
332    next: &mut Index,
333) -> Result<(), String> {
334    let old: std::collections::HashMap<_, _> = previous
335        .entries
336        .iter()
337        .map(|e| (e.path.as_str(), e))
338        .collect();
339    for entry in &mut next.entries {
340        if entry.status == EntryStatus::Removed {
341            continue;
342        }
343        if let Some(before) = old.get(entry.path.as_str())
344            && before.status != EntryStatus::Removed
345            // Symlink targets require a single Blob. A regular file with the
346            // same bytes may use a ChunkedBlob, so keep the newly staged target
347            // when changing a regular file into a symlink.
348            && (entry.status != EntryStatus::Symlink || before.status == EntryStatus::Symlink)
349            && worktree::content_eq(store, &before.object_hash, &entry.object_hash)
350                .map_err(|e| format!("compare staged {}: {e}", entry.path))?
351        {
352            entry.object_hash = before.object_hash;
353        }
354    }
355    Ok(())
356}
357
358/// Stage every non-ignored worktree file under `root`, then mark any
359/// tracked path missing from the worktree as removed. Backs both
360/// `mkit add .` and `mkit add -A`.
361fn add_whole_worktree(
362    root: &Path,
363    sink: &(dyn ObjectSink + Sync),
364    idx: &mut Index,
365) -> Result<(), u8> {
366    let ignores = match ignore::load(root) {
367        Ok(i) => i,
368        Err(e) => {
369            return Err(emit_err(
370                &format!("read ignore file: {e}"),
371                exit::GENERAL_ERROR,
372            ));
373        }
374    };
375    let mut seen = HashSet::new();
376    let mut pending = Vec::new();
377    add_tree(
378        root,
379        root,
380        false,
381        sink,
382        idx,
383        &ignores,
384        &mut seen,
385        &mut pending,
386    )?;
387
388    // The walk above only stats/validates paths (cheap); the expensive
389    // part — open + read + BLAKE3, streaming through `FastCdc` for large
390    // files — happens in `hash_pending_batch`, sequentially or via
391    // rayon depending on how many files are pending (see
392    // `hash_fanout_threshold`). Index mutation stays single-threaded and
393    // in walk order below regardless of which path hashed the files, so
394    // `remove_directory_conflicts`/`upsert_entry` (via `stage_hashed`)
395    // see the same order the fully-sequential pre-parallelism code did.
396    let hashed = hash_pending_batch(&pending, sink);
397
398    // Any single failure aborts the whole command — the caller never
399    // calls `batch.commit()`/`index::write_index()` on an `Err` path, so
400    // nothing persists regardless of how many files hashed successfully
401    // first. That's why it's fine to skip applying anything to `idx`
402    // below once a failure is known, and why `hash_one`'s `aborted` flag
403    // is worth having: it lets not-yet-started hashes skip entirely
404    // once one file has failed, instead of every pending file paying
405    // its full hash cost only to have the result discarded.
406    //
407    // Report the first failure in walk order (`hashed` mirrors
408    // `pending`'s order 1:1) — the same file `add` would have stopped
409    // on before this was parallelized — printed exactly once here
410    // rather than once per failing closure.
411    if let Some(pos) = hashed
412        .iter()
413        .position(|h| matches!(h, HashOutcome::Failed(_)))
414    {
415        let HashOutcome::Failed(e) = &hashed[pos] else {
416            unreachable!("position() just matched a Failed variant")
417        };
418        return Err(emit_err(&e.message, e.code));
419    }
420
421    for (p, outcome) in pending.into_iter().zip(hashed) {
422        let HashOutcome::Done(hashed_file) = outcome else {
423            unreachable!(
424                "Skipped only occurs once a Failed entry exists, and the check above already returned on any Failed entry"
425            )
426        };
427        stage_hashed(idx, p.rel_str.clone(), hashed_file);
428        seen.insert(p.rel_str);
429    }
430
431    mark_missing_paths_removed(root, idx, &seen);
432    Ok(())
433}
434
435/// Files-per-thread budget below which [`hash_pending_batch`] hashes
436/// sequentially instead of fanning out across rayon's thread pool, for
437/// a pool of a given size.
438///
439/// Measured with `cargo bench -p mkit-benches --bench add_hash_fanout`
440/// (PR #951 Slack thread) on a 4-core box: rayon's pool-dispatch
441/// overhead makes it 25-100% slower than a plain loop for 1-16 files,
442/// roughly ties a plain loop at 32, and wins clearly from 64 files up
443/// (the realistic-bulk-add case `add_staging`'s 10k/100k cases already
444/// cover) — 32 files / 4 threads = 8 files/thread, the conservative
445/// side of that crossover. [`hash_fanout_threshold`] scales this by
446/// the *actual* pool size rather than hardcoding 32, so the decision
447/// stays meaningful on a CI runner or contributor machine with a
448/// different core count than the one this was measured on — the ratio
449/// is assumed to hold rather than re-measured per core count.
450///
451/// A `commonware_parallel::Rayon`-backed adaptive strategy (raised in
452/// the same Slack thread, see `mkit-core/src/pack_shard.rs`'s
453/// `should_use_parallel_strategy`) was considered and rejected: that
454/// function is the same kind of static threshold as this one (a plain
455/// byte-length comparison), not commonware's learned-history policy,
456/// and it only needs `OnceLock`-memoized pool construction because it
457/// is forced to own a dedicated `commonware_parallel::Rayon` pool.
458/// Plain `rayon::prelude::*` (used here) already reuses rayon's own
459/// cached global pool across calls for free, so adopting
460/// commonware-parallel here would add its dependency weight to
461/// mkit-cli for no benefit over what this file already does.
462const HASH_FANOUT_FILES_PER_THREAD: usize = 8;
463
464/// The pending-file count below which [`hash_pending_batch`] hashes
465/// sequentially — see [`HASH_FANOUT_FILES_PER_THREAD`] for where the
466/// budget comes from. Reads rayon's already-initialized global pool
467/// size (cheap: an atomic load after first use, no allocation).
468fn hash_fanout_threshold() -> usize {
469    crate::fanout::threshold(HASH_FANOUT_FILES_PER_THREAD)
470}
471
472/// Hash one [`PendingHash`], short-circuiting to [`HashOutcome::Skipped`]
473/// once `aborted` is set by an earlier failure (from this call or a
474/// concurrent one). Shared by both branches of [`hash_pending_batch`]
475/// — an `AtomicBool` costs nothing extra in the sequential branch's
476/// single-threaded loop, and sharing this closure keeps the two
477/// branches' fail-fast/`Skipped` semantics from drifting apart.
478fn hash_one(
479    sink: &(dyn ObjectSink + Sync),
480    aborted: &AtomicBool,
481    p: &PendingHash,
482    chunk_fanout: bool,
483) -> HashOutcome {
484    if aborted.load(Ordering::Relaxed) {
485        return HashOutcome::Skipped;
486    }
487    match hash_pending(sink, p, chunk_fanout) {
488        Ok(v) => HashOutcome::Done(v),
489        Err(e) => {
490            aborted.store(true, Ordering::Relaxed);
491            HashOutcome::Failed(e)
492        }
493    }
494}
495
496/// Hash every `pending` file — sequentially below
497/// [`hash_fanout_threshold`], via rayon's global thread pool at or
498/// above it. Output mirrors `pending`'s order 1:1 either way, and both
499/// paths stop starting new hashes once one file has failed (see
500/// [`HashOutcome::Skipped`]) — nothing downstream uses a `Skipped`
501/// entry's value, since [`add_whole_worktree`] discards all of
502/// `hashed` on any [`HashOutcome::Failed`].
503///
504/// `WriteBatch::write` (batch.rs) short-locks only its staged-dedup
505/// check and does file I/O outside that lock specifically so
506/// concurrent writers sharing one batch don't convoy on each other —
507/// this is the "future parallel ingest" its own doc comment
508/// anticipated.
509///
510/// Passes `chunk_fanout = true` (allow [`hash_pending`]'s own intra-file
511/// chunk fan-out, see that function's doc) only on the sequential
512/// branch. On the `par_iter` branch every file is already hashed by one
513/// of up to `rayon::current_num_threads()` concurrently-busy workers;
514/// letting each of those *also* fan its own large file's chunks out into
515/// the same global pool was measured (via `chunk_hash_fanout`, an idle
516/// pool) to help a single file, not validated under N-workers-already-
517/// busy contention — nested dispatch there is pure overhead with no
518/// idle capacity left to soak up, not the assumed clean scaling. The
519/// sequential branch has no such outer parallelism to nest inside, so
520/// chunk-level fan-out is the only parallelism available to it and stays
521/// on — this is exactly the single-huge-file case
522/// [`hash_pending`]'s doc describes.
523fn hash_pending_batch(pending: &[PendingHash], sink: &(dyn ObjectSink + Sync)) -> Vec<HashOutcome> {
524    let aborted = AtomicBool::new(false);
525    crate::fanout::map_seq_or_par(pending, hash_fanout_threshold(), |p, is_par| {
526        hash_one(sink, &aborted, p, !is_par)
527    })
528}
529
530/// Result of hashing one [`PendingHash`] inside [`hash_pending_batch`].
531enum HashOutcome {
532    Done(HashedFile),
533    Failed(HashError),
534    /// A different file already failed (`aborted` was set) — this one
535    /// never ran `hash_pending` at all.
536    Skipped,
537}
538
539/// A regular file whose staging was routed by [`route_path`] but whose
540/// hash is not yet computed — the expensive part (open + read + BLAKE3,
541/// possibly a whole-file streaming chunk pass) is deferred so a
542/// tree-wide walk can run it across files in parallel (see
543/// [`add_whole_worktree`]).
544struct PendingHash {
545    abs: PathBuf,
546    rel_str: String,
547    previous_status: EntryStatus,
548}
549
550/// Outcome of routing one worktree path through the shared validate /
551/// ignore / stat-cache checks that used to live inline in `add_one`.
552enum Routed {
553    /// Already staged byte-for-byte (stat cache hit, or nothing to do).
554    Done(String),
555    /// Regular file that needs hashing — see [`PendingHash`].
556    NeedsHash(PendingHash),
557}
558
559/// Validate `abs`/`rel`, resolve the ignore/stat-cache decision, and
560/// stage symlinks inline (cheap: no file-content I/O). Regular files are
561/// handed back as a [`PendingHash`] rather than hashed here, so callers
562/// that stage many files at once (the `add_tree` walk) can hash them in
563/// parallel instead of one at a time.
564///
565/// Shared by [`add_one`] (single explicit path, hashed synchronously)
566/// and [`add_tree`] (whole-worktree walk, hashed via rayon).
567fn route_path(
568    root: &Path,
569    rel: &Path,
570    sink: &dyn ObjectSink,
571    idx: &mut Index,
572    ignores: &IgnoreList,
573    force: bool,
574) -> Result<Routed, u8> {
575    let abs = if rel.is_absolute() {
576        rel.to_path_buf()
577    } else {
578        root.join(rel)
579    };
580    let meta = abs
581        .symlink_metadata()
582        .map_err(|e| emit_err(&format!("metadata {}: {e}", abs.display()), exit::NOINPUT))?;
583    let rel_str = abs
584        .strip_prefix(root)
585        .unwrap_or(rel)
586        .to_string_lossy()
587        .replace('\\', "/");
588    if !index::validate_index_path(&rel_str) {
589        return Err(emit_err(&format!("invalid path: {rel_str}"), exit::DATAERR));
590    }
591    // One O(log n) lookup shared by every check below (issue #708 —
592    // `find_entry` used to be an O(n) scan, and this path once ran it
593    // three times per file, making bulk staging O(N^2)).
594    let existing_pos = idx.find_entry(&rel_str);
595    let previous_status = existing_pos.map_or(EntryStatus::Blob, |i| idx.entries[i].status);
596    // An ignored path named explicitly is refused unless `-f` — but a path
597    // that is *already tracked* is never subject to ignore (git parity).
598    let already_tracked = previous_status != EntryStatus::Removed && existing_pos.is_some();
599    if !force && !already_tracked && ignores.is_ignored_with_ancestors(&rel_str, meta.is_dir()) {
600        return Err(emit_err(
601            &format!("path '{rel_str}' is ignored; use -f to add it anyway"),
602            exit::USAGE,
603        ));
604    }
605    // Stat cache: a tracked file whose mtime+size+exec class match the
606    // index entry is already staged byte-for-byte — skip the read, the
607    // hash, and the store write entirely.
608    if let Some(existing) = existing_pos
609        && worktree::stat_matches(&idx.entries[existing], &meta)
610    {
611        return Ok(Routed::Done(rel_str));
612    }
613    // Regular files route through `store_file_object` (via
614    // `hash_file_with_metadata`, called by the caller once hashing
615    // actually runs) so large (> CHUNK_THRESHOLD) content lands as a
616    // ChunkedBlob, matching `worktree::{build_tree,hash_file}` (#203).
617    // Symlinks stay a single Blob of their target path and are cheap
618    // enough (no file-content I/O) to stage right here.
619    if meta.file_type().is_file() {
620        Ok(Routed::NeedsHash(PendingHash {
621            abs,
622            rel_str,
623            previous_status,
624        }))
625    } else if meta.file_type().is_symlink() {
626        let target = std::fs::read_link(&abs)
627            .map_err(|e| emit_err(&format!("read link {}: {e}", abs.display()), exit::NOINPUT))?;
628        let target_str = match target.to_str() {
629            Some(t) => t.to_string(),
630            None => return Err(emit_err("symlink target is not valid UTF-8", exit::DATAERR)),
631        };
632        if !worktree::validate_symlink_target(&target_str) {
633            return Err(emit_err(
634                &format!("invalid symlink target: {target_str}"),
635                exit::DATAERR,
636            ));
637        }
638        let blob = Object::Blob(Blob {
639            data: target_str.into_bytes(),
640        });
641        let ser = serialize::serialize(&blob)
642            .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
643        let h = sink
644            .put(&ser)
645            .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))?;
646        let entry = IndexEntry {
647            path: rel_str.clone(),
648            // Symlinks never stat-match (see worktree::stat_matches).
649            status: EntryStatus::Symlink,
650            object_hash: h,
651            mtime_ns: 0,
652            size: 0,
653            ino: 0,
654            ctime_ns: 0,
655        };
656        idx.remove_directory_conflicts(&entry.path);
657        idx.upsert_entry(entry);
658        Ok(Routed::Done(rel_str))
659    } else {
660        Err(emit_err(
661            &format!("not a regular file: {}", abs.display()),
662            exit::NOINPUT,
663        ))
664    }
665}
666
667/// A hashed file's staging fields: status, content hash, and the
668/// `(mtime_ns, size, ino, ctime_ns)` stat-cache tuple.
669type HashedFile = (EntryStatus, Hash, (u64, u64, u64, u64));
670
671/// A hashing failure that hasn't been reported yet: message + sysexits
672/// code, matching what `emit_err` takes. Kept unprinted until exactly
673/// one survives (see [`hash_pending`]'s doc) — `hash_pending` runs
674/// concurrently across a rayon thread pool, and `emit_err` prints as a
675/// side effect, so printing inside it would echo one line per failing
676/// file in the batch instead of the single error the command ultimately
677/// returns.
678struct HashError {
679    message: String,
680    code: u8,
681}
682
683/// Chunks-per-thread budget below which [`hash_pending`]'s per-chunk
684/// `hash_chunks` callback hashes a large file's chunk batch sequentially
685/// instead of fanning it out across rayon, for a pool of a given size —
686/// same crossover shape as [`hash_fanout_threshold`], sized separately
687/// because a chunk's cost (one BLAKE3 pass over up to `chunker::MAX_SIZE`
688/// bytes plus a temp-file write) differs from a whole small file's.
689///
690/// Measured with `cargo bench -p mkit-benches --bench chunk_hash_fanout`
691/// on a 4-core host (`batch/N_chunks`, sequential vs rayon): 8 chunks is
692/// a wash (0.641 ms vs 0.646 ms — rayon's dispatch cost roughly cancels
693/// its parallelism there), 16 chunks already wins clearly (1.477 ms vs
694/// 1.256 ms, ~15%), and the win widens through a full 64-chunk batch
695/// (this module's `STREAM_HASH_BATCH`-equivalent — see
696/// `worktree::store_large_file_streaming_with`) at 9.774 ms vs 8.164 ms
697/// (~16%), with 32 chunks the best-observed ratio (3.756 ms vs 2.581 ms,
698/// ~31%). 4 chunks/thread puts the crossover at 16 chunks on a 4-core
699/// pool — past the 8-chunk wash, at the first batch size the data shows
700/// a clean win. End-to-end (`file/N_mib`, real FastCDC-cut files
701/// streamed through `hash_file_with_metadata`/`_with`, same host): 8
702/// MiB 107.2 ms → 48.4 ms, 32 MiB 390.1 ms → 230.6 ms, 128 MiB 2095.7 ms
703/// → 1106.9 ms — roughly 1.7-2.2x across the range.
704const CHUNK_FANOUT_CHUNKS_PER_THREAD: usize = 4;
705
706/// The chunk-batch-size threshold below which [`hash_pending`]'s
707/// `hash_chunks` callback stays sequential — see
708/// [`CHUNK_FANOUT_CHUNKS_PER_THREAD`].
709fn chunk_fanout_threshold() -> usize {
710    crate::fanout::threshold(CHUNK_FANOUT_CHUNKS_PER_THREAD)
711}
712
713/// Hash a [`PendingHash`]'s file content. Pure function of `sink` and
714/// `p` (no index access, no printing), so it is safe to call
715/// concurrently across a batch's `PendingHash` list — `sink` (a
716/// `WriteBatch`) short-locks only its staged-dedup check and runs file
717/// I/O outside that lock. Callers report the error themselves via
718/// `emit_err` at the one point it's known to be *the* reported error
719/// (see [`add_one`] and [`add_whole_worktree`]).
720///
721/// For files above `worktree::CHUNK_THRESHOLD`, each batch of cut
722/// chunks is hashed and stored via
723/// [`worktree::hash_file_with_metadata_with`]'s `hash_chunks` callback.
724/// When `chunk_fanout` is true AND a batch is large enough to amortize
725/// rayon's dispatch cost (see [`chunk_fanout_threshold`]), that callback
726/// itself fans out across rayon — this is on top of, not instead of,
727/// [`hash_pending_batch`]'s per-file fan-out, so a worktree with a
728/// single huge file (no other file to fan across) still parallelizes.
729/// `chunk_fanout` is false when the caller is already one of several
730/// concurrently-busy per-file rayon workers (see
731/// [`hash_pending_batch`]'s doc for why nesting fan-out there is
732/// unvalidated, not assumed-safe scaling) — chunks still hash and store
733/// sequentially in that case, same as any file at or below
734/// `CHUNK_THRESHOLD` always has.
735fn hash_pending(
736    sink: &(dyn ObjectSink + Sync),
737    p: &PendingHash,
738    chunk_fanout: bool,
739) -> Result<HashedFile, HashError> {
740    let result = if chunk_fanout {
741        worktree::hash_file_with_metadata_with(sink, &p.abs, |sink, batch| {
742            crate::fanout::try_map_seq_or_par(batch, chunk_fanout_threshold(), |chunk| {
743                worktree::store_chunk_blob(sink, chunk)
744            })
745        })
746    } else {
747        // Per-file parallelism already occupies the pool. Borrow each
748        // chunk from the reader instead of allocating sequential batches.
749        worktree::hash_file_with_metadata(sink, &p.abs)
750    };
751    let (h, opened_meta) = result.map_err(|e| HashError {
752        message: format!("{}: {e}", p.abs.display()),
753        code: worktree_err_exit_code(&e),
754    })?;
755    let stat = worktree::stat_cache_fields(&opened_meta);
756    let status = file_status_from_meta(&opened_meta, p.previous_status);
757    Ok((status, h, stat))
758}
759
760/// Build the index entry for a successfully-hashed file and apply it —
761/// the tail shared by [`add_one`]'s single-path hash and
762/// [`add_whole_worktree`]'s parallel-hash apply loop.
763fn stage_hashed(idx: &mut Index, rel_str: String, hashed: HashedFile) {
764    let (status, h, stat) = hashed;
765    let entry = IndexEntry {
766        path: rel_str,
767        status,
768        object_hash: h,
769        mtime_ns: stat.0,
770        size: stat.1,
771        ino: stat.2,
772        ctime_ns: stat.3,
773    };
774    idx.remove_directory_conflicts(&entry.path);
775    idx.upsert_entry(entry);
776}
777
778fn add_one(
779    root: &Path,
780    rel: &Path,
781    sink: &(dyn ObjectSink + Sync),
782    idx: &mut Index,
783    ignores: &IgnoreList,
784    force: bool,
785) -> Result<String, u8> {
786    match route_path(root, rel, sink, idx, ignores, force)? {
787        Routed::Done(rel_str) => Ok(rel_str),
788        Routed::NeedsHash(p) => {
789            // Single explicit path, never part of a per-file rayon
790            // fan-out (that's `add_whole_worktree`'s `hash_pending_batch`
791            // only) — chunk-level fan-out is the only parallelism
792            // available for a large file here, so keep it on.
793            let hashed = hash_pending(sink, &p, true).map_err(|e| emit_err(&e.message, e.code))?;
794            stage_hashed(idx, p.rel_str.clone(), hashed);
795            Ok(p.rel_str)
796        }
797    }
798}
799
800/// Walk `dir`, routing each included file/symlink through [`route_path`].
801/// Symlinks (and stat-cache hits) are fully staged as they're visited;
802/// regular files that need hashing are appended to `pending` instead, so
803/// [`add_whole_worktree`] can hash the whole tree's files in parallel
804/// once the (cheap, metadata-only) walk finishes.
805fn add_tree(
806    root: &Path,
807    dir: &Path,
808    parent_ignored: bool,
809    sink: &dyn ObjectSink,
810    idx: &mut Index,
811    ignores: &IgnoreList,
812    seen: &mut HashSet<String>,
813    pending: &mut Vec<PendingHash>,
814) -> Result<(), u8> {
815    let rd = std::fs::read_dir(dir)
816        .map_err(|e| emit_err(&format!("read dir {}: {e}", dir.display()), exit::NOINPUT))?;
817    for ent in rd.flatten() {
818        let p = ent.path();
819        let meta = p
820            .symlink_metadata()
821            .map_err(|e| emit_err(&format!("metadata {}: {e}", p.display()), exit::NOINPUT))?;
822        let is_dir = meta.file_type().is_dir();
823        // Match ignore patterns against the repo-relative path (so anchored
824        // and multi-segment patterns work), not just the basename.
825        let rel_path = p
826            .strip_prefix(root)
827            .unwrap_or(&p)
828            .to_string_lossy()
829            .replace('\\', "/");
830        // Ignore only excludes UNTRACKED content: an ignored file that is
831        // already tracked (or an ignored dir holding tracked content) is
832        // still visited so `add .`/`add -A` refresh tracked modifications,
833        // matching git. The ancestor-ignored bit propagates so a tracked
834        // dir's untracked-ignored children stay excluded.
835        let entry_ignored = parent_ignored || ignores.is_ignored(&rel_path, is_dir);
836        if entry_ignored && !super::index_tracks_path_or_descendant(idx, &rel_path) {
837            continue;
838        }
839        if meta.file_type().is_dir() {
840            add_tree(root, &p, entry_ignored, sink, idx, ignores, seen, pending)?;
841        } else if meta.file_type().is_file() || meta.file_type().is_symlink() {
842            // The include decision was made above, so `force` skips a
843            // redundant ignore re-check in `route_path`.
844            match route_path(root, &p, sink, idx, ignores, true)? {
845                Routed::Done(rel) => {
846                    seen.insert(rel);
847                }
848                Routed::NeedsHash(pend) => pending.push(pend),
849            }
850        }
851    }
852    Ok(())
853}
854
855fn mark_missing_paths_removed(root: &Path, idx: &mut Index, seen: &HashSet<String>) {
856    for entry in &mut idx.entries {
857        if entry.status != EntryStatus::Removed
858            && !seen.contains(&entry.path)
859            && matches!(
860                root.join(&entry.path).symlink_metadata(),
861                Err(e) if matches!(
862                    e.kind(),
863                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
864                )
865            )
866        {
867            entry.status = EntryStatus::Removed;
868            entry.object_hash = ZERO;
869        }
870    }
871}
872
873// =====================================================================
874// `add -p` — interactive hunk staging
875// =====================================================================
876
877/// Outcome of patching a single file.
878struct PatchOutcome {
879    /// At least one hunk was staged (the index needs writing).
880    staged: bool,
881    /// The user asked to quit (`q`) — stop processing remaining files.
882    quit: bool,
883}
884
885/// Drive interactive hunk staging across the named files. The index is
886/// seeded from HEAD (so a base exists for already-committed files) and only
887/// written back if at least one hunk was staged — selecting nothing leaves
888/// the index untouched, matching `git add -p`.
889fn run_patch(layout: &RepoLayout, store: &ObjectStore, paths: &[String], force: bool) -> u8 {
890    let root = layout.worktree_root();
891    let mut idx = match super::read_or_seed_index_from_head(layout, store) {
892        Ok(i) => i,
893        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
894    };
895    let ignores = match ignore::load(root) {
896        Ok(i) => i,
897        Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
898    };
899    let stdin = std::io::stdin();
900    let mut input = stdin.lock();
901    let mut any_staged = false;
902    for target in paths {
903        match patch_one_file(
904            root,
905            Path::new(target),
906            store,
907            &mut idx,
908            &ignores,
909            force,
910            &mut input,
911        ) {
912            Ok(outcome) => {
913                any_staged |= outcome.staged;
914                if outcome.quit {
915                    break;
916                }
917            }
918            Err(code) => return code,
919        }
920    }
921    if any_staged && let Err(e) = index::write_index(layout, &idx) {
922        return emit_err(&format!("write index: {e}"), exit::CANTCREAT);
923    }
924    exit::OK
925}
926
927fn patch_one_file(
928    root: &Path,
929    rel: &Path,
930    store: &ObjectStore,
931    idx: &mut Index,
932    ignores: &IgnoreList,
933    force: bool,
934    input: &mut impl BufRead,
935) -> Result<PatchOutcome, u8> {
936    let abs = if rel.is_absolute() {
937        rel.to_path_buf()
938    } else {
939        root.join(rel)
940    };
941    let meta = abs
942        .symlink_metadata()
943        .map_err(|e| emit_err(&format!("metadata {}: {e}", abs.display()), exit::NOINPUT))?;
944    let rel_str = abs
945        .strip_prefix(root)
946        .unwrap_or(rel)
947        .to_string_lossy()
948        .replace('\\', "/");
949    if !index::validate_index_path(&rel_str) {
950        return Err(emit_err(&format!("invalid path: {rel_str}"), exit::DATAERR));
951    }
952    // Refuse a path that reaches outside the repo through a symlinked parent
953    // directory (e.g. `link_out/file.txt`): the lexical `rel_str` would be an
954    // in-repo index path, but reading `abs` follows the symlink and would
955    // stage external content. git refuses to add "beyond a symbolic link".
956    if let Err(e) = ensure_within_repo(root, &abs) {
957        return Err(emit_err(&e, exit::DATAERR));
958    }
959    // Interactive hunk staging is for regular text files only. Directories,
960    // symlinks, and special files are refused with a clear message (git's
961    // `add -p` likewise only patches regular files).
962    if !meta.file_type().is_file() {
963        return Err(emit_err(
964            &format!("-p/--patch supports regular files only: {rel_str}"),
965            exit::USAGE,
966        ));
967    }
968    // An explicitly-named ignored path is refused unless `-f`, matching plain
969    // `add`; an already-tracked path is never subject to ignore (git parity).
970    let already_tracked = idx
971        .find_entry(&rel_str)
972        .is_some_and(|i| idx.entries[i].status != EntryStatus::Removed);
973    if !force && !already_tracked && ignores.is_ignored_with_ancestors(&rel_str, false) {
974        return Err(emit_err(
975            &format!("path '{rel_str}' is ignored; use -f to add it anyway"),
976            exit::USAGE,
977        ));
978    }
979
980    // Base = the currently-staged (or HEAD-seeded) blob, or empty for a new
981    // file. The worktree side is the on-disk content.
982    let base = match idx.find_entry(&rel_str) {
983        Some(i) if idx.entries[i].status != EntryStatus::Removed => {
984            worktree::read_blob(store, &idx.entries[i].object_hash)
985                .map_err(|e| emit_err(&format!("read staged blob: {e}"), exit::GENERAL_ERROR))?
986        }
987        _ => Vec::new(),
988    };
989    let previous_status = idx
990        .find_entry(&rel_str)
991        .map_or(EntryStatus::Blob, |i| idx.entries[i].status);
992    let (opened_meta, work_bytes) = worktree::read_regular_file_bounded(&abs)
993        .map_err(|e| emit_err(&format!("read {}: {e}", abs.display()), exit::NOINPUT))?;
994
995    let hunks = match enumerate_hunks(&base, &work_bytes) {
996        None => {
997            eprintln!("{rel_str}: binary file — skipped (use `mkit add` to stage whole)");
998            return Ok(PatchOutcome {
999                staged: false,
1000                quit: false,
1001            });
1002        }
1003        Some(h) if h.is_empty() => {
1004            eprintln!("{rel_str}: no changes to stage");
1005            return Ok(PatchOutcome {
1006                staged: false,
1007                quit: false,
1008            });
1009        }
1010        Some(h) => h,
1011    };
1012
1013    let (selected, quit) = select_hunks(&rel_str, &hunks, input)?;
1014    if selected.is_empty() {
1015        return Ok(PatchOutcome {
1016            staged: false,
1017            quit,
1018        });
1019    }
1020
1021    let new_bytes = apply_hunks_subset(&base, &hunks, &selected);
1022    let h = worktree::store_file_object(store, &new_bytes)
1023        .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))?;
1024    let status = file_status_from_meta(&opened_meta, previous_status);
1025    let entry = IndexEntry {
1026        path: rel_str.clone(),
1027        status,
1028        object_hash: h,
1029        mtime_ns: 0,
1030        size: 0,
1031        ino: 0,
1032        ctime_ns: 0,
1033    };
1034    idx.remove_directory_conflicts(&entry.path);
1035    idx.upsert_entry(entry);
1036    eprintln!(
1037        "{rel_str}: staged {} of {} hunks",
1038        selected.len(),
1039        hunks.len()
1040    );
1041    Ok(PatchOutcome { staged: true, quit })
1042}
1043
1044/// Prompt the user for each hunk and return the indices to stage plus
1045/// whether they asked to quit. Prompts and hunk rendering go to stderr
1046/// (human-facing); stdout stays clean.
1047fn select_hunks(
1048    path: &str,
1049    hunks: &[PatchHunk],
1050    input: &mut impl BufRead,
1051) -> Result<(Vec<usize>, bool), u8> {
1052    let mut stderr = std::io::stderr().lock();
1053    let mut selected = Vec::new();
1054    // `Some(true)` = stage all remaining (`a`), `Some(false)` = skip all
1055    // remaining (`d`).
1056    let mut auto: Option<bool> = None;
1057    let mut i = 0;
1058    while i < hunks.len() {
1059        if let Some(stage_rest) = auto {
1060            if stage_rest {
1061                selected.push(i);
1062            }
1063            i += 1;
1064            continue;
1065        }
1066        render_hunk(&mut stderr, path, i, hunks.len(), &hunks[i]);
1067        let _ = write!(stderr, "Stage this hunk [y,n,q,a,d,?]? ");
1068        let _ = stderr.flush();
1069        let mut line = String::new();
1070        let read = input
1071            .read_line(&mut line)
1072            .map_err(|e| emit_err(&format!("read input: {e}"), exit::NOINPUT))?;
1073        if read == 0 {
1074            // EOF — treat as quit, staging whatever was chosen so far.
1075            return Ok((selected, true));
1076        }
1077        match line.trim().chars().next() {
1078            Some('y') => {
1079                selected.push(i);
1080                i += 1;
1081            }
1082            Some('n') => i += 1,
1083            Some('q') => return Ok((selected, true)),
1084            Some('a') => {
1085                selected.push(i);
1086                auto = Some(true);
1087                i += 1;
1088            }
1089            Some('d') => auto = Some(false),
1090            _ => {
1091                let _ = writeln!(
1092                    stderr,
1093                    "y - stage this hunk\nn - skip this hunk\nq - quit; stage selected hunks\na - stage this and all later hunks in the file\nd - skip this and all later hunks in the file\n? - print help"
1094                );
1095            }
1096        }
1097    }
1098    Ok((selected, false))
1099}
1100
1101/// Render a hunk to `out` as a unified-diff fragment for display.
1102fn render_hunk(out: &mut impl Write, path: &str, idx: usize, total: usize, hunk: &PatchHunk) {
1103    let _ = writeln!(out, "--- {path} (hunk {}/{total}) ---", idx + 1);
1104    let _ = writeln!(
1105        out,
1106        "@@ -{} +{} @@",
1107        range_str(hunk.old_start, hunk.old_len),
1108        range_str(hunk.new_start, hunk.new_len)
1109    );
1110    for l in &hunk.lines {
1111        let prefix = match l.kind {
1112            HunkLineKind::Context => b' ',
1113            HunkLineKind::Added => b'+',
1114            HunkLineKind::Removed => b'-',
1115        };
1116        let mut buf = vec![prefix];
1117        buf.extend_from_slice(&l.text);
1118        buf.push(b'\n');
1119        let _ = out.write_all(&buf);
1120        if !l.has_newline {
1121            let _ = writeln!(out, "\\ No newline at end of file");
1122        }
1123    }
1124}
1125
1126/// Format one side of an `@@` range: `start,len`, omitting `,len` when 1.
1127fn range_str(start: usize, len: usize) -> String {
1128    if len == 1 {
1129        start.to_string()
1130    } else {
1131        format!("{start},{len}")
1132    }
1133}
1134
1135/// Reject an explicitly-named path that escapes the repository through a
1136/// symlinked parent directory. Two refusals, matching git's "beyond a
1137/// symbolic link" behavior:
1138///
1139/// 1. The path escapes the repo — its canonical parent is not under the
1140///    canonical repo root (covers `..` traversal and symlinks pointing
1141///    outside).
1142/// 2. Any intermediate (non-leaf) path component is a symlink — even one
1143///    resolving back *inside* the repo. Staging under the lexical path (e.g.
1144///    `link_in/file.txt`) would record an index/tree shape the worktree
1145///    snapshot can never reproduce, since the snapshot treats `link_in` as a
1146///    symlink, not a directory. A symlink as the *leaf* is fine (it is staged
1147///    as a symlink).
1148///
1149/// Only used for explicitly-named paths; the `.`/`-A` worktree walk never
1150/// descends symlinked directories, so it cannot reach through one this way.
1151fn ensure_within_repo(root: &Path, abs: &Path) -> Result<(), String> {
1152    use std::path::Component;
1153
1154    let parent = abs
1155        .parent()
1156        .ok_or_else(|| format!("invalid path: {}", abs.display()))?;
1157    let real_parent = parent
1158        .canonicalize()
1159        .map_err(|e| format!("path {}: {e}", parent.display()))?;
1160    let real_root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
1161    if real_parent != real_root && !real_parent.starts_with(&real_root) {
1162        return Err(format!("path is outside repository: {}", abs.display()));
1163    }
1164
1165    // Reject a symlink anywhere in the parent chain (between root and the
1166    // leaf). `abs` is `root.join(rel)` for relative args, so stripping root
1167    // yields the user-supplied components to check; an absolute arg that does
1168    // not lie lexically under root is already caught by the escape check.
1169    if let Ok(rel) = abs.strip_prefix(root) {
1170        let comps: Vec<Component<'_>> = rel.components().collect();
1171        let parent_count = comps.len().saturating_sub(1); // exclude the leaf
1172        let mut cur = root.to_path_buf();
1173        for comp in &comps[..parent_count] {
1174            if let Component::Normal(name) = comp {
1175                cur.push(name);
1176                if matches!(cur.symlink_metadata(), Ok(m) if m.file_type().is_symlink()) {
1177                    return Err(format!(
1178                        "path traverses a symbolic link ({}): refusing to stage beyond it",
1179                        cur.display()
1180                    ));
1181                }
1182            }
1183        }
1184    }
1185    Ok(())
1186}
1187
1188use super::error as emit_err;