1use 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#[allow(clippy::struct_excessive_bools)]
33struct AddOpts {
34 #[arg(short = 'A', long)]
38 all: bool,
39
40 #[arg(short = 'u', long)]
44 update: bool,
45
46 #[arg(short = 'f', long)]
49 force: bool,
50
51 #[arg(short = 'p', long)]
58 patch: bool,
59
60 paths: Vec<String>,
63}
64
65pub(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 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 if worktree::stat_matches(entry, &meta) {
109 continue;
110 }
111
112 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 (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 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
178fn 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 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 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 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 let batch = store.batch();
261
262 if opts.all {
263 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 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 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 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
316fn 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 let hashed = hash_pending_batch(&pending, sink);
355
356 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
393const HASH_FANOUT_FILES_PER_THREAD: usize = 8;
421
422fn hash_fanout_threshold() -> usize {
427 HASH_FANOUT_FILES_PER_THREAD.saturating_mul(rayon::current_num_threads())
428}
429
430fn 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
449fn 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
476enum HashOutcome {
478 Done(HashedFile),
479 Failed(HashError),
480 Skipped,
483}
484
485struct PendingHash {
491 abs: PathBuf,
492 rel_str: String,
493 previous_status: EntryStatus,
494}
495
496enum Routed {
499 Done(String),
501 NeedsHash(PendingHash),
503}
504
505fn 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 let existing_pos = idx.find_entry(&rel_str);
541 let previous_status = existing_pos.map_or(EntryStatus::Blob, |i| idx.entries[i].status);
542 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 if let Some(existing) = existing_pos
555 && worktree::stat_matches(&idx.entries[existing], &meta)
556 {
557 return Ok(Routed::Done(rel_str));
558 }
559 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 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
613type HashedFile = (EntryStatus, Hash, (u64, u64, u64, u64));
616
617struct HashError {
625 message: String,
626 code: u8,
627}
628
629fn 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
647fn 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
683fn 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 let rel_path = p
709 .strip_prefix(root)
710 .unwrap_or(&p)
711 .to_string_lossy()
712 .replace('\\', "/");
713 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 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
756struct PatchOutcome {
762 staged: bool,
764 quit: bool,
766}
767
768fn 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 if let Err(e) = ensure_within_repo(root, &abs) {
840 return Err(emit_err(&e, exit::DATAERR));
841 }
842 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 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 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
927fn 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 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 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
984fn 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
1009fn range_str(start: usize, len: usize) -> String {
1011 if len == 1 {
1012 start.to_string()
1013 } else {
1014 format!("{start},{len}")
1015 }
1016}
1017
1018fn 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 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); 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;