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