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