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