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;
8
9use clap::Parser;
10use mkit_core::hash::ZERO;
11use mkit_core::ignore::{self, IgnoreList};
12use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
13use mkit_core::object::{Blob, Object};
14use mkit_core::ops::{HunkLineKind, PatchHunk, apply_hunks_subset, enumerate_hunks};
15use mkit_core::serialize;
16use mkit_core::store::{ObjectSink, ObjectStore};
17use mkit_core::worktree;
18
19use crate::clap_shim;
20use crate::exit;
21
22#[derive(Debug, Parser)]
23#[command(
24    name = "mkit add",
25    about = "Stage files (paths, `.`, `-A`, or `-u`) into the index."
26)]
27// CLI flag struct: each bool is an independent clap switch, not a state
28// machine begging to be an enum.
29#[allow(clippy::struct_excessive_bools)]
30struct AddOpts {
31    /// Stage every change in the worktree, including deletions of
32    /// tracked files. Equivalent to `mkit add .` plus deletion
33    /// detection; takes no path arguments.
34    #[arg(short = 'A', long)]
35    all: bool,
36
37    /// Restage only files already tracked in the index: update modified
38    /// ones and record deletions, without adding untracked paths. Takes
39    /// no path arguments.
40    #[arg(short = 'u', long)]
41    update: bool,
42
43    /// Allow staging an explicitly-named path that is ignored by
44    /// `.gitignore`/`.mkitignore` (git refuses these without `-f`).
45    #[arg(short = 'f', long)]
46    force: bool,
47
48    /// Interactively choose hunks to stage from each named file (like
49    /// `git add -p`). Prompts per hunk: `y` stage, `n` skip, `a` stage
50    /// the rest of the file, `d` skip the rest, `q` quit. Regular text
51    /// files only: binary files are skipped (the command still succeeds),
52    /// while symlinks and directories are refused. Requires explicit path
53    /// arguments.
54    #[arg(short = 'p', long)]
55    patch: bool,
56
57    /// Paths to stage. Pass `.` to stage every non-ignored file under
58    /// the current directory. Multiple paths may be given.
59    paths: Vec<String>,
60}
61
62/// Refresh already-tracked index entries from the worktree.
63///
64/// This backs `mkit commit -a`: it mirrors Git's tracked-only shortcut
65/// by updating modified tracked files and staging tracked deletions,
66/// without adding untracked paths.
67pub(super) fn stage_tracked_changes(root: &Path, store: &ObjectStore) -> Result<(), String> {
68    let mut idx = super::read_or_seed_index_from_head(root, store)?;
69
70    // One durability batch for every restaged object; committed below,
71    // before the index write that references them.
72    let batch = store.batch();
73
74    for entry in &mut idx.entries {
75        if entry.status == EntryStatus::Removed {
76            continue;
77        }
78        if !index::validate_index_path(&entry.path) {
79            return Err(format!("invalid index path: {}", entry.path));
80        }
81
82        let abs = root.join(&entry.path);
83        let meta = match abs.symlink_metadata() {
84            Ok(meta) => meta,
85            Err(e)
86                if matches!(
87                    e.kind(),
88                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
89                ) =>
90            {
91                entry.status = EntryStatus::Removed;
92                entry.object_hash = ZERO;
93                continue;
94            }
95            Err(e) => return Err(format!("metadata {}: {e}", abs.display())),
96        };
97
98        // Stat cache: an unchanged tracked file (mtime+size+exec class
99        // all match what was observed at staging time) keeps its entry
100        // untouched — no read, no hash, no store. O(stat) restage.
101        if worktree::stat_matches(entry, &meta) {
102            continue;
103        }
104
105        // Regular files route through `store_file_object` so large
106        // (> CHUNK_THRESHOLD) content lands as a ChunkedBlob, matching
107        // `worktree::{build_tree,hash_file}` and keeping commit/status/rm
108        // hashes consistent (#203). Symlinks are always a single Blob of
109        // their target path.
110        let (status, h, stat) = if meta.file_type().is_file() {
111            let (opened_meta, bytes) = worktree::read_regular_file_bounded(&abs)
112                .map_err(|e| format!("read {}: {e}", abs.display()))?;
113            let h =
114                worktree::store_file_object(&batch, &bytes).map_err(|e| format!("store: {e}"))?;
115            let stat = worktree::stat_cache_fields(&opened_meta);
116            (file_status_from_meta(&opened_meta, entry.status), h, stat)
117        } else if meta.file_type().is_symlink() {
118            let target = std::fs::read_link(&abs)
119                .map_err(|e| format!("read link {}: {e}", abs.display()))?;
120            let target_str = target
121                .to_str()
122                .ok_or_else(|| "symlink target is not valid UTF-8".to_string())?;
123            if !worktree::validate_symlink_target(target_str) {
124                return Err(format!("invalid symlink target: {target_str}"));
125            }
126            let blob = Object::Blob(Blob {
127                data: target_str.as_bytes().to_vec(),
128            });
129            let ser = serialize::serialize(&blob).map_err(|e| format!("serialize: {e}"))?;
130            let h = batch.put(&ser).map_err(|e| format!("store: {e}"))?;
131            // Symlinks never stat-match (see worktree::stat_matches).
132            (EntryStatus::Symlink, h, (0, 0, 0, 0))
133        } else {
134            entry.status = EntryStatus::Removed;
135            entry.object_hash = ZERO;
136            continue;
137        };
138
139        entry.status = status;
140        entry.object_hash = h;
141        entry.mtime_ns = stat.0;
142        entry.size = stat.1;
143        entry.ino = stat.2;
144        entry.ctime_ns = stat.3;
145    }
146
147    // Durability ordering: objects first, then the index that
148    // references them.
149    batch.commit().map_err(|e| format!("store: {e}"))?;
150    index::write_index(root, &idx).map_err(|e| format!("write index: {e}"))
151}
152
153#[cfg(unix)]
154fn file_status_from_meta(meta: &std::fs::Metadata, _previous: EntryStatus) -> EntryStatus {
155    use std::os::unix::fs::PermissionsExt;
156
157    if meta.permissions().mode() & 0o111 != 0 {
158        EntryStatus::Executable
159    } else {
160        EntryStatus::Blob
161    }
162}
163
164#[cfg(not(unix))]
165fn file_status_from_meta(_meta: &std::fs::Metadata, previous: EntryStatus) -> EntryStatus {
166    if previous == EntryStatus::Executable {
167        EntryStatus::Executable
168    } else {
169        EntryStatus::Blob
170    }
171}
172
173#[must_use]
174pub fn run(args: &[String]) -> u8 {
175    let opts = match clap_shim::parse::<AddOpts>("mkit add", args) {
176        Ok(o) => o,
177        Err(code) => return code,
178    };
179    let cwd = match std::env::current_dir() {
180        Ok(p) => p,
181        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
182    };
183    let store = match super::open_store_configured(&cwd) {
184        Ok(s) => s,
185        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
186    };
187    let _lock = match super::acquire_worktree_lock(&cwd) {
188        Ok(l) => l,
189        Err(code) => return code,
190    };
191
192    // Interactive hunk staging. Incompatible with the bulk modes and
193    // requires explicit file paths (no `.` / `-A` / `-u`).
194    if opts.patch {
195        if opts.all || opts.update {
196            return emit_err(
197                "-p/--patch cannot be combined with -A/--all or -u/--update",
198                exit::USAGE,
199            );
200        }
201        if opts.paths.is_empty() {
202            return emit_err("-p/--patch requires one or more file paths", exit::USAGE);
203        }
204        return run_patch(&cwd, &store, &opts.paths, opts.force);
205    }
206
207    // Mode selection. `-A` and `-u` are mutually exclusive with each
208    // other and with positional paths.
209    if opts.all && opts.update {
210        return emit_err("cannot combine -A/--all with -u/--update", exit::USAGE);
211    }
212    if (opts.all || opts.update) && !opts.paths.is_empty() {
213        return emit_err(
214            "-A/--all and -u/--update take no path arguments",
215            exit::USAGE,
216        );
217    }
218
219    if opts.update {
220        // Tracked-only restage, reusing the shared helper that backs
221        // `commit -a`.
222        return match stage_tracked_changes(&cwd, &store) {
223            Ok(()) => exit::OK,
224            Err(e) => emit_err(&e, exit::GENERAL_ERROR),
225        };
226    }
227
228    let mut idx = match super::read_or_seed_index_from_head(&cwd, &store) {
229        Ok(i) => i,
230        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
231    };
232
233    // One durability batch for the whole command: every staged object
234    // costs zero full flushes until the single commit() below, which
235    // runs before the index write that references them.
236    let batch = store.batch();
237
238    if opts.all {
239        // Stage everything under cwd, then record deletions of tracked
240        // files that vanished from the worktree.
241        if let Err(code) = add_whole_worktree(&cwd, &batch, &mut idx) {
242            return code;
243        }
244    } else if opts.paths.is_empty() {
245        return emit_err(
246            "no paths given (use `.`, -A, -u, or one or more paths)",
247            exit::USAGE,
248        );
249    } else {
250        // Explicit paths are checked against the ignore list (git refuses an
251        // ignored path unless `-f`). Loaded once and shared across paths.
252        let ignores = match ignore::load(&cwd) {
253            Ok(i) => i,
254            Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
255        };
256        for target in &opts.paths {
257            if target == "." {
258                if let Err(code) = add_whole_worktree(&cwd, &batch, &mut idx) {
259                    return code;
260                }
261            } else {
262                // Reject an explicit path that escapes the repo through a
263                // symlinked parent before reading/staging it (the bulk `.`/`-A`
264                // walk can't reach outside, so it is exempt).
265                let p = Path::new(target);
266                let abs = if p.is_absolute() {
267                    p.to_path_buf()
268                } else {
269                    cwd.join(p)
270                };
271                if let Err(e) = ensure_within_repo(&cwd, &abs) {
272                    return emit_err(&e, exit::DATAERR);
273                }
274                match add_one(&cwd, p, &batch, &mut idx, &ignores, opts.force) {
275                    Ok(_) => {}
276                    Err(code) => return code,
277                }
278            }
279        }
280    }
281
282    // Objects become durable before the index that references them.
283    if let Err(e) = batch.commit() {
284        return emit_err(&format!("store: {e}"), exit::CANTCREAT);
285    }
286    match index::write_index(&cwd, &idx) {
287        Ok(()) => exit::OK,
288        Err(e) => emit_err(&format!("write index: {e}"), exit::CANTCREAT),
289    }
290}
291
292/// Stage every non-ignored worktree file under `root`, then mark any
293/// tracked path missing from the worktree as removed. Backs both
294/// `mkit add .` and `mkit add -A`.
295fn add_whole_worktree(root: &Path, sink: &dyn ObjectSink, idx: &mut Index) -> Result<(), u8> {
296    let ignores = match ignore::load(root) {
297        Ok(i) => i,
298        Err(e) => {
299            return Err(emit_err(
300                &format!("read ignore file: {e}"),
301                exit::GENERAL_ERROR,
302            ));
303        }
304    };
305    let mut seen = HashSet::new();
306    add_tree(root, root, false, sink, idx, &ignores, &mut seen)?;
307    mark_missing_paths_removed(root, idx, &seen);
308    Ok(())
309}
310
311fn add_one(
312    root: &Path,
313    rel: &Path,
314    sink: &dyn ObjectSink,
315    idx: &mut Index,
316    ignores: &IgnoreList,
317    force: bool,
318) -> Result<String, u8> {
319    let abs = if rel.is_absolute() {
320        rel.to_path_buf()
321    } else {
322        root.join(rel)
323    };
324    let meta = abs
325        .symlink_metadata()
326        .map_err(|e| emit_err(&format!("metadata {}: {e}", abs.display()), exit::NOINPUT))?;
327    let rel_str = abs
328        .strip_prefix(root)
329        .unwrap_or(rel)
330        .to_string_lossy()
331        .replace('\\', "/");
332    if !index::validate_index_path(&rel_str) {
333        return Err(emit_err(&format!("invalid path: {rel_str}"), exit::DATAERR));
334    }
335    let previous_status = idx
336        .find_entry(&rel_str)
337        .map_or(EntryStatus::Blob, |existing| idx.entries[existing].status);
338    // An ignored path named explicitly is refused unless `-f` — but a path
339    // that is *already tracked* is never subject to ignore (git parity).
340    let already_tracked =
341        previous_status != EntryStatus::Removed && idx.find_entry(&rel_str).is_some();
342    if !force && !already_tracked && ignores.is_ignored_with_ancestors(&rel_str, meta.is_dir()) {
343        return Err(emit_err(
344            &format!("path '{rel_str}' is ignored; use -f to add it anyway"),
345            exit::USAGE,
346        ));
347    }
348    // Stat cache: a tracked file whose mtime+size+exec class match the
349    // index entry is already staged byte-for-byte — skip the read, the
350    // hash, and the store write entirely.
351    if let Some(existing) = idx.find_entry(&rel_str)
352        && worktree::stat_matches(&idx.entries[existing], &meta)
353    {
354        return Ok(rel_str);
355    }
356    // Regular files route through `store_file_object` so large
357    // (> CHUNK_THRESHOLD) content lands as a ChunkedBlob, matching
358    // `worktree::{build_tree,hash_file}` (#203). Symlinks stay a single
359    // Blob of their target path.
360    let (status, h, stat) = if meta.file_type().is_file() {
361        let (opened_meta, bytes) = worktree::read_regular_file_bounded(&abs)
362            .map_err(|e| emit_err(&format!("read {}: {e}", abs.display()), exit::NOINPUT))?;
363        let h = worktree::store_file_object(sink, &bytes)
364            .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))?;
365        let stat = worktree::stat_cache_fields(&opened_meta);
366        (
367            file_status_from_meta(&opened_meta, previous_status),
368            h,
369            stat,
370        )
371    } else if meta.file_type().is_symlink() {
372        let target = std::fs::read_link(&abs)
373            .map_err(|e| emit_err(&format!("read link {}: {e}", abs.display()), exit::NOINPUT))?;
374        let target_str = match target.to_str() {
375            Some(t) => t.to_string(),
376            None => return Err(emit_err("symlink target is not valid UTF-8", exit::DATAERR)),
377        };
378        if !worktree::validate_symlink_target(&target_str) {
379            return Err(emit_err(
380                &format!("invalid symlink target: {target_str}"),
381                exit::DATAERR,
382            ));
383        }
384        let blob = Object::Blob(Blob {
385            data: target_str.into_bytes(),
386        });
387        let ser = serialize::serialize(&blob)
388            .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
389        let h = sink
390            .put(&ser)
391            .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))?;
392        // Symlinks never stat-match (see worktree::stat_matches).
393        (EntryStatus::Symlink, h, (0, 0, 0, 0))
394    } else {
395        return Err(emit_err(
396            &format!("not a regular file: {}", abs.display()),
397            exit::NOINPUT,
398        ));
399    };
400    let entry = IndexEntry {
401        path: rel_str.clone(),
402        status,
403        object_hash: h,
404        mtime_ns: stat.0,
405        size: stat.1,
406        ino: stat.2,
407        ctime_ns: stat.3,
408    };
409    remove_file_directory_conflicts(idx, &entry.path);
410    if let Some(existing) = idx.find_entry(&entry.path) {
411        idx.entries[existing] = entry;
412    } else {
413        idx.entries.push(entry);
414    }
415    Ok(rel_str)
416}
417
418fn remove_file_directory_conflicts(idx: &mut Index, path: &str) {
419    idx.entries.retain(|entry| {
420        entry.path == path
421            || (!super::index_path_descends_from(&entry.path, path)
422                && !super::index_path_descends_from(path, &entry.path))
423    });
424}
425
426fn add_tree(
427    root: &Path,
428    dir: &Path,
429    parent_ignored: bool,
430    sink: &dyn ObjectSink,
431    idx: &mut Index,
432    ignores: &IgnoreList,
433    seen: &mut HashSet<String>,
434) -> Result<(), u8> {
435    let rd = std::fs::read_dir(dir)
436        .map_err(|e| emit_err(&format!("read dir {}: {e}", dir.display()), exit::NOINPUT))?;
437    for ent in rd.flatten() {
438        let p = ent.path();
439        let meta = p
440            .symlink_metadata()
441            .map_err(|e| emit_err(&format!("metadata {}: {e}", p.display()), exit::NOINPUT))?;
442        let is_dir = meta.file_type().is_dir();
443        // Match ignore patterns against the repo-relative path (so anchored
444        // and multi-segment patterns work), not just the basename.
445        let rel_path = p
446            .strip_prefix(root)
447            .unwrap_or(&p)
448            .to_string_lossy()
449            .replace('\\', "/");
450        // Ignore only excludes UNTRACKED content: an ignored file that is
451        // already tracked (or an ignored dir holding tracked content) is
452        // still visited so `add .`/`add -A` refresh tracked modifications,
453        // matching git. The ancestor-ignored bit propagates so a tracked
454        // dir's untracked-ignored children stay excluded.
455        let entry_ignored = parent_ignored || ignores.is_ignored(&rel_path, is_dir);
456        if entry_ignored && !super::index_tracks_path_or_descendant(idx, &rel_path) {
457            continue;
458        }
459        if meta.file_type().is_dir() {
460            add_tree(root, &p, entry_ignored, sink, idx, ignores, seen)?;
461        } else if meta.file_type().is_file() || meta.file_type().is_symlink() {
462            // The include decision was made above, so `force` skips a
463            // redundant ignore re-check in `add_one`.
464            let rel = add_one(root, &p, sink, idx, ignores, true)?;
465            seen.insert(rel);
466        }
467    }
468    Ok(())
469}
470
471fn mark_missing_paths_removed(root: &Path, idx: &mut Index, seen: &HashSet<String>) {
472    for entry in &mut idx.entries {
473        if entry.status != EntryStatus::Removed
474            && !seen.contains(&entry.path)
475            && matches!(
476                root.join(&entry.path).symlink_metadata(),
477                Err(e) if matches!(
478                    e.kind(),
479                    std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
480                )
481            )
482        {
483            entry.status = EntryStatus::Removed;
484            entry.object_hash = ZERO;
485        }
486    }
487}
488
489// =====================================================================
490// `add -p` — interactive hunk staging
491// =====================================================================
492
493/// Outcome of patching a single file.
494struct PatchOutcome {
495    /// At least one hunk was staged (the index needs writing).
496    staged: bool,
497    /// The user asked to quit (`q`) — stop processing remaining files.
498    quit: bool,
499}
500
501/// Drive interactive hunk staging across the named files. The index is
502/// seeded from HEAD (so a base exists for already-committed files) and only
503/// written back if at least one hunk was staged — selecting nothing leaves
504/// the index untouched, matching `git add -p`.
505fn run_patch(root: &Path, store: &ObjectStore, paths: &[String], force: bool) -> u8 {
506    let mut idx = match super::read_or_seed_index_from_head(root, store) {
507        Ok(i) => i,
508        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
509    };
510    let ignores = match ignore::load(root) {
511        Ok(i) => i,
512        Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
513    };
514    let stdin = std::io::stdin();
515    let mut input = stdin.lock();
516    let mut any_staged = false;
517    for target in paths {
518        match patch_one_file(
519            root,
520            Path::new(target),
521            store,
522            &mut idx,
523            &ignores,
524            force,
525            &mut input,
526        ) {
527            Ok(outcome) => {
528                any_staged |= outcome.staged;
529                if outcome.quit {
530                    break;
531                }
532            }
533            Err(code) => return code,
534        }
535    }
536    if any_staged && let Err(e) = index::write_index(root, &idx) {
537        return emit_err(&format!("write index: {e}"), exit::CANTCREAT);
538    }
539    exit::OK
540}
541
542fn patch_one_file(
543    root: &Path,
544    rel: &Path,
545    store: &ObjectStore,
546    idx: &mut Index,
547    ignores: &IgnoreList,
548    force: bool,
549    input: &mut impl BufRead,
550) -> Result<PatchOutcome, u8> {
551    let abs = if rel.is_absolute() {
552        rel.to_path_buf()
553    } else {
554        root.join(rel)
555    };
556    let meta = abs
557        .symlink_metadata()
558        .map_err(|e| emit_err(&format!("metadata {}: {e}", abs.display()), exit::NOINPUT))?;
559    let rel_str = abs
560        .strip_prefix(root)
561        .unwrap_or(rel)
562        .to_string_lossy()
563        .replace('\\', "/");
564    if !index::validate_index_path(&rel_str) {
565        return Err(emit_err(&format!("invalid path: {rel_str}"), exit::DATAERR));
566    }
567    // Refuse a path that reaches outside the repo through a symlinked parent
568    // directory (e.g. `link_out/file.txt`): the lexical `rel_str` would be an
569    // in-repo index path, but reading `abs` follows the symlink and would
570    // stage external content. git refuses to add "beyond a symbolic link".
571    if let Err(e) = ensure_within_repo(root, &abs) {
572        return Err(emit_err(&e, exit::DATAERR));
573    }
574    // Interactive hunk staging is for regular text files only. Directories,
575    // symlinks, and special files are refused with a clear message (git's
576    // `add -p` likewise only patches regular files).
577    if !meta.file_type().is_file() {
578        return Err(emit_err(
579            &format!("-p/--patch supports regular files only: {rel_str}"),
580            exit::USAGE,
581        ));
582    }
583    // An explicitly-named ignored path is refused unless `-f`, matching plain
584    // `add`; an already-tracked path is never subject to ignore (git parity).
585    let already_tracked = idx
586        .find_entry(&rel_str)
587        .is_some_and(|i| idx.entries[i].status != EntryStatus::Removed);
588    if !force && !already_tracked && ignores.is_ignored_with_ancestors(&rel_str, false) {
589        return Err(emit_err(
590            &format!("path '{rel_str}' is ignored; use -f to add it anyway"),
591            exit::USAGE,
592        ));
593    }
594
595    // Base = the currently-staged (or HEAD-seeded) blob, or empty for a new
596    // file. The worktree side is the on-disk content.
597    let base = match idx.find_entry(&rel_str) {
598        Some(i) if idx.entries[i].status != EntryStatus::Removed => {
599            worktree::read_blob(store, &idx.entries[i].object_hash)
600                .map_err(|e| emit_err(&format!("read staged blob: {e}"), exit::GENERAL_ERROR))?
601        }
602        _ => Vec::new(),
603    };
604    let previous_status = idx
605        .find_entry(&rel_str)
606        .map_or(EntryStatus::Blob, |i| idx.entries[i].status);
607    let (opened_meta, work_bytes) = worktree::read_regular_file_bounded(&abs)
608        .map_err(|e| emit_err(&format!("read {}: {e}", abs.display()), exit::NOINPUT))?;
609
610    let hunks = match enumerate_hunks(&base, &work_bytes) {
611        None => {
612            eprintln!("{rel_str}: binary file — skipped (use `mkit add` to stage whole)");
613            return Ok(PatchOutcome {
614                staged: false,
615                quit: false,
616            });
617        }
618        Some(h) if h.is_empty() => {
619            eprintln!("{rel_str}: no changes to stage");
620            return Ok(PatchOutcome {
621                staged: false,
622                quit: false,
623            });
624        }
625        Some(h) => h,
626    };
627
628    let (selected, quit) = select_hunks(&rel_str, &hunks, input)?;
629    if selected.is_empty() {
630        return Ok(PatchOutcome {
631            staged: false,
632            quit,
633        });
634    }
635
636    let new_bytes = apply_hunks_subset(&base, &hunks, &selected);
637    let h = worktree::store_file_object(store, &new_bytes)
638        .map_err(|e| emit_err(&format!("store: {e}"), exit::CANTCREAT))?;
639    let status = file_status_from_meta(&opened_meta, previous_status);
640    let entry = IndexEntry {
641        path: rel_str.clone(),
642        status,
643        object_hash: h,
644        mtime_ns: 0,
645        size: 0,
646        ino: 0,
647        ctime_ns: 0,
648    };
649    remove_file_directory_conflicts(idx, &entry.path);
650    if let Some(existing) = idx.find_entry(&entry.path) {
651        idx.entries[existing] = entry;
652    } else {
653        idx.entries.push(entry);
654    }
655    eprintln!(
656        "{rel_str}: staged {} of {} hunks",
657        selected.len(),
658        hunks.len()
659    );
660    Ok(PatchOutcome { staged: true, quit })
661}
662
663/// Prompt the user for each hunk and return the indices to stage plus
664/// whether they asked to quit. Prompts and hunk rendering go to stderr
665/// (human-facing); stdout stays clean.
666fn select_hunks(
667    path: &str,
668    hunks: &[PatchHunk],
669    input: &mut impl BufRead,
670) -> Result<(Vec<usize>, bool), u8> {
671    let mut stderr = std::io::stderr().lock();
672    let mut selected = Vec::new();
673    // `Some(true)` = stage all remaining (`a`), `Some(false)` = skip all
674    // remaining (`d`).
675    let mut auto: Option<bool> = None;
676    let mut i = 0;
677    while i < hunks.len() {
678        if let Some(stage_rest) = auto {
679            if stage_rest {
680                selected.push(i);
681            }
682            i += 1;
683            continue;
684        }
685        render_hunk(&mut stderr, path, i, hunks.len(), &hunks[i]);
686        let _ = write!(stderr, "Stage this hunk [y,n,q,a,d,?]? ");
687        let _ = stderr.flush();
688        let mut line = String::new();
689        let read = input
690            .read_line(&mut line)
691            .map_err(|e| emit_err(&format!("read input: {e}"), exit::NOINPUT))?;
692        if read == 0 {
693            // EOF — treat as quit, staging whatever was chosen so far.
694            return Ok((selected, true));
695        }
696        match line.trim().chars().next() {
697            Some('y') => {
698                selected.push(i);
699                i += 1;
700            }
701            Some('n') => i += 1,
702            Some('q') => return Ok((selected, true)),
703            Some('a') => {
704                selected.push(i);
705                auto = Some(true);
706                i += 1;
707            }
708            Some('d') => auto = Some(false),
709            _ => {
710                let _ = writeln!(
711                    stderr,
712                    "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"
713                );
714            }
715        }
716    }
717    Ok((selected, false))
718}
719
720/// Render a hunk to `out` as a unified-diff fragment for display.
721fn render_hunk(out: &mut impl Write, path: &str, idx: usize, total: usize, hunk: &PatchHunk) {
722    let _ = writeln!(out, "--- {path} (hunk {}/{total}) ---", idx + 1);
723    let _ = writeln!(
724        out,
725        "@@ -{} +{} @@",
726        range_str(hunk.old_start, hunk.old_len),
727        range_str(hunk.new_start, hunk.new_len)
728    );
729    for l in &hunk.lines {
730        let prefix = match l.kind {
731            HunkLineKind::Context => b' ',
732            HunkLineKind::Added => b'+',
733            HunkLineKind::Removed => b'-',
734        };
735        let mut buf = vec![prefix];
736        buf.extend_from_slice(&l.text);
737        buf.push(b'\n');
738        let _ = out.write_all(&buf);
739        if !l.has_newline {
740            let _ = writeln!(out, "\\ No newline at end of file");
741        }
742    }
743}
744
745/// Format one side of an `@@` range: `start,len`, omitting `,len` when 1.
746fn range_str(start: usize, len: usize) -> String {
747    if len == 1 {
748        start.to_string()
749    } else {
750        format!("{start},{len}")
751    }
752}
753
754/// Reject an explicitly-named path that escapes the repository through a
755/// symlinked parent directory. Two refusals, matching git's "beyond a
756/// symbolic link" behavior:
757///
758/// 1. The path escapes the repo — its canonical parent is not under the
759///    canonical repo root (covers `..` traversal and symlinks pointing
760///    outside).
761/// 2. Any intermediate (non-leaf) path component is a symlink — even one
762///    resolving back *inside* the repo. Staging under the lexical path (e.g.
763///    `link_in/file.txt`) would record an index/tree shape the worktree
764///    snapshot can never reproduce, since the snapshot treats `link_in` as a
765///    symlink, not a directory. A symlink as the *leaf* is fine (it is staged
766///    as a symlink).
767///
768/// Only used for explicitly-named paths; the `.`/`-A` worktree walk never
769/// descends symlinked directories, so it cannot reach through one this way.
770fn ensure_within_repo(root: &Path, abs: &Path) -> Result<(), String> {
771    use std::path::Component;
772
773    let parent = abs
774        .parent()
775        .ok_or_else(|| format!("invalid path: {}", abs.display()))?;
776    let real_parent = parent
777        .canonicalize()
778        .map_err(|e| format!("path {}: {e}", parent.display()))?;
779    let real_root = root.canonicalize().map_err(|e| format!("repo root: {e}"))?;
780    if real_parent != real_root && !real_parent.starts_with(&real_root) {
781        return Err(format!("path is outside repository: {}", abs.display()));
782    }
783
784    // Reject a symlink anywhere in the parent chain (between root and the
785    // leaf). `abs` is `root.join(rel)` for relative args, so stripping root
786    // yields the user-supplied components to check; an absolute arg that does
787    // not lie lexically under root is already caught by the escape check.
788    if let Ok(rel) = abs.strip_prefix(root) {
789        let comps: Vec<Component<'_>> = rel.components().collect();
790        let parent_count = comps.len().saturating_sub(1); // exclude the leaf
791        let mut cur = root.to_path_buf();
792        for comp in &comps[..parent_count] {
793            if let Component::Normal(name) = comp {
794                cur.push(name);
795                if matches!(cur.symlink_metadata(), Ok(m) if m.file_type().is_symlink()) {
796                    return Err(format!(
797                        "path traverses a symbolic link ({}): refusing to stage beyond it",
798                        cur.display()
799                    ));
800                }
801            }
802        }
803    }
804    Ok(())
805}
806
807fn emit_err(msg: &str, code: u8) -> u8 {
808    let mut stderr = std::io::stderr().lock();
809    let _ = writeln!(stderr, "error: {msg}");
810    code
811}