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::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#[allow(clippy::struct_excessive_bools)]
31struct AddOpts {
32 #[arg(short = 'A', long)]
36 all: bool,
37
38 #[arg(short = 'u', long)]
42 update: bool,
43
44 #[arg(short = 'f', long)]
47 force: bool,
48
49 #[arg(short = 'p', long)]
56 patch: bool,
57
58 paths: Vec<String>,
61}
62
63pub(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 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 if worktree::stat_matches(entry, &meta) {
107 continue;
108 }
109
110 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 (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 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
176fn 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 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 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 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 let batch = store.batch();
259
260 if opts.all {
261 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 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 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 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
314fn 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 let existing_pos = idx.find_entry(&rel_str);
361 let previous_status = existing_pos.map_or(EntryStatus::Blob, |i| idx.entries[i].status);
362 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 if let Some(existing) = existing_pos
375 && worktree::stat_matches(&idx.entries[existing], &meta)
376 {
377 return Ok(rel_str);
378 }
379 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 (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 let rel_path = p
457 .strip_prefix(root)
458 .unwrap_or(&p)
459 .to_string_lossy()
460 .replace('\\', "/");
461 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 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
500struct PatchOutcome {
506 staged: bool,
508 quit: bool,
510}
511
512fn 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 if let Err(e) = ensure_within_repo(root, &abs) {
584 return Err(emit_err(&e, exit::DATAERR));
585 }
586 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 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 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
671fn 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 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 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
728fn 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
753fn range_str(start: usize, len: usize) -> String {
755 if len == 1 {
756 start.to_string()
757 } else {
758 format!("{start},{len}")
759 }
760}
761
762fn 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 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); 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;