1use 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#[allow(clippy::struct_excessive_bools)]
30struct AddOpts {
31 #[arg(short = 'A', long)]
35 all: bool,
36
37 #[arg(short = 'u', long)]
41 update: bool,
42
43 #[arg(short = 'f', long)]
46 force: bool,
47
48 #[arg(short = 'p', long)]
55 patch: bool,
56
57 paths: Vec<String>,
60}
61
62pub(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 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 if worktree::stat_matches(entry, &meta) {
102 continue;
103 }
104
105 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 (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 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 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 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 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 let batch = store.batch();
237
238 if opts.all {
239 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 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 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 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
292fn 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 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 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 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 (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 let rel_path = p
446 .strip_prefix(root)
447 .unwrap_or(&p)
448 .to_string_lossy()
449 .replace('\\', "/");
450 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 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
489struct PatchOutcome {
495 staged: bool,
497 quit: bool,
499}
500
501fn 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 if let Err(e) = ensure_within_repo(root, &abs) {
572 return Err(emit_err(&e, exit::DATAERR));
573 }
574 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 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 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
663fn 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 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 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
720fn 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
745fn range_str(start: usize, len: usize) -> String {
747 if len == 1 {
748 start.to_string()
749 } else {
750 format!("{start},{len}")
751 }
752}
753
754fn 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 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); 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}