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