1use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fs;
6use std::path::Path as StdPath;
7use std::str::FromStr;
8
9use thiserror::Error;
10
11use crate::apply::parse_patch;
12use crate::apply::{
13 apply_hunks_to_blob, apply_patch_to_index, apply_patch_to_index_and_write, ApplyOptions,
14 FilePatch, Hunk, HunkLine, IndexApplyOptions, IndexApplyOutcome,
15};
16use crate::diff::hunk_id::{hunk_ids, hunk_ids_with_duplicate_peers, DiffSide, HunkId};
17use crate::diff::structured::{
18 structured_file_diff, SideBytes, StructuredHunk, StructuredLine, StructuredLineKind,
19 StructuredSegment,
20};
21use crate::diff::{
22 collect_files_for_target, imara_unified_hunk_slices, include_file_in_diff,
23 read_worktree_side_bytes_for_diff, unified_hunk_body_for_diff_options,
24 unstaged_file_diffs_with_peer_hunks, unstaged_raw_changes_with_peer_hunks,
25 unstaged_raw_changes_with_peer_hunks_in, DiffOptions, DiffStatus, DiffTarget, FileChange,
26 FileDiff,
27};
28use crate::error::{ApplyError, Error};
29use crate::index::{Index, IndexLock, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
30use crate::objects::FileMode;
31use crate::objects::{parse_commit, ObjectId, ObjectKind};
32use crate::porcelain::checkout::{
33 remove_empty_parent_dirs, smudge_bytes_for_worktree_path, write_smudged_blob_to_worktree,
34 write_to_worktree_cached, LeadingDirCache,
35};
36use crate::porcelain::index_from_tree::refresh_stage0_index_stats_for_paths;
37use crate::progress::{poll_cancel, Cancel, ProgressPhase, ProgressSink};
38use crate::repo::Repository;
39use crate::state::{resolve_head_for_repository, HeadState};
40use crate::worktree_rules::WorktreeRules;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct LineRange {
47 pub path: String,
49 pub start: usize,
51 pub end: usize,
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct HunkSelection {
58 pub ids: Vec<HunkId>,
60 pub ranges: Vec<LineRange>,
62}
63
64#[derive(Debug, Error)]
66pub enum HunkSelectError {
67 #[error("unknown hunk id {id}")]
69 UnknownId {
70 id: String,
72 },
73 #[error("stale hunk id {id}")]
75 StaleId {
76 id: HunkId,
78 path: Option<String>,
80 current_ids: Vec<HunkId>,
82 },
83 #[error("hunk id {id} is for the {actual_side} side, not {requested_side}")]
85 WrongSide {
86 id: HunkId,
88 actual_side: DiffSide,
90 requested_side: DiffSide,
92 },
93 #[error("hunks cannot be selected on the {side} side")]
95 UnselectableSide {
96 side: DiffSide,
98 },
99 #[error("binary file {path} has no selectable hunks")]
101 Binary {
102 path: String,
104 },
105 #[error("{0}")]
107 Apply(#[from] ApplyError),
108 #[error("line range {start}-{end} is out of bounds for {path} (file has {max_line} lines)")]
110 RangeOutOfBounds {
111 path: String,
113 start: usize,
115 end: usize,
117 max_line: usize,
119 },
120 #[error("line range selects nothing on {path}")]
122 RangeSelectsNothing {
123 path: String,
125 },
126 #[error("submodule {path} cannot be restored by hunk")]
128 Gitlink {
129 path: String,
131 },
132 #[error("invalid file mode {mode} for {path}")]
134 InvalidMode {
135 path: String,
137 mode: String,
139 },
140 #[error("patch names no path")]
142 MissingPath,
143 #[error(transparent)]
148 Repository(Box<Error>),
149 #[error("could not build patch for selected hunks")]
151 PatchBuild,
152}
153
154impl From<Error> for HunkSelectError {
155 fn from(err: Error) -> Self {
156 HunkSelectError::Repository(Box::new(err))
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct StageHunksOutcome {
163 pub dry_run: bool,
165 pub staged_hunks: BTreeMap<String, Vec<HunkId>>,
167 pub staged_lines: BTreeMap<String, Vec<String>>,
169 pub added: Vec<String>,
171 pub modified: Vec<String>,
173 pub removed: Vec<String>,
175}
176
177impl StageHunksOutcome {
178 fn from_apply(
179 dry_run: bool,
180 staged_hunks: BTreeMap<String, Vec<HunkId>>,
181 staged_lines: BTreeMap<String, Vec<String>>,
182 apply: IndexApplyOutcome,
183 ) -> Self {
184 Self {
185 dry_run,
186 staged_hunks,
187 staged_lines,
188 added: apply.added,
189 modified: apply.modified,
190 removed: apply.removed,
191 }
192 }
193}
194
195pub fn unstaged_file_diffs(
201 repo: &Repository,
202 pathspecs: &[String],
203) -> std::result::Result<Vec<(FileDiff, Vec<StructuredHunk>)>, Error> {
204 let opts = DiffOptions::from_config(repo.config()?.as_ref());
205 let (files, peers) = unstaged_file_diffs_with_peer_hunks(repo, pathspecs, opts)?;
206 Ok(files
207 .into_iter()
208 .map(|f| {
209 let peer = peers.get(&f.path).cloned().unwrap_or_default();
210 (f, peer)
211 })
212 .collect())
213}
214
215pub fn parse_hunk_id_list(
221 tokens: &[String],
222) -> std::result::Result<HunkSelection, HunkSelectError> {
223 let mut ids = Vec::new();
224 for token in tokens {
225 for part in token.split(',').map(str::trim).filter(|s| !s.is_empty()) {
226 ids.push(
227 HunkId::from_str(part).map_err(|_| HunkSelectError::UnknownId {
228 id: part.to_string(),
229 })?,
230 );
231 }
232 }
233 Ok(HunkSelection {
234 ids,
235 ranges: Vec::new(),
236 })
237}
238
239pub fn resolve_selection(
248 repo: &Repository,
249 side: DiffSide,
250 selection: &HunkSelection,
251 pathspecs: &[String],
252 diff_options: DiffOptions,
253) -> std::result::Result<Vec<FilePatch>, HunkSelectError> {
254 Ok(resolve_selection_with_staged(repo, side, selection, pathspecs, diff_options)?.patches)
256}
257
258#[derive(Debug, Clone, Default)]
261pub(crate) struct ResolvedSelection {
262 pub(crate) patches: Vec<FilePatch>,
263 pub(crate) selected_hunks: BTreeMap<String, Vec<HunkId>>,
264 pub(crate) selected_lines: BTreeMap<String, Vec<String>>,
266}
267
268pub(crate) fn resolve_unstaged_selection(
275 repo: &Repository,
276 selection: &HunkSelection,
277 pathspecs: &[String],
278) -> std::result::Result<ResolvedSelection, HunkSelectError> {
279 let diff_opts = diff_options_for_hunk_select(repo)?;
280 resolve_selection_with_staged(repo, DiffSide::Unstaged, selection, pathspecs, diff_opts)
281}
282
283fn merge_resolved(into: &mut ResolvedSelection, other: ResolvedSelection) {
284 for (path, ids) in other.selected_hunks {
285 into.selected_hunks.entry(path).or_default().extend(ids);
286 }
287 for (path, ranges) in other.selected_lines {
288 into.selected_lines.entry(path).or_default().extend(ranges);
289 }
290 for patch in other.patches {
291 if let Some(existing) = into
292 .patches
293 .iter_mut()
294 .find(|p| p.effective_path() == patch.effective_path())
295 {
296 existing.hunks.extend(patch.hunks);
297 existing.hunks.sort_by_key(|h| (h.new_start, h.old_start));
298 } else {
299 into.patches.push(patch);
300 }
301 }
302}
303
304fn resolve_selection_with_staged(
305 repo: &Repository,
306 side: DiffSide,
307 selection: &HunkSelection,
308 pathspecs: &[String],
309 diff_options: DiffOptions,
310) -> std::result::Result<ResolvedSelection, HunkSelectError> {
311 resolve_selection_in(repo, None, side, selection, pathspecs, diff_options)
312}
313
314fn resolve_selection_in(
317 repo: &Repository,
318 index: Option<&mut Index>,
319 side: DiffSide,
320 selection: &HunkSelection,
321 pathspecs: &[String],
322 diff_options: DiffOptions,
323) -> std::result::Result<ResolvedSelection, HunkSelectError> {
324 if selection.ids.is_empty() && selection.ranges.is_empty() {
325 return Ok(ResolvedSelection::default());
326 }
327 let opts = diff_options.with_word_level_inline(false);
329 let requested: HashSet<HunkId> = selection.ids.iter().cloned().collect();
330
331 match side {
332 DiffSide::Unstaged => {
333 let (changes, peer_hunks) = match index {
335 Some(index) => {
336 unstaged_raw_changes_with_peer_hunks_in(repo, index, pathspecs, opts.clone())?
337 }
338 None => unstaged_raw_changes_with_peer_hunks(repo, pathspecs, opts.clone())?,
339 };
340 let mut resolved = if requested.is_empty() {
341 ResolvedSelection::default()
342 } else {
343 resolve_unstaged(
344 repo,
345 &changes,
346 &peer_hunks,
347 pathspecs,
348 opts.clone(),
349 &requested,
350 )?
351 };
352 if !selection.ranges.is_empty() {
353 let line_part = resolve_line_ranges_unstaged(
354 &changes,
355 &peer_hunks,
356 &opts,
357 selection,
358 &requested,
359 )?;
360 merge_resolved(&mut resolved, line_part);
361 }
362 Ok(resolved)
363 }
364 DiffSide::Staged => {
365 if !selection.ranges.is_empty() {
366 return Err(HunkSelectError::RangeSelectsNothing {
367 path: selection.ranges[0].path.clone(),
368 });
369 }
370 resolve_staged(repo, pathspecs, opts, &requested)
371 }
372 DiffSide::Other => Err(HunkSelectError::UnselectableSide { side }),
373 }
374}
375
376fn resolve_staged(
377 repo: &Repository,
378 pathspecs: &[String],
379 opts: DiffOptions,
380 requested: &HashSet<HunkId>,
381) -> std::result::Result<ResolvedSelection, HunkSelectError> {
382 let files = collect_files_for_target(repo, &DiffTarget::IndexVsHead, pathspecs, opts)?;
383 resolve_on_files(
384 DiffSide::Staged,
385 files,
386 HashMap::new(),
387 requested,
388 pathspecs,
389 )
390}
391
392fn resolve_unstaged(
393 repo: &Repository,
394 changes: &[FileChange],
395 peer_hunks: &HashMap<String, Vec<StructuredHunk>>,
396 pathspecs: &[String],
397 opts: DiffOptions,
398 requested: &HashSet<HunkId>,
399) -> std::result::Result<ResolvedSelection, HunkSelectError> {
400 let mut patches = Vec::new();
401 let mut staged_hunks = BTreeMap::new();
402 let mut found = HashSet::new();
403
404 for change in changes {
405 if change.binary {
406 continue;
407 }
408 let peer = peer_hunks
409 .get(&change.path)
410 .map(|h| h.as_slice())
411 .unwrap_or(&[]);
412 let (file_patches, staged, matched) = resolve_file_change_by_unified_body(
413 DiffSide::Unstaged,
414 change,
415 peer,
416 requested,
417 &opts,
418 )?;
419 found.extend(matched);
420 if let Some(staged) = staged {
421 staged_hunks.insert(change.path.clone(), staged);
422 }
423 patches.extend(file_patches);
424 }
425
426 let missing: Vec<HunkId> = requested
427 .iter()
428 .filter(|id| !found.contains(*id))
429 .cloned()
430 .collect();
431 if let Some(id) = missing.first().cloned() {
432 if let Some(wrong) = find_id_on_staged_side(repo, pathspecs, opts.clone(), requested)? {
433 return Err(HunkSelectError::WrongSide {
434 id: wrong,
435 actual_side: DiffSide::Staged,
436 requested_side: DiffSide::Unstaged,
437 });
438 }
439 if pathspecs.len() == 1 {
440 let path = &pathspecs[0];
441 if changes.iter().any(|c| c.path == *path && c.binary) {
442 return Err(HunkSelectError::Binary { path: path.clone() });
443 }
444 }
445 let files: Vec<FileDiff> = changes
446 .iter()
447 .filter(|c| !c.binary)
448 .map(|c| build_file_diff(c, &opts))
449 .collect();
450 let current_ids = current_ids_for_stale(DiffSide::Unstaged, pathspecs, &files, peer_hunks);
451 return Err(HunkSelectError::StaleId {
452 id,
453 path: stale_path_hint(pathspecs, &files),
454 current_ids,
455 });
456 }
457
458 Ok(ResolvedSelection {
459 patches,
460 selected_hunks: staged_hunks,
461 selected_lines: BTreeMap::new(),
462 })
463}
464
465fn side_text(bytes: &SideBytes) -> Cow<'_, str> {
466 if bytes.data.is_empty() {
467 return Cow::Borrowed("");
468 }
469 match std::str::from_utf8(&bytes.data) {
470 Ok(text) => Cow::Borrowed(text),
471 Err(_) => Cow::Owned(String::from_utf8_lossy(&bytes.data).into_owned()),
472 }
473}
474
475fn line_count(text: &str) -> usize {
476 let newlines = memchr::memchr_iter(b'\n', text.as_bytes()).count();
477 if text.is_empty() || text.ends_with('\n') {
478 newlines
479 } else {
480 newlines + 1
481 }
482}
483
484type UnifiedHunkResolve = (Vec<FilePatch>, Option<Vec<HunkId>>, HashSet<HunkId>);
485
486fn resolve_file_change_by_unified_body(
487 side: DiffSide,
488 change: &FileChange,
489 peer_hunks: &[StructuredHunk],
490 requested: &HashSet<HunkId>,
491 diff_options: &DiffOptions,
492) -> std::result::Result<UnifiedHunkResolve, HunkSelectError> {
493 let old_text = side_text(&change.old_bytes);
494 let new_text = side_text(&change.new_bytes);
495 let body =
496 unified_hunk_body_for_diff_options(old_text.as_ref(), new_text.as_ref(), diff_options);
497 let before_count = line_count(old_text.as_ref());
498 let after_count = line_count(new_text.as_ref());
499 let before_ends_nl = old_text.as_ref().ends_with('\n');
500 let after_ends_nl = new_text.as_ref().ends_with('\n');
501 let slices = imara_unified_hunk_slices(&body);
502 let ids = crate::diff::hunk_id::hunk_ids_for_unified_slices(
503 side,
504 &change.path,
505 &slices,
506 before_count,
507 after_count,
508 before_ends_nl,
509 after_ends_nl,
510 peer_hunks,
511 );
512 let mut selected_slices = Vec::new();
513 let mut staged_ids = Vec::new();
514 let mut matched = HashSet::new();
515 for (slice, id) in slices.iter().zip(ids.iter()) {
516 if requested.contains(id) {
517 selected_slices.push(*slice);
518 staged_ids.push(id.clone());
519 matched.insert(id.clone());
520 }
521 }
522 if selected_slices.is_empty() {
523 return Ok((Vec::new(), None, matched));
524 }
525 let patch = file_patch_from_unified_slices(change, &selected_slices)?;
526 Ok((vec![patch], Some(staged_ids), matched))
527}
528
529fn file_patch_from_unified_slices(
530 change: &FileChange,
531 slices: &[&str],
532) -> std::result::Result<FilePatch, HunkSelectError> {
533 let path = &change.path;
534 let is_new = change.old_mode == FileMode::ABSENT;
535 let mut is_deleted =
536 matches!(change.status, DiffStatus::Deleted) || change.new_mode == FileMode::ABSENT;
537 let is_rename = matches!(change.status, DiffStatus::Renamed);
538 let is_copy = matches!(change.status, DiffStatus::Copied);
539 if !is_new && !is_deleted && side_text(&change.new_bytes).trim().is_empty() {
540 let preimage = side_text(&change.old_bytes);
541 if !preimage.trim().is_empty() {
542 is_deleted = true;
543 }
544 }
545 let mut text = format!("diff --git a/{path} b/{path}\n");
546 if is_new {
547 text.push_str(&format!("new file mode {}\n", change.new_mode));
548 }
549 if is_deleted {
550 text.push_str(&format!("deleted file mode {}\n", change.old_mode));
551 }
552 let old = if is_new {
553 "/dev/null".to_string()
554 } else {
555 format!("a/{}", change.old_path.as_deref().unwrap_or(path.as_str()))
556 };
557 let new = if is_deleted {
558 "/dev/null".to_string()
559 } else {
560 format!("b/{}", path)
561 };
562 text.push_str(&format!("--- {old}\n+++ {new}\n"));
563 for slice in slices {
564 text.push_str(slice);
565 if !slice.ends_with('\n') {
566 text.push('\n');
567 }
568 }
569 let mut patches = parse_patch(&text, 1, path, false, None).map_err(map_parse_err)?;
570 let mut patch = patches.pop().ok_or(HunkSelectError::PatchBuild)?;
571 patch.is_new = false;
572 patch.is_deleted = is_deleted;
573 patch.is_rename = is_rename;
574 patch.is_copy = is_copy;
575 patch.old_path = if is_new {
576 None
577 } else {
578 change.old_path.clone().or_else(|| Some(path.clone()))
579 };
580 patch.new_path = if is_deleted { None } else { Some(path.clone()) };
581 patch.old_mode = Some(change.old_mode.to_string());
582 patch.new_mode = Some(change.new_mode.to_string());
583 Ok(patch)
584}
585
586fn build_file_diff(change: &FileChange, opts: &DiffOptions) -> FileDiff {
587 let structured = structured_file_diff(
588 change.old_mode,
589 change.new_mode,
590 &change.old_bytes,
591 &change.new_bytes,
592 opts.clone(),
593 );
594 FileDiff {
595 path: change.path.clone(),
596 old_path: change.old_path.clone(),
597 status: change.status,
598 old_mode: structured.old_mode,
599 new_mode: structured.new_mode,
600 old_oid: change.old_oid,
601 new_oid: change.new_oid,
602 binary: change.binary,
603 content_changed: change.content_changed,
604 old_encoding_lossy: structured.old_encoding_lossy,
605 new_encoding_lossy: structured.new_encoding_lossy,
606 hunks: structured.hunks,
607 line_stat: None,
608 }
609}
610
611fn find_id_on_staged_side(
612 repo: &Repository,
613 pathspecs: &[String],
614 opts: DiffOptions,
615 requested: &HashSet<HunkId>,
616) -> std::result::Result<Option<HunkId>, HunkSelectError> {
617 let files = collect_files_for_target(repo, &DiffTarget::IndexVsHead, pathspecs, opts)?;
618 Ok(files.into_iter().find_map(|file| {
619 if file.binary {
620 return None;
621 }
622 hunk_ids(DiffSide::Staged, &file.path, &file.hunks)
623 .into_iter()
624 .find(|id| requested.contains(id))
625 }))
626}
627
628fn resolve_on_files(
629 side: DiffSide,
630 files: Vec<FileDiff>,
631 peer_hunks: HashMap<String, Vec<StructuredHunk>>,
632 requested: &HashSet<HunkId>,
633 pathspecs: &[String],
634) -> std::result::Result<ResolvedSelection, HunkSelectError> {
635 let mut found = HashSet::new();
636 let mut patches = Vec::new();
637 let mut staged_by_path: BTreeMap<String, Vec<HunkId>> = BTreeMap::new();
638
639 for file in &files {
640 if file.binary {
641 continue;
642 }
643 let peer = peer_hunks
644 .get(&file.path)
645 .map(|h| h.as_slice())
646 .unwrap_or(&[]);
647 let ids = hunk_ids_with_duplicate_peers(side, &file.path, &file.hunks, peer);
648 let mut selected = Vec::new();
649 let mut selected_ids = Vec::new();
650 for (hunk, id) in file.hunks.iter().zip(ids.iter()) {
651 if requested.contains(id) {
652 selected.push(hunk.clone());
653 selected_ids.push(id.clone());
654 found.insert(id.clone());
655 }
656 }
657 if selected.is_empty() {
658 continue;
659 }
660 staged_by_path.insert(file.path.clone(), selected_ids);
661 patches.push(file_patch_from_hunks(file, &selected)?);
662 }
663
664 let missing: Vec<HunkId> = requested
665 .iter()
666 .filter(|id| !found.contains(*id))
667 .cloned()
668 .collect();
669 if let Some(id) = missing.first().cloned() {
670 if let Some(path) = stale_path_hint(pathspecs, &files) {
671 if files
672 .iter()
673 .find(|f| f.path == path)
674 .is_some_and(|f| f.binary)
675 {
676 return Err(HunkSelectError::Binary { path });
677 }
678 }
679 let current_ids = current_ids_for_stale(side, pathspecs, &files, &peer_hunks);
680 return Err(HunkSelectError::StaleId {
681 id,
682 path: stale_path_hint(pathspecs, &files),
683 current_ids,
684 });
685 }
686
687 Ok(ResolvedSelection {
688 patches,
689 selected_hunks: staged_by_path,
690 selected_lines: BTreeMap::new(),
691 })
692}
693
694fn stale_path_hint(pathspecs: &[String], files: &[FileDiff]) -> Option<String> {
695 if pathspecs.len() == 1 {
696 return Some(pathspecs[0].clone());
697 }
698 if files.len() == 1 {
699 return Some(files[0].path.clone());
700 }
701 None
702}
703
704fn current_ids_for_stale(
705 side: DiffSide,
706 pathspecs: &[String],
707 files: &[FileDiff],
708 peer_hunks: &HashMap<String, Vec<StructuredHunk>>,
709) -> Vec<HunkId> {
710 let path = stale_path_hint(pathspecs, files);
711 let Some(path) = path else {
712 return Vec::new();
713 };
714 let Some(file) = files.iter().find(|f| f.path == path) else {
715 return Vec::new();
716 };
717 if file.binary {
718 return Vec::new();
719 }
720 let peer = peer_hunks
721 .get(&file.path)
722 .map(|h| h.as_slice())
723 .unwrap_or(&[]);
724 hunk_ids_with_duplicate_peers(side, &file.path, &file.hunks, peer)
725}
726
727fn file_patch_from_hunks(
728 file: &FileDiff,
729 hunks: &[StructuredHunk],
730) -> std::result::Result<FilePatch, HunkSelectError> {
731 if file.binary {
732 return Err(HunkSelectError::Binary {
733 path: file.path.clone(),
734 });
735 }
736 let is_new = false;
738 let is_deleted = matches!(file.status, DiffStatus::Deleted);
739 let is_rename = matches!(file.status, DiffStatus::Renamed);
740 let apply_hunks: Vec<Hunk> = hunks
741 .iter()
742 .enumerate()
743 .map(|(i, h)| structured_hunk_to_apply_hunk(h, i + 1))
744 .collect();
745 Ok(FilePatch {
746 diff_old_path: file.old_path.clone().or_else(|| Some(file.path.clone())),
747 diff_new_path: Some(file.path.clone()),
748 old_path: if is_new {
749 None
750 } else {
751 file.old_path.clone().or_else(|| Some(file.path.clone()))
752 },
753 new_path: if is_deleted {
754 None
755 } else {
756 Some(file.path.clone())
757 },
758 saw_old_header: true,
759 saw_new_header: true,
760 old_mode: file.old_mode.clone(),
761 new_mode: file.new_mode.clone(),
762 old_mode_line: None,
763 new_mode_line: None,
764 is_new,
765 is_deleted,
766 is_rename,
767 is_copy: matches!(file.status, DiffStatus::Copied),
768 similarity_index: None,
769 dissimilarity_index: None,
770 old_oid: None,
771 new_oid: None,
772 binary_patch: None,
773 is_binary: false,
774 hunks: apply_hunks,
775 ws_rule: 0,
776 is_toplevel_relative: true,
777 })
778}
779
780fn merge_intervals(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
781 if ranges.is_empty() {
782 return Vec::new();
783 }
784 let mut sorted = ranges.to_vec();
785 sorted.sort_unstable_by_key(|(s, _)| *s);
786 let mut out = vec![sorted[0]];
787 for (start, end) in sorted.into_iter().skip(1) {
788 if let Some(last) = out.last_mut() {
789 if start <= last.1 + 1 {
790 last.1 = last.1.max(end);
791 continue;
792 }
793 }
794 out.push((start, end));
795 }
796 out
797}
798
799fn line_in_intervals(line: usize, intervals: &[(usize, usize)]) -> bool {
800 intervals
801 .iter()
802 .any(|(start, end)| line >= *start && line <= *end)
803}
804
805fn deletion_selected(removal_point: usize, intervals: &[(usize, usize)]) -> bool {
806 intervals
807 .iter()
808 .any(|(start, end)| removal_point >= *start && removal_point <= *end + 1)
809}
810
811fn count_new_side_lines(bytes: &[u8]) -> usize {
812 if bytes.is_empty() {
813 return 0;
814 }
815 let mut count = bytes.iter().filter(|&&b| b == b'\n').count();
816 if bytes.last() != Some(&b'\n') {
817 count += 1;
818 }
819 count
820}
821
822fn new_side_line_count(change: &FileChange) -> usize {
823 if matches!(change.status, DiffStatus::Deleted) {
824 return 0;
825 }
826 count_new_side_lines(&change.new_bytes.data)
827}
828
829fn range_label(start: usize, end: usize) -> String {
830 if start == end {
831 format!("{start}")
832 } else {
833 format!("{start}-{end}")
834 }
835}
836
837fn validate_line_ranges(
838 path: &str,
839 intervals: &[(usize, usize)],
840 max_line: usize,
841) -> std::result::Result<(), HunkSelectError> {
842 for &(start, end) in intervals {
843 if start == 0 || end == 0 || start > end || end > max_line {
844 return Err(HunkSelectError::RangeOutOfBounds {
845 path: path.to_string(),
846 start,
847 end,
848 max_line,
849 });
850 }
851 }
852 Ok(())
853}
854
855fn filter_hunk_by_intervals(
856 hunk: &StructuredHunk,
857 intervals: &[(usize, usize)],
858) -> Option<StructuredHunk> {
859 let mut new_cur = hunk.new_start;
860 let mut old_cur = hunk.old_start;
861 let mut out_lines = Vec::new();
862 let mut any_change = false;
863
864 for line in &hunk.lines {
865 match line.kind {
866 StructuredLineKind::Context => {
867 let new_n = line.new.unwrap_or(new_cur);
868 let old_n = line.old.unwrap_or(old_cur);
869 out_lines.push(line.clone());
870 new_cur = new_n.saturating_add(1);
871 old_cur = old_n.saturating_add(1);
872 }
873 StructuredLineKind::Del => {
874 let removal = new_cur;
875 let old_n = line.old.unwrap_or(old_cur);
876 if deletion_selected(removal, intervals) {
877 out_lines.push(line.clone());
878 any_change = true;
879 } else {
880 let text: String = line.segments.iter().map(|s| s.text.as_str()).collect();
881 out_lines.push(StructuredLine {
882 kind: StructuredLineKind::Context,
883 old: Some(old_n),
884 new: Some(removal),
885 segments: vec![StructuredSegment {
886 text,
887 emphasis: false,
888 }],
889 no_newline_at_eof: line.no_newline_at_eof,
890 });
891 }
892 old_cur = old_n.saturating_add(1);
893 }
894 StructuredLineKind::Add => {
895 let new_n = line.new.unwrap_or(new_cur);
896 if line_in_intervals(new_n, intervals) {
897 out_lines.push(line.clone());
898 any_change = true;
899 }
900 new_cur = new_n.saturating_add(1);
901 }
902 }
903 }
904
905 if !any_change {
906 return None;
907 }
908
909 let mut filtered = StructuredHunk {
910 old_start: hunk.old_start,
911 new_start: hunk.new_start,
912 old_lines: hunk.old_lines,
913 new_lines: hunk.new_lines,
914 context: hunk.context.clone(),
915 lines: out_lines,
916 };
917 recount_structured_hunk(&mut filtered);
918 Some(filtered)
919}
920
921fn recount_structured_hunk(hunk: &mut StructuredHunk) {
922 let mut old_start = None;
923 let mut new_start = None;
924 let mut old_count = 0usize;
925 let mut new_count = 0usize;
926 let mut old_line = 0usize;
927 let mut new_line = 0usize;
928
929 for line in &mut hunk.lines {
930 match line.kind {
931 StructuredLineKind::Context => {
932 if old_start.is_none() {
933 old_start = line.old.or(Some(old_line));
934 new_start = line.new.or(Some(new_line));
935 }
936 old_count += 1;
937 new_count += 1;
938 if let Some(o) = line.old {
939 old_line = o.saturating_add(1);
940 } else {
941 old_line = old_line.saturating_add(1);
942 }
943 if let Some(n) = line.new {
944 new_line = n.saturating_add(1);
945 } else {
946 new_line = new_line.saturating_add(1);
947 }
948 }
949 StructuredLineKind::Del => {
950 if old_start.is_none() {
951 old_start = line.old.or(Some(old_line));
952 new_start = Some(new_line);
953 }
954 old_count += 1;
955 if let Some(o) = line.old {
956 old_line = o.saturating_add(1);
957 } else {
958 old_line = old_line.saturating_add(1);
959 }
960 }
961 StructuredLineKind::Add => {
962 if old_start.is_none() {
963 old_start = Some(old_line);
964 new_start = line.new.or(Some(new_line));
965 }
966 new_count += 1;
967 if let Some(n) = line.new {
968 new_line = n.saturating_add(1);
969 } else {
970 new_line = new_line.saturating_add(1);
971 }
972 }
973 }
974 }
975
976 hunk.old_start = old_start.unwrap_or(1);
977 hunk.new_start = new_start.unwrap_or(1);
978 hunk.old_lines = old_count;
979 hunk.new_lines = new_count;
980}
981
982fn resolve_line_ranges_unstaged(
983 changes: &[FileChange],
984 peer_hunks: &HashMap<String, Vec<StructuredHunk>>,
985 opts: &DiffOptions,
986 selection: &HunkSelection,
987 id_selected: &HashSet<HunkId>,
988) -> std::result::Result<ResolvedSelection, HunkSelectError> {
989 let mut by_path: BTreeMap<String, Vec<(usize, usize)>> = BTreeMap::new();
990 let mut labels: BTreeMap<String, Vec<String>> = BTreeMap::new();
991 for range in &selection.ranges {
992 by_path
993 .entry(range.path.clone())
994 .or_default()
995 .push((range.start, range.end));
996 labels
997 .entry(range.path.clone())
998 .or_default()
999 .push(range_label(range.start, range.end));
1000 }
1001
1002 let mut patches = Vec::new();
1003 let mut staged_lines = BTreeMap::new();
1004
1005 for (path, raw_intervals) in by_path {
1006 let intervals = merge_intervals(&raw_intervals);
1007 let change = changes
1008 .iter()
1009 .find(|c| c.path == path)
1010 .ok_or_else(|| HunkSelectError::RangeSelectsNothing { path: path.clone() })?;
1011 if change.binary {
1012 return Err(HunkSelectError::Binary { path: path.clone() });
1013 }
1014 let file = build_file_diff(change, opts);
1015 if !include_file_in_diff(&file, opts) {
1016 return Err(HunkSelectError::RangeSelectsNothing { path });
1017 }
1018 validate_line_ranges(&path, &intervals, new_side_line_count(change))?;
1019 let peer = peer_hunks.get(&path).map(|h| h.as_slice()).unwrap_or(&[]);
1020 let ids = hunk_ids_with_duplicate_peers(DiffSide::Unstaged, &path, &file.hunks, peer);
1021 let mut partial = Vec::new();
1022 let mut selected_any = false;
1023 for (hunk, id) in file.hunks.iter().zip(ids.iter()) {
1024 if id_selected.contains(id) {
1025 continue;
1026 }
1027 if let Some(filtered) = filter_hunk_by_intervals(hunk, &intervals) {
1028 selected_any = true;
1029 partial.push(filtered);
1030 }
1031 }
1032 let id_covered_any = file
1033 .hunks
1034 .iter()
1035 .zip(ids.iter())
1036 .any(|(_, id)| id_selected.contains(id));
1037 if !selected_any && !id_covered_any {
1038 return Err(HunkSelectError::RangeSelectsNothing { path: path.clone() });
1039 }
1040 if selected_any {
1041 patches.push(file_patch_from_hunks(&file, &partial)?);
1042 }
1043 if let Some(labs) = labels.remove(&path) {
1044 staged_lines.insert(path, labs);
1045 }
1046 }
1047
1048 Ok(ResolvedSelection {
1049 patches,
1050 selected_hunks: BTreeMap::new(),
1051 selected_lines: staged_lines,
1052 })
1053}
1054
1055fn structured_hunk_to_apply_hunk(hunk: &StructuredHunk, first_body_line: usize) -> Hunk {
1056 let mut lines = Vec::new();
1057 for line in &hunk.lines {
1058 let text: String = line.segments.iter().map(|s| s.text.as_str()).collect();
1059 match line.kind {
1060 StructuredLineKind::Context => lines.push(HunkLine::Context(text)),
1061 StructuredLineKind::Add => lines.push(HunkLine::Add(text)),
1062 StructuredLineKind::Del => lines.push(HunkLine::Remove(text)),
1063 }
1064 if line.no_newline_at_eof {
1065 lines.push(HunkLine::NoNewline);
1066 }
1067 }
1068 Hunk {
1069 old_start: hunk.old_start,
1070 old_count: hunk.old_lines,
1071 new_start: hunk.new_start,
1072 new_count: hunk.new_lines,
1073 first_body_line,
1074 lines,
1075 }
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Default)]
1080pub struct RestoreHunksOutcome {
1081 pub dry_run: bool,
1083 pub restored: Vec<String>,
1085 pub restored_hunks: BTreeMap<String, Vec<HunkId>>,
1087 pub restored_lines: BTreeMap<String, Vec<String>>,
1089 pub removed: Vec<String>,
1091}
1092
1093pub fn restore_hunks(
1106 repo: &Repository,
1107 selection: &HunkSelection,
1108 pathspecs: &[String],
1109 restore_staged: bool,
1110 restore_worktree: bool,
1111 dry_run: bool,
1112) -> std::result::Result<RestoreHunksOutcome, HunkSelectError> {
1113 let diff_opts = diff_options_for_hunk_select(repo)?;
1114 let restore_worktree = restore_worktree || !restore_staged;
1115 let mut outcome = RestoreHunksOutcome {
1116 dry_run,
1117 ..RestoreHunksOutcome::default()
1118 };
1119 if restore_staged {
1120 let side = DiffSide::Staged;
1121 let resolved =
1122 resolve_selection_with_staged(repo, side, selection, pathspecs, diff_opts.clone())?;
1123 let apply = apply_patch_to_index_and_write(
1124 repo,
1125 &resolved.patches,
1126 &IndexApplyOptions {
1127 dry_run,
1128 reverse: true,
1129 head_tree_oid: head_tree_oid(repo)?,
1130 },
1131 )?;
1132 outcome
1133 .restored
1134 .extend(apply.added.into_iter().chain(apply.modified));
1135 outcome.restored.sort();
1136 outcome.restored.dedup();
1137 outcome.restored_hunks = resolved.selected_hunks;
1138 outcome.restored_lines = resolved.selected_lines;
1139 }
1140 if restore_worktree {
1141 let handle = repo.index_handle();
1144 let lock = if dry_run {
1145 None
1146 } else {
1147 Some(handle.begin_update()?)
1148 };
1149 let mut index = handle.load()?;
1150 let resolved = resolve_selection_in(
1151 repo,
1152 Some(&mut index),
1153 DiffSide::Unstaged,
1154 selection,
1155 pathspecs,
1156 diff_opts,
1157 )?;
1158 let planned = plan_worktree_reverse(repo, &index, &resolved.patches)?;
1159 match lock {
1160 Some(lock) => commit_worktree_reverse(repo, lock, index, &planned, &mut outcome)?,
1161 None => {
1162 for plan in &planned {
1163 push_restored_path(&mut outcome.restored, &plan.path);
1164 }
1165 }
1166 }
1167 merge_restored_maps(&mut outcome.restored_hunks, resolved.selected_hunks);
1168 merge_restored_line_maps(&mut outcome.restored_lines, resolved.selected_lines);
1169 }
1170 Ok(outcome)
1171}
1172
1173fn merge_restored_maps(
1174 into: &mut BTreeMap<String, Vec<HunkId>>,
1175 other: BTreeMap<String, Vec<HunkId>>,
1176) {
1177 for (path, ids) in other {
1178 into.entry(path).or_default().extend(ids);
1179 }
1180}
1181
1182fn merge_restored_line_maps(
1183 into: &mut BTreeMap<String, Vec<String>>,
1184 other: BTreeMap<String, Vec<String>>,
1185) {
1186 for (path, ranges) in other {
1187 into.entry(path).or_default().extend(ranges);
1188 }
1189}
1190
1191fn push_restored_path(restored: &mut Vec<String>, path: &str) {
1192 if !restored.iter().any(|p| p == path) {
1193 restored.push(path.to_string());
1194 }
1195}
1196
1197struct WorktreeReversePlan {
1198 path: String,
1199 clean_bytes: Vec<u8>,
1200 mode: u32,
1201}
1202
1203fn plan_worktree_reverse(
1204 repo: &Repository,
1205 index: &Index,
1206 patches: &[FilePatch],
1207) -> std::result::Result<Vec<WorktreeReversePlan>, HunkSelectError> {
1208 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
1209 let rules = WorktreeRules::from_repository(repo, index).map_err(HunkSelectError::from)?;
1210 let config = repo.config().map_err(HunkSelectError::from)?;
1211 let mut out = Vec::with_capacity(patches.len());
1212 for patch in patches {
1213 let path = patch
1214 .effective_path()
1215 .ok_or(HunkSelectError::MissingPath)?
1216 .to_string();
1217 if patch.involves_gitlink() {
1218 return Err(HunkSelectError::Gitlink { path });
1219 }
1220 let entry = index.get(path.as_bytes(), 0);
1221 let mode = entry.map(|e| e.mode).unwrap_or(MODE_REGULAR);
1222 if mode != MODE_REGULAR && mode != MODE_EXECUTABLE {
1223 return Err(HunkSelectError::Binary { path: path.clone() });
1224 }
1225 let abs = crate::repo_relpath::worktree_abs(work_tree, &path);
1226 let clean = if patch.is_deleted || !abs.exists() {
1227 Vec::new()
1228 } else {
1229 read_worktree_side_bytes_for_diff(
1230 repo.odb(),
1231 work_tree,
1232 &path,
1233 mode,
1234 config.as_ref(),
1235 entry,
1236 Some(&rules),
1237 )
1238 .map_err(HunkSelectError::from)?
1239 };
1240 let new_clean = apply_hunks_to_blob(&clean, &patch.hunks, &ApplyOptions { reverse: true })
1241 .map_err(HunkSelectError::Apply)?;
1242 out.push(WorktreeReversePlan {
1243 path,
1244 clean_bytes: new_clean,
1245 mode,
1246 });
1247 }
1248 Ok(out)
1249}
1250
1251fn commit_worktree_reverse(
1257 repo: &Repository,
1258 mut lock: IndexLock,
1259 mut index: Index,
1260 planned: &[WorktreeReversePlan],
1261 outcome: &mut RestoreHunksOutcome,
1262) -> std::result::Result<(), HunkSelectError> {
1263 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
1264 let rules = WorktreeRules::from_repository(repo, &index).map_err(HunkSelectError::from)?;
1265 let mut dir_cache = LeadingDirCache::new();
1266 for plan in planned {
1267 let oid = repo
1270 .odb()
1271 .hash_algo()
1272 .hash_object(ObjectKind::Blob, &plan.clean_bytes)
1273 .map_err(HunkSelectError::from)?;
1274 let worktree_bytes = smudge_bytes_for_worktree_path(
1275 repo,
1276 &rules,
1277 &plan.path,
1278 plan.mode,
1279 &plan.clean_bytes,
1280 &oid,
1281 )
1282 .map_err(HunkSelectError::from)?;
1283 write_to_worktree_cached(
1284 work_tree,
1285 &plan.path,
1286 &worktree_bytes,
1287 plan.mode,
1288 &mut dir_cache,
1289 )?;
1290 push_restored_path(&mut outcome.restored, &plan.path);
1291 }
1292 if refresh_partial_restore_index_stats(repo, work_tree, &mut index, &outcome.restored)? {
1293 repo.index_handle()
1294 .commit_update(&mut lock, &mut index, false)?;
1295 }
1296 Ok(())
1297}
1298
1299fn head_tree_oid(repo: &Repository) -> std::result::Result<Option<ObjectId>, HunkSelectError> {
1300 match resolve_head_for_repository(repo)? {
1301 HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => {
1302 let obj = repo.odb().read(&oid)?;
1303 let commit = parse_commit(&obj.data)?;
1304 Ok(Some(commit.tree))
1305 }
1306 HeadState::Branch { .. } => Ok(None),
1307 HeadState::Invalid => Err(Error::InvalidHead.into()),
1308 }
1309}
1310
1311fn refresh_partial_restore_index_stats(
1313 repo: &Repository,
1314 work_tree: &StdPath,
1315 index: &mut Index,
1316 restored: &[String],
1317) -> std::result::Result<bool, HunkSelectError> {
1318 let touched: BTreeSet<&str> = restored.iter().map(String::as_str).collect();
1319 Ok(refresh_stage0_index_stats_for_paths(
1320 repo, work_tree, index, &touched,
1321 )?)
1322}
1323
1324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1326pub struct WorktreeReverseApplyOptions {
1327 pub dry_run: bool,
1329}
1330
1331pub fn reverse_apply_patches_to_worktree(
1343 repo: &Repository,
1344 patches: &[FilePatch],
1345 opts: &WorktreeReverseApplyOptions,
1346 progress: &mut dyn ProgressSink,
1347 cancel: &dyn Cancel,
1348) -> std::result::Result<(), ApplyError> {
1349 let work_tree = repo.work_tree().ok_or_else(|| ApplyError::Failed {
1350 path: String::new(),
1351 message: "worktree reverse apply requires a working tree".into(),
1352 })?;
1353 let index = repo.load_index().map_err(|e| ApplyError::Failed {
1354 path: String::new(),
1355 message: e.to_string(),
1356 })?;
1357 let config = repo.config().map_err(|e| ApplyError::Failed {
1358 path: String::new(),
1359 message: e.to_string(),
1360 })?;
1361 let rules = WorktreeRules::from_repository(repo, &index).map_err(|e| ApplyError::Failed {
1362 path: String::new(),
1363 message: e.to_string(),
1364 })?;
1365 progress.start(ProgressPhase::CheckingOutFiles, Some(patches.len() as u64));
1366 for patch in patches {
1367 poll_cancel(progress, cancel).map_err(|_| ApplyError::Cancelled)?;
1368 progress.inc(1);
1369 if patch.involves_gitlink() {
1370 let path = patch.effective_path().unwrap_or("<unknown>").to_string();
1371 return Err(ApplyError::Failed {
1372 path,
1373 message: "submodule/gitlink patches are not supported".into(),
1374 });
1375 }
1376 let path = patch
1377 .target_path()
1378 .map(str::to_string)
1379 .or_else(|| patch.effective_path().map(str::to_string))
1380 .ok_or_else(|| ApplyError::Failed {
1381 path: String::new(),
1382 message: "patch has no target path".into(),
1383 })?;
1384
1385 if patch.is_deleted {
1386 let Some(entry) = index.get(path.as_bytes(), 0) else {
1387 return Err(ApplyError::NotInIndex { path: path.clone() });
1388 };
1389 if entry.mode == MODE_GITLINK {
1390 continue;
1391 }
1392 if !opts.dry_run {
1393 write_smudged_blob_to_worktree(
1394 repo, work_tree, &rules, &path, &entry.oid, entry.mode,
1395 )
1396 .map_err(|e| ApplyError::Failed {
1397 path: path.clone(),
1398 message: e.to_string(),
1399 })?;
1400 }
1401 continue;
1402 }
1403
1404 if patch.is_new {
1405 if !opts.dry_run {
1406 let abs = crate::repo_relpath::worktree_abs(work_tree, &path);
1407 if let Ok(meta) = fs::symlink_metadata(&abs) {
1408 if meta.is_dir() {
1409 let _ = fs::remove_dir_all(&abs);
1410 } else {
1411 fs::remove_file(&abs).map_err(|e| ApplyError::Failed {
1412 path: path.clone(),
1413 message: e.to_string(),
1414 })?;
1415 }
1416 remove_empty_parent_dirs(work_tree, &abs);
1417 }
1418 }
1419 continue;
1420 }
1421
1422 let mode = index
1423 .get(path.as_bytes(), 0)
1424 .map(|e| e.mode)
1425 .unwrap_or(MODE_REGULAR);
1426 if mode == MODE_GITLINK {
1427 continue;
1428 }
1429 let index_entry = index.get(path.as_bytes(), 0);
1430 let base = read_worktree_side_bytes_for_diff(
1431 repo.odb(),
1432 work_tree,
1433 &path,
1434 mode,
1435 config.as_ref(),
1436 index_entry,
1437 Some(&rules),
1438 )
1439 .map_err(|e| ApplyError::Failed {
1440 path: path.clone(),
1441 message: e.to_string(),
1442 })?;
1443 let new_clean = apply_hunks_to_blob(&base, &patch.hunks, &ApplyOptions { reverse: true })?;
1444 if opts.dry_run {
1445 continue;
1446 }
1447 let oid = repo
1448 .odb()
1449 .write(ObjectKind::Blob, &new_clean)
1450 .map_err(|e| ApplyError::Failed {
1451 path: path.clone(),
1452 message: e.to_string(),
1453 })?;
1454 write_smudged_blob_to_worktree(repo, work_tree, &rules, &path, &oid, mode).map_err(
1455 |e| ApplyError::Failed {
1456 path: path.clone(),
1457 message: e.to_string(),
1458 },
1459 )?;
1460 }
1461 progress.finish();
1462 Ok(())
1463}
1464
1465pub fn diff_options_for_hunk_select(repo: &Repository) -> std::result::Result<DiffOptions, Error> {
1471 let config = repo.config()?;
1472 Ok(DiffOptions::from_config(config.as_ref()))
1473}
1474
1475pub fn stage_hunks(
1484 repo: &Repository,
1485 side: DiffSide,
1486 selection: &HunkSelection,
1487 pathspecs: &[String],
1488 diff_options: DiffOptions,
1489 dry_run: bool,
1490) -> std::result::Result<StageHunksOutcome, HunkSelectError> {
1491 let handle = repo.index_handle();
1494 let mut lock = if dry_run {
1495 None
1496 } else {
1497 Some(handle.begin_update()?)
1498 };
1499 let resolved = resolve_selection_in(repo, None, side, selection, pathspecs, diff_options)?;
1500 let mut index = handle.load()?;
1501 let apply = apply_patch_to_index(
1502 repo,
1503 &mut index,
1504 &resolved.patches,
1505 &IndexApplyOptions {
1506 dry_run,
1507 ..IndexApplyOptions::default()
1508 },
1509 )?;
1510 if let Some(lock) = lock.as_mut() {
1511 handle.commit_update(lock, &mut index, false)?;
1512 }
1513 Ok(StageHunksOutcome::from_apply(
1514 dry_run,
1515 resolved.selected_hunks,
1516 resolved.selected_lines,
1517 apply,
1518 ))
1519}
1520
1521fn map_parse_err(_err: Error) -> HunkSelectError {
1522 HunkSelectError::PatchBuild
1523}
1524
1525pub fn list_unstaged_hunk_ids(
1531 repo: &Repository,
1532 pathspecs: &[String],
1533 diff_options: DiffOptions,
1534) -> std::result::Result<BTreeMap<String, Vec<HunkId>>, Error> {
1535 let (changes, peer_hunks) =
1536 unstaged_raw_changes_with_peer_hunks(repo, pathspecs, diff_options.clone())?;
1537 let mut out = BTreeMap::new();
1538 for change in &changes {
1539 if change.binary {
1540 continue;
1541 }
1542 let peer = peer_hunks
1543 .get(&change.path)
1544 .map(|h| h.as_slice())
1545 .unwrap_or(&[]);
1546 let old_text = side_text(&change.old_bytes);
1547 let new_text = side_text(&change.new_bytes);
1548 let body =
1549 unified_hunk_body_for_diff_options(old_text.as_ref(), new_text.as_ref(), &diff_options);
1550 let slices = imara_unified_hunk_slices(&body);
1551 let ids = crate::diff::hunk_id::hunk_ids_for_unified_slices(
1552 DiffSide::Unstaged,
1553 &change.path,
1554 &slices,
1555 line_count(old_text.as_ref()),
1556 line_count(new_text.as_ref()),
1557 old_text.as_ref().ends_with('\n'),
1558 new_text.as_ref().ends_with('\n'),
1559 peer,
1560 );
1561 out.insert(change.path.clone(), ids);
1562 }
1563 Ok(out)
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569 use crate::diff::hunk_diff_bundle;
1570 use crate::diff::hunk_id::hunk_ids;
1571 use crate::index::write_tree::write_tree_from_index;
1572 use crate::porcelain::add::{stage, StageOptions};
1573 use crate::progress::NullProgress;
1574 use crate::repo::{InitOptions, Repository};
1575 use std::process::Command;
1576
1577 fn git(dir: &std::path::Path, args: &[&str]) {
1578 let out = Command::new("git")
1579 .current_dir(dir)
1580 .args(args)
1581 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1582 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1583 .output()
1584 .expect("git");
1585 assert!(
1586 out.status.success(),
1587 "git {args:?}: {}",
1588 String::from_utf8_lossy(&out.stderr)
1589 );
1590 }
1591
1592 fn hunk_diff_opts() -> DiffOptions {
1593 DiffOptions::default()
1594 }
1595
1596 fn init_repo() -> (tempfile::TempDir, Repository) {
1597 let dir = tempfile::tempdir().expect("tempdir");
1598 git(dir.path(), &["init", "-q", "-b", "main"]);
1599 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
1600 (dir, repo)
1601 }
1602
1603 fn spaced_two_hunk_body() -> (String, String) {
1604 three_hunk_body()
1605 }
1606
1607 fn three_hunk_body() -> (String, String) {
1608 let mut base = String::new();
1609 for i in 0..30 {
1610 base.push_str(&format!("line{i}\n"));
1611 }
1612 let mut edited = base.clone();
1613 edited = edited.replacen("line5\n", "LINE5\n", 1);
1614 edited = edited.replacen("line13\n", "LINE13\n", 1);
1615 edited = edited.replacen("line21\n", "LINE21\n", 1);
1616 (base, edited)
1617 }
1618
1619 #[cfg_attr(
1620 not(feature = "full-tests"),
1621 ignore = "starts system git or is heavy; run with --features full-tests"
1622 )]
1623 #[test]
1624 fn stage_hunks_subset_matches_git_apply_cached() {
1625 let (dir, repo) = init_repo();
1626 let root = dir.path();
1627 let (base, edited) = spaced_two_hunk_body();
1628 std::fs::write(root.join("f.txt"), base).unwrap();
1629 git(root, &["add", "f.txt"]);
1630 git(root, &["commit", "-qm", "init"]);
1631
1632 std::fs::write(root.join("f.txt"), edited).unwrap();
1633 let (files, peers) =
1634 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1635 assert_eq!(files.len(), 1);
1636 let ids = hunk_ids_with_duplicate_peers(
1637 DiffSide::Unstaged,
1638 "f.txt",
1639 &files[0].hunks,
1640 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1641 );
1642 assert!(ids.len() >= 2);
1643 let selection = HunkSelection {
1644 ids: vec![ids[0].clone()],
1645 ranges: Vec::new(),
1646 };
1647
1648 let patch = resolve_selection(&repo, DiffSide::Unstaged, &selection, &[], hunk_diff_opts())
1649 .unwrap();
1650 let mut patch_text = String::new();
1651 for p in &patch {
1652 patch_text.push_str(&format_patch_for_git(p));
1653 }
1654
1655 stage_hunks(
1656 &repo,
1657 DiffSide::Unstaged,
1658 &selection,
1659 &[],
1660 hunk_diff_opts(),
1661 false,
1662 )
1663 .unwrap();
1664 let grit_tree =
1665 write_tree_from_index(repo.odb(), &repo.index_handle().load().unwrap(), "").unwrap();
1666
1667 git(root, &["read-tree", "HEAD"]);
1668 let patch_path = root.join("partial.diff");
1669 std::fs::write(&patch_path, patch_text).unwrap();
1670 git(root, &["apply", "--cached", patch_path.to_str().unwrap()]);
1671 let git_tree = Command::new("git")
1672 .current_dir(root)
1673 .args(["write-tree"])
1674 .output()
1675 .expect("write-tree");
1676 let git_tree_hex = String::from_utf8_lossy(&git_tree.stdout).trim().to_string();
1677 assert_eq!(grit_tree.to_hex(), git_tree_hex);
1678 git(root, &["fsck"]);
1679 }
1680
1681 fn write_hunk_line(out: &mut String, prefix: char, text: &str) {
1682 out.push(prefix);
1683 if text.ends_with('\n') {
1684 out.push_str(text);
1685 } else {
1686 out.push_str(text);
1687 out.push('\n');
1688 }
1689 }
1690
1691 fn format_patch_for_git(patch: &FilePatch) -> String {
1692 let mut out = String::new();
1693 let path = patch.effective_path().unwrap_or("f");
1694 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
1695 if patch.is_new {
1696 out.push_str("new file mode 100644\n");
1697 }
1698 let old = if patch.is_new {
1699 "/dev/null".to_string()
1700 } else {
1701 format!("a/{}", patch.old_path.as_deref().unwrap_or(path))
1702 };
1703 let new = if patch.is_deleted {
1704 "/dev/null".to_string()
1705 } else {
1706 format!("b/{}", patch.new_path.as_deref().unwrap_or(path))
1707 };
1708 out.push_str(&format!("--- {old}\n"));
1709 out.push_str(&format!("+++ {new}\n"));
1710 for h in &patch.hunks {
1711 out.push_str(&format!(
1712 "@@ -{},{} +{},{} @@\n",
1713 h.old_start, h.old_count, h.new_start, h.new_count
1714 ));
1715 for line in &h.lines {
1716 match line {
1717 HunkLine::Context(s) => write_hunk_line(&mut out, ' ', s),
1718 HunkLine::Add(s) => write_hunk_line(&mut out, '+', s),
1719 HunkLine::Remove(s) => write_hunk_line(&mut out, '-', s),
1720 HunkLine::NoNewline => out.push_str("\\ No newline at end of file\n"),
1721 }
1722 }
1723 }
1724 out
1725 }
1726
1727 #[cfg_attr(
1728 not(feature = "full-tests"),
1729 ignore = "starts system git or is heavy; run with --features full-tests"
1730 )]
1731 #[test]
1732 fn stage_hunks_sequential_ids_stay_valid() {
1733 let (dir, repo) = init_repo();
1734 let root = dir.path();
1735 let (base, edited) = spaced_two_hunk_body();
1736 std::fs::write(root.join("f.txt"), base).unwrap();
1737 git(root, &["add", "f.txt"]);
1738 git(root, &["commit", "-qm", "init"]);
1739 std::fs::write(root.join("f.txt"), edited).unwrap();
1740
1741 let (files, peers) =
1742 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1743 let ids = hunk_ids_with_duplicate_peers(
1744 DiffSide::Unstaged,
1745 "f.txt",
1746 &files[0].hunks,
1747 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1748 );
1749 assert_eq!(ids.len(), 3, "expected three hunks, got {}", ids.len());
1750 let id0 = ids[0].clone();
1751 let id2 = ids[2].clone();
1752 stage_hunks(
1753 &repo,
1754 DiffSide::Unstaged,
1755 &HunkSelection {
1756 ids: vec![id0],
1757 ranges: Vec::new(),
1758 },
1759 &[],
1760 hunk_diff_opts(),
1761 false,
1762 )
1763 .unwrap();
1764 stage_hunks(
1765 &repo,
1766 DiffSide::Unstaged,
1767 &HunkSelection {
1768 ids: vec![id2],
1769 ranges: Vec::new(),
1770 },
1771 &[],
1772 hunk_diff_opts(),
1773 false,
1774 )
1775 .unwrap();
1776 git(root, &["fsck"]);
1777 }
1778
1779 #[cfg_attr(
1780 not(feature = "full-tests"),
1781 ignore = "starts system git or is heavy; run with --features full-tests"
1782 )]
1783 #[test]
1784 fn stage_hunks_inter_hunk_context_matches_diff_ids() {
1785 let (dir, repo) = init_repo();
1786 let root = dir.path();
1787 git(root, &["config", "diff.interHunkContext", "1"]);
1788 let (base, _edited) = three_hunk_body();
1789 let mut edited_two = base.clone();
1790 edited_two = edited_two.replacen("line5\n", "LINE5\n", 1);
1791 edited_two = edited_two.replacen("line13\n", "LINE13\n", 1);
1792 std::fs::write(root.join("f.txt"), base).unwrap();
1793 git(root, &["add", "f.txt"]);
1794 git(root, &["commit", "-qm", "init"]);
1795 std::fs::write(root.join("f.txt"), edited_two).unwrap();
1796
1797 let opts = DiffOptions::from_config(repo.config().unwrap().as_ref());
1798 let (files, peers) = unstaged_file_diffs_with_peer_hunks(&repo, &[], opts.clone()).unwrap();
1799 let ids = hunk_ids_with_duplicate_peers(
1800 DiffSide::Unstaged,
1801 "f.txt",
1802 &files[0].hunks,
1803 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1804 );
1805 assert_eq!(
1806 ids.len(),
1807 1,
1808 "interHunkContext=1 should merge edits at lines 5 and 13"
1809 );
1810 stage_hunks(
1811 &repo,
1812 DiffSide::Unstaged,
1813 &HunkSelection {
1814 ids: vec![ids[0].clone()],
1815 ranges: Vec::new(),
1816 },
1817 &[],
1818 opts,
1819 false,
1820 )
1821 .unwrap();
1822 git(root, &["fsck"]);
1823 }
1824
1825 #[cfg_attr(
1826 not(feature = "full-tests"),
1827 ignore = "starts system git or is heavy; run with --features full-tests"
1828 )]
1829 #[test]
1830 fn stage_hunks_text_to_binary_returns_binary_error() {
1831 let (dir, repo) = init_repo();
1832 let root = dir.path();
1833 let (base, edited) = spaced_two_hunk_body();
1834 std::fs::write(root.join("f.txt"), base).unwrap();
1835 git(root, &["add", "f.txt"]);
1836 git(root, &["commit", "-qm", "init"]);
1837 std::fs::write(root.join("f.txt"), edited).unwrap();
1838 let (files, peers) =
1839 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1840 let ids = hunk_ids_with_duplicate_peers(
1841 DiffSide::Unstaged,
1842 "f.txt",
1843 &files[0].hunks,
1844 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1845 );
1846 let id = ids[0].clone();
1847 let binary = b"before\0after".to_vec();
1848 std::fs::write(root.join("f.txt"), &binary).unwrap();
1849 let err = stage_hunks(
1850 &repo,
1851 DiffSide::Unstaged,
1852 &HunkSelection {
1853 ids: vec![id],
1854 ranges: Vec::new(),
1855 },
1856 &["f.txt".into()],
1857 hunk_diff_opts(),
1858 false,
1859 )
1860 .unwrap_err();
1861 assert!(
1862 matches!(err, HunkSelectError::Binary { .. }),
1863 "expected Binary, got {err:?}"
1864 );
1865 }
1866
1867 #[cfg_attr(
1868 not(feature = "full-tests"),
1869 ignore = "starts system git or is heavy; run with --features full-tests"
1870 )]
1871 #[test]
1872 fn stage_hunks_stale_id_rejected_index_untouched() {
1873 let (dir, repo) = init_repo();
1874 let root = dir.path();
1875 std::fs::write(root.join("f.txt"), b"one\n").unwrap();
1876 git(root, &["add", "f.txt"]);
1877 git(root, &["commit", "-qm", "init"]);
1878 std::fs::write(root.join("f.txt"), b"two\n").unwrap();
1879 let before = repo.index_handle().load().unwrap();
1880 let stale = HunkId::from_str("000000000001").unwrap();
1881 let err = stage_hunks(
1882 &repo,
1883 DiffSide::Unstaged,
1884 &HunkSelection {
1885 ids: vec![stale.clone()],
1886 ranges: Vec::new(),
1887 },
1888 &[],
1889 hunk_diff_opts(),
1890 false,
1891 )
1892 .unwrap_err();
1893 assert!(matches!(err, HunkSelectError::StaleId { .. }));
1894 let after = repo.index_handle().load().unwrap();
1895 assert_eq!(before.entries.len(), after.entries.len());
1896 }
1897
1898 #[cfg_attr(
1899 not(feature = "full-tests"),
1900 ignore = "starts system git or is heavy; run with --features full-tests"
1901 )]
1902 #[test]
1903 fn stage_hunks_staged_side_id_rejected() {
1904 let (dir, repo) = init_repo();
1905 let root = dir.path();
1906 std::fs::write(root.join("f.txt"), b"one\n").unwrap();
1907 git(root, &["add", "f.txt"]);
1908 git(root, &["commit", "-qm", "init"]);
1909 std::fs::write(root.join("f.txt"), b"two\n").unwrap();
1910 stage(
1911 &repo,
1912 &StageOptions::default(),
1913 &mut NullProgress,
1914 &crate::progress::NeverCancel,
1915 )
1916 .unwrap();
1917 let staged_files =
1918 collect_files_for_target(&repo, &DiffTarget::IndexVsHead, &[], DiffOptions::default())
1919 .unwrap();
1920 let staged_id = hunk_ids(DiffSide::Staged, "f.txt", &staged_files[0].hunks)[0].clone();
1921 std::fs::write(root.join("f.txt"), b"three\n").unwrap();
1922 let err = stage_hunks(
1923 &repo,
1924 DiffSide::Unstaged,
1925 &HunkSelection {
1926 ids: vec![staged_id],
1927 ranges: Vec::new(),
1928 },
1929 &[],
1930 hunk_diff_opts(),
1931 false,
1932 )
1933 .unwrap_err();
1934 assert!(matches!(
1935 err,
1936 HunkSelectError::WrongSide {
1937 actual_side: DiffSide::Staged,
1938 requested_side: DiffSide::Unstaged,
1939 ..
1940 }
1941 ));
1942 }
1943
1944 #[test]
1945 fn wrong_side_message_names_both_sides() {
1946 let err = HunkSelectError::WrongSide {
1947 id: HunkId::from_bytes([0xab; 6]),
1948 actual_side: DiffSide::Staged,
1949 requested_side: DiffSide::Unstaged,
1950 };
1951 assert_eq!(
1952 err.to_string(),
1953 "hunk id abababababab is for the staged side, not unstaged"
1954 );
1955 }
1956
1957 #[test]
1958 fn repository_error_keeps_typed_source() {
1959 let err = HunkSelectError::from(Error::InvalidHead);
1960 assert_eq!(err.to_string(), Error::InvalidHead.to_string());
1961 match err {
1962 HunkSelectError::Repository(inner) => assert!(matches!(*inner, Error::InvalidHead)),
1963 other => panic!("expected Repository, got {other:?}"),
1964 }
1965 }
1966
1967 #[test]
1968 fn other_side_is_unselectable() {
1969 let dir = tempfile::tempdir().unwrap();
1970 let repo = Repository::init(dir.path(), &InitOptions::default()).unwrap();
1971 let by_id = HunkSelection {
1972 ids: vec![HunkId::from_bytes([1; 6])],
1973 ranges: Vec::new(),
1974 };
1975 let by_range = HunkSelection {
1976 ids: Vec::new(),
1977 ranges: vec![LineRange {
1978 path: "f.txt".into(),
1979 start: 1,
1980 end: 1,
1981 }],
1982 };
1983 for selection in [by_id, by_range] {
1984 let err = resolve_selection(&repo, DiffSide::Other, &selection, &[], hunk_diff_opts())
1985 .unwrap_err();
1986 assert!(
1987 matches!(
1988 err,
1989 HunkSelectError::UnselectableSide {
1990 side: DiffSide::Other
1991 }
1992 ),
1993 "{err:?}"
1994 );
1995 }
1996 }
1997
1998 fn one_line_patch(path: &str) -> FilePatch {
1999 let text = format!(
2000 "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n-old\n+new\n"
2001 );
2002 parse_patch(&text, 1, path, false, None)
2003 .unwrap()
2004 .pop()
2005 .unwrap()
2006 }
2007
2008 #[test]
2009 fn restore_plan_requires_work_tree() {
2010 let dir = tempfile::tempdir().unwrap();
2011 let repo = Repository::init(
2012 dir.path(),
2013 &InitOptions {
2014 bare: true,
2015 ..InitOptions::default()
2016 },
2017 )
2018 .unwrap();
2019 let err = plan_worktree_reverse(&repo, &Index::new(), &[one_line_patch("f.txt")])
2020 .err()
2021 .unwrap();
2022 match err {
2023 HunkSelectError::Repository(inner) => {
2024 assert!(matches!(*inner, Error::RequiresWorkTree), "{inner:?}");
2025 }
2026 other => panic!("expected Repository(RequiresWorkTree), got {other:?}"),
2027 }
2028 }
2029
2030 #[test]
2031 fn restore_plan_rejects_gitlink_and_pathless_patches() {
2032 let dir = tempfile::tempdir().unwrap();
2033 let repo = Repository::init(dir.path(), &InitOptions::default()).unwrap();
2034
2035 let mut gitlink = one_line_patch("sub");
2036 gitlink.new_mode = Some("160000".into());
2037 let err = plan_worktree_reverse(&repo, &Index::new(), &[gitlink])
2038 .err()
2039 .unwrap();
2040 assert!(
2041 matches!(&err, HunkSelectError::Gitlink { path } if path == "sub"),
2042 "{err:?}"
2043 );
2044
2045 let mut pathless = one_line_patch("f.txt");
2046 pathless.old_path = None;
2047 pathless.new_path = None;
2048 let err = plan_worktree_reverse(&repo, &Index::new(), &[pathless])
2049 .err()
2050 .unwrap();
2051 assert!(matches!(err, HunkSelectError::MissingPath), "{err:?}");
2052 }
2053
2054 #[cfg_attr(
2055 not(feature = "full-tests"),
2056 ignore = "starts system git or is heavy; run with --features full-tests"
2057 )]
2058 #[test]
2059 fn stage_hunks_new_file_whole() {
2060 let (dir, repo) = init_repo();
2061 let root = dir.path();
2062 std::fs::write(root.join("base.txt"), b"keep\n").unwrap();
2063 git(root, &["add", "base.txt"]);
2064 git(root, &["commit", "-qm", "init"]);
2065 std::fs::write(root.join("new.txt"), b"hello\n").unwrap();
2066 stage(
2067 &repo,
2068 &StageOptions {
2069 pathspecs: vec!["new.txt".into()],
2070 intent_to_add: true,
2071 ..StageOptions::default()
2072 },
2073 &mut NullProgress,
2074 &crate::progress::NeverCancel,
2075 )
2076 .unwrap();
2077 let (files, peers) =
2078 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2079 let new_file = files
2080 .iter()
2081 .find(|f| f.path == "new.txt")
2082 .expect("new file in unstaged diff");
2083 assert!(
2084 !new_file.hunks.is_empty(),
2085 "new file should have structured hunks"
2086 );
2087 let ids = hunk_ids_with_duplicate_peers(
2088 DiffSide::Unstaged,
2089 "new.txt",
2090 &new_file.hunks,
2091 peers.get("new.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2092 );
2093 stage_hunks(
2094 &repo,
2095 DiffSide::Unstaged,
2096 &HunkSelection {
2097 ids: ids.clone(),
2098 ranges: Vec::new(),
2099 },
2100 &[],
2101 hunk_diff_opts(),
2102 false,
2103 )
2104 .unwrap();
2105 assert!(repo
2106 .index_handle()
2107 .load()
2108 .unwrap()
2109 .get(b"new.txt", 0)
2110 .is_some());
2111 git(root, &["fsck"]);
2112 }
2113
2114 fn stage_lines_and_compare_git(
2115 repo: &Repository,
2116 root: &std::path::Path,
2117 path: &str,
2118 ranges: Vec<LineRange>,
2119 ) {
2120 let selection = HunkSelection {
2121 ids: Vec::new(),
2122 ranges,
2123 };
2124 let patch = resolve_selection(
2125 &repo,
2126 DiffSide::Unstaged,
2127 &selection,
2128 &[path.into()],
2129 hunk_diff_opts(),
2130 )
2131 .unwrap();
2132 let mut patch_text = String::new();
2133 for p in &patch {
2134 patch_text.push_str(&format_patch_for_git(p));
2135 }
2136 let index_path = root.join(".git/index");
2137 let index_before = std::fs::read(&index_path).unwrap();
2138 stage_hunks(
2139 &repo,
2140 DiffSide::Unstaged,
2141 &selection,
2142 &[path.into()],
2143 hunk_diff_opts(),
2144 false,
2145 )
2146 .unwrap();
2147 let grit_tree =
2148 write_tree_from_index(repo.odb(), &repo.index_handle().load().unwrap(), "").unwrap();
2149 std::fs::write(&index_path, index_before).unwrap();
2150 let patch_path = root.join("partial.diff");
2151 std::fs::write(&patch_path, patch_text).unwrap();
2152 git(root, &["apply", "--cached", patch_path.to_str().unwrap()]);
2153 let git_tree = Command::new("git")
2154 .current_dir(root)
2155 .args(["write-tree"])
2156 .output()
2157 .expect("write-tree");
2158 let git_tree_hex = String::from_utf8_lossy(&git_tree.stdout).trim().to_string();
2159 assert_eq!(grit_tree.to_hex(), git_tree_hex);
2160 git(root, &["fsck"]);
2161 }
2162
2163 #[cfg_attr(
2164 not(feature = "full-tests"),
2165 ignore = "starts system git or is heavy; run with --features full-tests"
2166 )]
2167 #[test]
2168 fn stage_hunks_whole_file_deletion_removes_index_entry() {
2169 let (dir, repo) = init_repo();
2170 let root = dir.path();
2171 std::fs::write(root.join("gone.txt"), b"keep\n").unwrap();
2172 git(root, &["add", "gone.txt"]);
2173 git(root, &["commit", "-qm", "init"]);
2174 std::fs::remove_file(root.join("gone.txt")).unwrap();
2175 let ids = list_unstaged_hunk_ids(&repo, &[], hunk_diff_opts())
2176 .unwrap()
2177 .remove("gone.txt")
2178 .expect("deletion in unstaged diff");
2179 stage_hunks(
2180 &repo,
2181 DiffSide::Unstaged,
2182 &HunkSelection {
2183 ids,
2184 ranges: Vec::new(),
2185 },
2186 &[],
2187 hunk_diff_opts(),
2188 false,
2189 )
2190 .unwrap();
2191 assert!(
2192 repo.index_handle()
2193 .load()
2194 .unwrap()
2195 .get(b"gone.txt", 0)
2196 .is_none(),
2197 "index entry removed after staging delete hunk"
2198 );
2199 }
2200
2201 #[cfg_attr(
2202 not(feature = "full-tests"),
2203 ignore = "starts system git or is heavy; run with --features full-tests"
2204 )]
2205 #[test]
2206 fn directory_pathspec_keeps_staged_peer_hunk_ids() {
2207 let (dir, repo) = init_repo();
2208 let root = dir.path();
2209 std::fs::create_dir_all(root.join("pkg")).unwrap();
2210 let (base, edited) = spaced_two_hunk_body();
2211 std::fs::write(root.join("pkg/a.txt"), &base).unwrap();
2212 git(root, &["add", "pkg/a.txt"]);
2213 git(root, &["commit", "-qm", "init"]);
2214 std::fs::write(root.join("pkg/a.txt"), &edited).unwrap();
2215 let (files, peers) =
2216 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2217 let file = files.iter().find(|f| f.path == "pkg/a.txt").expect("a.txt");
2218 let ids = hunk_ids_with_duplicate_peers(
2219 DiffSide::Unstaged,
2220 "pkg/a.txt",
2221 &file.hunks,
2222 peers.get("pkg/a.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2223 );
2224 stage_hunks(
2225 &repo,
2226 DiffSide::Unstaged,
2227 &HunkSelection {
2228 ids: vec![ids[0].clone()],
2229 ranges: Vec::new(),
2230 },
2231 &[],
2232 hunk_diff_opts(),
2233 false,
2234 )
2235 .unwrap();
2236 let file_pathspec = vec!["pkg/a.txt".to_string()];
2237 let dir_pathspec = vec!["pkg/".to_string()];
2238 let opts = hunk_diff_opts();
2239 let by_file = list_unstaged_hunk_ids(&repo, &file_pathspec, opts.clone()).unwrap();
2240 let by_dir = list_unstaged_hunk_ids(&repo, &dir_pathspec, opts).unwrap();
2241 assert_eq!(
2242 by_file.get("pkg/a.txt"),
2243 by_dir.get("pkg/a.txt"),
2244 "directory pathspec must include staged peer hunks when assigning ids"
2245 );
2246 }
2247
2248 #[cfg_attr(
2249 not(feature = "full-tests"),
2250 ignore = "starts system git or is heavy; run with --features full-tests"
2251 )]
2252 #[test]
2253 fn lines_selects_part_of_hunk() {
2254 let (dir, repo) = init_repo();
2255 let root = dir.path();
2256 std::fs::write(root.join("f.txt"), b"a\nb\nc\nd\n").unwrap();
2257 git(root, &["add", "f.txt"]);
2258 git(root, &["commit", "-qm", "init"]);
2259 std::fs::write(root.join("f.txt"), b"a\nB\nc\nD\n").unwrap();
2260 stage_lines_and_compare_git(
2261 &repo,
2262 root,
2263 "f.txt",
2264 vec![LineRange {
2265 path: "f.txt".into(),
2266 start: 2,
2267 end: 2,
2268 }],
2269 );
2270 }
2271
2272 #[cfg_attr(
2273 not(feature = "full-tests"),
2274 ignore = "starts system git or is heavy; run with --features full-tests"
2275 )]
2276 #[test]
2277 fn lines_unselected_deletion_becomes_context() {
2278 let (dir, repo) = init_repo();
2279 let root = dir.path();
2280 std::fs::write(root.join("f.txt"), b"keep\nremove\nstay\n").unwrap();
2281 git(root, &["add", "f.txt"]);
2282 git(root, &["commit", "-qm", "init"]);
2283 std::fs::write(root.join("f.txt"), b"keep\nstay\n").unwrap();
2284 stage_lines_and_compare_git(
2285 &repo,
2286 root,
2287 "f.txt",
2288 vec![LineRange {
2289 path: "f.txt".into(),
2290 start: 1,
2291 end: 1,
2292 }],
2293 );
2294 }
2295
2296 #[cfg_attr(
2297 not(feature = "full-tests"),
2298 ignore = "starts system git or is heavy; run with --features full-tests"
2299 )]
2300 #[test]
2301 fn lines_selected_deletion_at_range_edge() {
2302 let (dir, repo) = init_repo();
2303 let root = dir.path();
2304 std::fs::write(root.join("f.txt"), b"a\nb\nc\n").unwrap();
2305 git(root, &["add", "f.txt"]);
2306 git(root, &["commit", "-qm", "init"]);
2307 std::fs::write(root.join("f.txt"), b"a\nc\n").unwrap();
2308 stage_lines_and_compare_git(
2309 &repo,
2310 root,
2311 "f.txt",
2312 vec![LineRange {
2313 path: "f.txt".into(),
2314 start: 1,
2315 end: 1,
2316 }],
2317 );
2318 }
2319
2320 #[cfg_attr(
2321 not(feature = "full-tests"),
2322 ignore = "starts system git or is heavy; run with --features full-tests"
2323 )]
2324 #[test]
2325 fn lines_range_spanning_two_hunks() {
2326 let (dir, repo) = init_repo();
2327 let root = dir.path();
2328 let (base, edited) = three_hunk_body();
2329 std::fs::write(root.join("f.txt"), base).unwrap();
2330 git(root, &["add", "f.txt"]);
2331 git(root, &["commit", "-qm", "init"]);
2332 std::fs::write(root.join("f.txt"), edited).unwrap();
2333 stage_lines_and_compare_git(
2334 &repo,
2335 root,
2336 "f.txt",
2337 vec![LineRange {
2338 path: "f.txt".into(),
2339 start: 5,
2340 end: 14,
2341 }],
2342 );
2343 }
2344
2345 #[cfg_attr(
2346 not(feature = "full-tests"),
2347 ignore = "starts system git or is heavy; run with --features full-tests"
2348 )]
2349 #[test]
2350 fn glob_pathspec_includes_file_in_hunk_bundle() {
2351 let (dir, repo) = init_repo();
2352 let root = dir.path();
2353 std::fs::write(root.join("f.txt"), b"a\nb\n").unwrap();
2354 git(root, &["add", "f.txt"]);
2355 git(root, &["commit", "-qm", "init"]);
2356 std::fs::write(root.join("f.txt"), b"a\nB\n").unwrap();
2357 let bundle = hunk_diff_bundle(&repo, &[":(glob)*.txt".into()], DiffOptions::default())
2358 .expect("bundle");
2359 assert!(
2360 bundle.unstaged.iter().any(|f| f.path == "f.txt"),
2361 "expected f.txt in bundle, got {:?}",
2362 bundle
2363 .unstaged
2364 .iter()
2365 .map(|f| f.path.as_str())
2366 .collect::<Vec<_>>()
2367 );
2368 }
2369
2370 #[cfg_attr(
2371 not(feature = "full-tests"),
2372 ignore = "starts system git or is heavy; run with --features full-tests"
2373 )]
2374 #[test]
2375 fn lines_pathspec_glob_stages() {
2376 let (dir, repo) = init_repo();
2377 let root = dir.path();
2378 std::fs::write(root.join("f.txt"), b"a\nb\n").unwrap();
2379 git(root, &["add", "f.txt"]);
2380 git(root, &["commit", "-qm", "init"]);
2381 std::fs::write(root.join("f.txt"), b"a\nB\n").unwrap();
2382 let selection = HunkSelection {
2383 ids: Vec::new(),
2384 ranges: vec![LineRange {
2385 path: "f.txt".into(),
2386 start: 2,
2387 end: 2,
2388 }],
2389 };
2390 stage_hunks(
2391 &repo,
2392 DiffSide::Unstaged,
2393 &selection,
2394 &[":(glob)*.txt".into()],
2395 hunk_diff_opts(),
2396 false,
2397 )
2398 .unwrap();
2399 git(root, &["fsck"]);
2400 }
2401
2402 #[cfg_attr(
2403 not(feature = "full-tests"),
2404 ignore = "starts system git or is heavy; run with --features full-tests"
2405 )]
2406 #[test]
2407 fn lines_mix_hunk_id_overlap_stages_via_id() {
2408 let (dir, repo) = init_repo();
2409 let root = dir.path();
2410 std::fs::write(root.join("f.txt"), b"one\n").unwrap();
2411 git(root, &["add", "f.txt"]);
2412 git(root, &["commit", "-qm", "init"]);
2413 std::fs::write(root.join("f.txt"), b"two\n").unwrap();
2414 let (files, peers) =
2415 unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2416 let ids = hunk_ids_with_duplicate_peers(
2417 DiffSide::Unstaged,
2418 "f.txt",
2419 &files[0].hunks,
2420 peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2421 );
2422 let selection = HunkSelection {
2423 ids: vec![ids[0].clone()],
2424 ranges: vec![LineRange {
2425 path: "f.txt".into(),
2426 start: 1,
2427 end: 1,
2428 }],
2429 };
2430 stage_hunks(
2431 &repo,
2432 DiffSide::Unstaged,
2433 &selection,
2434 &["f.txt".into()],
2435 hunk_diff_opts(),
2436 false,
2437 )
2438 .unwrap();
2439 git(root, &["fsck"]);
2440 }
2441
2442 #[cfg_attr(
2443 not(feature = "full-tests"),
2444 ignore = "starts system git or is heavy; run with --features full-tests"
2445 )]
2446 #[test]
2447 fn lines_unchanged_line_beyond_hunk_is_selects_nothing() {
2448 let (dir, repo) = init_repo();
2449 let root = dir.path();
2450 let mut base = String::new();
2451 for i in 1..=100 {
2452 base.push_str(&format!("line{i}\n"));
2453 }
2454 std::fs::write(root.join("f.txt"), &base).unwrap();
2455 git(root, &["add", "f.txt"]);
2456 git(root, &["commit", "-qm", "init"]);
2457 let mut edited = base.clone();
2458 edited = edited.replacen("line1\n", "LINE1\n", 1);
2459 std::fs::write(root.join("f.txt"), edited).unwrap();
2460 let err = stage_hunks(
2461 &repo,
2462 DiffSide::Unstaged,
2463 &HunkSelection {
2464 ids: Vec::new(),
2465 ranges: vec![LineRange {
2466 path: "f.txt".into(),
2467 start: 100,
2468 end: 100,
2469 }],
2470 },
2471 &[],
2472 hunk_diff_opts(),
2473 false,
2474 )
2475 .unwrap_err();
2476 assert!(
2477 matches!(err, HunkSelectError::RangeSelectsNothing { .. }),
2478 "expected RangeSelectsNothing, got {err:?}"
2479 );
2480 }
2481
2482 #[cfg_attr(
2483 not(feature = "full-tests"),
2484 ignore = "starts system git or is heavy; run with --features full-tests"
2485 )]
2486 #[test]
2487 fn lines_out_of_bounds_error() {
2488 let (dir, repo) = init_repo();
2489 let root = dir.path();
2490 std::fs::write(root.join("f.txt"), b"one\n").unwrap();
2491 git(root, &["add", "f.txt"]);
2492 git(root, &["commit", "-qm", "init"]);
2493 std::fs::write(root.join("f.txt"), b"two\n").unwrap();
2494 let err = stage_hunks(
2495 &repo,
2496 DiffSide::Unstaged,
2497 &HunkSelection {
2498 ids: Vec::new(),
2499 ranges: vec![LineRange {
2500 path: "f.txt".into(),
2501 start: 1,
2502 end: 99,
2503 }],
2504 },
2505 &[],
2506 hunk_diff_opts(),
2507 false,
2508 )
2509 .unwrap_err();
2510 assert!(matches!(err, HunkSelectError::RangeOutOfBounds { .. }));
2511 }
2512
2513 #[cfg(unix)]
2514 #[cfg_attr(
2515 not(feature = "full-tests"),
2516 ignore = "starts system git or is heavy; run with --features full-tests"
2517 )]
2518 #[test]
2519 fn lines_symlink_dangling_target_matches_git_apply_cached() {
2520 use std::os::unix::fs::symlink;
2521
2522 let (dir, repo) = init_repo();
2523 let root = dir.path();
2524 symlink("old-target", root.join("link")).expect("symlink");
2525 git(root, &["add", "link"]);
2526 git(root, &["commit", "-qm", "init"]);
2527 std::fs::remove_file(root.join("link")).expect("remove link");
2528 symlink("new-target", root.join("link")).expect("retarget");
2529 stage_lines_and_compare_git(
2530 &repo,
2531 root,
2532 "link",
2533 vec![LineRange {
2534 path: "link".into(),
2535 start: 1,
2536 end: 1,
2537 }],
2538 );
2539 }
2540
2541 fn numbered_lines(n: usize) -> String {
2542 (1..=n).map(|i| format!("line{i}\n")).collect()
2543 }
2544
2545 fn edit_every_tenth_line(base: &str) -> String {
2546 base.lines()
2547 .enumerate()
2548 .map(|(i, l)| {
2549 if i % 10 == 0 {
2550 format!("{}\n", l.to_uppercase())
2551 } else {
2552 format!("{l}\n")
2553 }
2554 })
2555 .collect()
2556 }
2557
2558 #[test]
2559 fn stage_lines_dry_run_writes_nothing_and_stages_only_the_range() {
2560 let dir = tempfile::tempdir().unwrap();
2561 let root = dir.path();
2562 let repo = Repository::init(root, &InitOptions::default()).unwrap();
2563 let base = numbered_lines(40);
2564 std::fs::write(root.join("f.txt"), &base).unwrap();
2565 std::fs::write(root.join("other.txt"), b"other\n").unwrap();
2566 stage(
2567 &repo,
2568 &StageOptions::default(),
2569 &mut NullProgress,
2570 &crate::progress::NeverCancel,
2571 )
2572 .unwrap();
2573 std::fs::write(root.join("f.txt"), edit_every_tenth_line(&base)).unwrap();
2574 std::fs::write(root.join("other.txt"), b"changed\n").unwrap();
2575 let selection = HunkSelection {
2576 ids: Vec::new(),
2577 ranges: vec![LineRange {
2578 path: "f.txt".into(),
2579 start: 1,
2580 end: 15,
2581 }],
2582 };
2583 let index_path = root.join(".git/index");
2584 let before = std::fs::read(&index_path).unwrap();
2585 let outcome = stage_hunks(
2586 &repo,
2587 DiffSide::Unstaged,
2588 &selection,
2589 &["f.txt".into()],
2590 hunk_diff_opts(),
2591 true,
2592 )
2593 .unwrap();
2594 assert!(outcome.dry_run);
2595 assert_eq!(outcome.modified, ["f.txt"]);
2596 assert_eq!(std::fs::read(&index_path).unwrap(), before);
2597 assert!(!root.join(".git/index.lock").exists());
2598
2599 let outcome = stage_hunks(
2600 &repo,
2601 DiffSide::Unstaged,
2602 &selection,
2603 &["f.txt".into()],
2604 hunk_diff_opts(),
2605 false,
2606 )
2607 .unwrap();
2608 assert_eq!(outcome.staged_lines["f.txt"], ["1-15"]);
2609 assert!(!root.join(".git/index.lock").exists());
2610 let index = repo.index_handle().load().unwrap();
2611 let staged = repo
2612 .odb()
2613 .read(&index.get(b"f.txt", 0).unwrap().oid)
2614 .unwrap()
2615 .data;
2616 let expected: String = base
2617 .lines()
2618 .enumerate()
2619 .map(|(i, l)| {
2620 if i < 15 && i % 10 == 0 {
2621 format!("{}\n", l.to_uppercase())
2622 } else {
2623 format!("{l}\n")
2624 }
2625 })
2626 .collect();
2627 assert_eq!(String::from_utf8(staged).unwrap(), expected);
2628 let other = repo
2629 .odb()
2630 .read(&index.get(b"other.txt", 0).unwrap().oid)
2631 .unwrap()
2632 .data;
2633 assert_eq!(other, b"other\n", "unselected paths stay unstaged");
2634 }
2635
2636 #[cfg_attr(
2637 not(feature = "full-tests"),
2638 ignore = "starts system git or is heavy; run with --features full-tests"
2639 )]
2640 #[test]
2641 fn lines_with_literal_pathspec_among_other_dirty_files_match_git() {
2642 let (dir, repo) = init_repo();
2643 let root = dir.path();
2644 let base = numbered_lines(300);
2645 std::fs::write(root.join("big.txt"), &base).unwrap();
2646 std::fs::create_dir_all(root.join("sub")).unwrap();
2647 for i in 0..20 {
2648 std::fs::write(root.join(format!("sub/f{i}.txt")), format!("{i}\n")).unwrap();
2649 }
2650 git(root, &["add", "."]);
2651 git(root, &["commit", "-qm", "init"]);
2652 std::fs::write(root.join("big.txt"), edit_every_tenth_line(&base)).unwrap();
2653 std::fs::write(root.join("sub/f3.txt"), b"dirty\n").unwrap();
2654 stage_lines_and_compare_git(
2655 &repo,
2656 root,
2657 "big.txt",
2658 vec![
2659 LineRange {
2660 path: "big.txt".into(),
2661 start: 1,
2662 end: 105,
2663 },
2664 LineRange {
2665 path: "big.txt".into(),
2666 start: 251,
2667 end: 251,
2668 },
2669 ],
2670 );
2671 let out = Command::new("git")
2672 .current_dir(root)
2673 .args(["diff", "--name-only"])
2674 .output()
2675 .expect("git diff");
2676 let unstaged = String::from_utf8_lossy(&out.stdout);
2677 assert!(unstaged.lines().any(|l| l == "sub/f3.txt"), "{unstaged}");
2678 assert!(unstaged.lines().any(|l| l == "big.txt"), "{unstaged}");
2679 }
2680
2681 #[cfg_attr(
2682 not(feature = "full-tests"),
2683 ignore = "starts system git or is heavy; run with --features full-tests"
2684 )]
2685 #[test]
2686 fn lines_on_top_of_staged_change_match_git() {
2687 let (dir, repo) = init_repo();
2688 let root = dir.path();
2689 let base = numbered_lines(100);
2690 std::fs::write(root.join("f.txt"), &base).unwrap();
2691 git(root, &["add", "f.txt"]);
2692 git(root, &["commit", "-qm", "init"]);
2693 let edited = edit_every_tenth_line(&base);
2694 std::fs::write(root.join("f.txt"), &edited).unwrap();
2695 stage_hunks(
2696 &repo,
2697 DiffSide::Unstaged,
2698 &HunkSelection {
2699 ids: Vec::new(),
2700 ranges: vec![LineRange {
2701 path: "f.txt".into(),
2702 start: 1,
2703 end: 30,
2704 }],
2705 },
2706 &["f.txt".into()],
2707 hunk_diff_opts(),
2708 false,
2709 )
2710 .unwrap();
2711 git(root, &["commit", "-qm", "partial"]);
2712 stage_hunks(
2713 &repo,
2714 DiffSide::Unstaged,
2715 &HunkSelection {
2716 ids: Vec::new(),
2717 ranges: vec![LineRange {
2718 path: "f.txt".into(),
2719 start: 31,
2720 end: 60,
2721 }],
2722 },
2723 &["f.txt".into()],
2724 hunk_diff_opts(),
2725 false,
2726 )
2727 .unwrap();
2728 stage_lines_and_compare_git(
2729 &repo,
2730 root,
2731 "f.txt",
2732 vec![LineRange {
2733 path: "f.txt".into(),
2734 start: 61,
2735 end: 80,
2736 }],
2737 );
2738 }
2739
2740 #[cfg_attr(
2741 not(feature = "full-tests"),
2742 ignore = "starts system git or is heavy; run with --features full-tests"
2743 )]
2744 #[test]
2745 fn lines_without_pathspec_match_git() {
2746 let (dir, repo) = init_repo();
2747 let root = dir.path();
2748 let (base, edited) = three_hunk_body();
2749 std::fs::write(root.join("f.txt"), base).unwrap();
2750 std::fs::write(root.join("g.txt"), b"g\n").unwrap();
2751 git(root, &["add", "."]);
2752 git(root, &["commit", "-qm", "init"]);
2753 std::fs::write(root.join("f.txt"), &edited).unwrap();
2754 std::fs::write(root.join("g.txt"), b"G\n").unwrap();
2755 let selection = HunkSelection {
2756 ids: Vec::new(),
2757 ranges: vec![LineRange {
2758 path: "f.txt".into(),
2759 start: 10,
2760 end: 30,
2761 }],
2762 };
2763 let patch = resolve_selection(&repo, DiffSide::Unstaged, &selection, &[], hunk_diff_opts())
2764 .unwrap();
2765 let patch_text: String = patch.iter().map(format_patch_for_git).collect();
2766 stage_hunks(
2767 &repo,
2768 DiffSide::Unstaged,
2769 &selection,
2770 &[],
2771 hunk_diff_opts(),
2772 false,
2773 )
2774 .unwrap();
2775 let grit_tree =
2776 write_tree_from_index(repo.odb(), &repo.index_handle().load().unwrap(), "").unwrap();
2777 git(root, &["read-tree", "HEAD"]);
2778 let patch_path = root.join("partial.diff");
2779 std::fs::write(&patch_path, patch_text).unwrap();
2780 git(root, &["apply", "--cached", patch_path.to_str().unwrap()]);
2781 let git_tree = Command::new("git")
2782 .current_dir(root)
2783 .args(["write-tree"])
2784 .output()
2785 .expect("write-tree");
2786 assert_eq!(
2787 grit_tree.to_hex(),
2788 String::from_utf8_lossy(&git_tree.stdout).trim()
2789 );
2790 git(root, &["fsck"]);
2791 }
2792
2793 #[cfg_attr(
2794 not(feature = "full-tests"),
2795 ignore = "starts system git or is heavy; run with --features full-tests"
2796 )]
2797 #[test]
2798 fn lines_no_eof_newline() {
2799 let (dir, repo) = init_repo();
2800 let root = dir.path();
2801 std::fs::write(root.join("f.txt"), b"old").unwrap();
2802 git(root, &["add", "f.txt"]);
2803 git(root, &["commit", "-qm", "init"]);
2804 std::fs::write(root.join("f.txt"), b"new").unwrap();
2805 stage_lines_and_compare_git(
2806 &repo,
2807 root,
2808 "f.txt",
2809 vec![LineRange {
2810 path: "f.txt".into(),
2811 start: 1,
2812 end: 1,
2813 }],
2814 );
2815 }
2816}