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