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