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