1use std::{
2 borrow::Cow,
3 collections::{BTreeMap, HashSet},
4 path::Path,
5};
6
7use crate::{
8 compose_types::{ComposeExecutableGroup, ComposeFile, ComposeHunk, ComposeSnapshot, WorktreePin},
9 error::{CommitGenError, Result},
10 git::{git_command, git_command_with_index},
11};
12
13#[derive(Debug, Clone)]
14struct ParsedHunk {
15 old_start: usize,
16 old_count: usize,
17 new_start: usize,
18 new_count: usize,
19 header: String,
20 lines: Vec<String>,
21}
22
23#[derive(Debug, Clone)]
24struct ParsedFile {
25 path: String,
26 header_lines: Vec<String>,
27 hunks: Vec<ParsedHunk>,
28 additions: usize,
29 deletions: usize,
30 is_binary: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ComposeGroupPatch {
35 pub diff: String,
36 pub stat: String,
37 apply_patches: Vec<FilePatch>,
38 fallback_files: Vec<String>,
39 index_blobs: Vec<IndexBlob>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct FilePatch {
44 path: String,
45 patch: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49struct IndexBlob {
50 path: String,
51 mode: String,
52 object: IndexObject,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56enum IndexObject {
57 BlobContents(String),
58 BlobBytes(Vec<u8>),
59 ExistingObject(String),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum StageResult {
64 Staged,
65 AlreadyApplied,
66 EmptyPatch,
67}
68
69impl StageResult {
70 const fn combine(self, other: Self) -> Self {
71 match (self, other) {
72 (Self::Staged, _) | (_, Self::Staged) => Self::Staged,
73 (Self::AlreadyApplied, _) | (_, Self::AlreadyApplied) => Self::AlreadyApplied,
74 (Self::EmptyPatch, Self::EmptyPatch) => Self::EmptyPatch,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81enum FilePatchOutcome {
82 Staged,
83 AlreadyApplied,
84 Empty,
85 Failed(String),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct SkippedFile {
93 pub path: String,
94 pub reason: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ComposeStageOutcome {
101 pub result: StageResult,
102 pub skipped: Vec<SkippedFile>,
103}
104
105fn git_command_for_index(index_file: Option<&Path>) -> std::process::Command {
107 if let Some(index_file) = index_file {
108 git_command_with_index(index_file)
109 } else {
110 git_command()
111 }
112}
113
114fn run_git_apply(
115 patch: &str,
116 args: &[&str],
117 dir: &str,
118 index_file: Option<&Path>,
119) -> Result<std::process::Output> {
120 let mut child = git_command_for_index(index_file)
121 .args(args)
122 .current_dir(dir)
123 .stdin(std::process::Stdio::piped())
124 .stdout(std::process::Stdio::piped())
125 .stderr(std::process::Stdio::piped())
126 .spawn()
127 .map_err(|e| CommitGenError::git(format!("Failed to spawn git apply: {e}")))?;
128
129 if let Some(mut stdin) = child.stdin.take() {
130 use std::io::Write;
131
132 stdin
133 .write_all(patch.as_bytes())
134 .map_err(|e| CommitGenError::git(format!("Failed to write patch: {e}")))?;
135 }
136
137 child
138 .wait_with_output()
139 .map_err(|e| CommitGenError::git(format!("Failed to wait for git apply: {e}")))
140}
141
142fn patch_is_already_applied_to_index(
143 patch: &str,
144 dir: &str,
145 index_file: Option<&Path>,
146) -> Result<bool> {
147 let output = run_git_apply(
148 patch,
149 &["apply", "--cached", "--reverse", "--check", "--recount"],
150 dir,
151 index_file,
152 )?;
153 Ok(output.status.success())
154}
155
156fn apply_file_patch_to_index(
162 patch: &str,
163 dir: &str,
164 index_file: Option<&Path>,
165) -> Result<FilePatchOutcome> {
166 if patch.trim().is_empty() {
167 return Ok(FilePatchOutcome::Empty);
168 }
169
170 if patch_is_already_applied_to_index(patch, dir, index_file)? {
171 return Ok(FilePatchOutcome::AlreadyApplied);
172 }
173
174 let output =
175 run_git_apply(patch, &["apply", "--cached", "--3way", "--recount"], dir, index_file)?;
176 if output.status.success() {
177 return Ok(FilePatchOutcome::Staged);
178 }
179
180 Ok(FilePatchOutcome::Failed(String::from_utf8_lossy(&output.stderr).trim().to_string()))
181}
182
183fn restore_index_path_to_head(path: &str, dir: &str, index_file: Option<&Path>) -> Result<()> {
188 let output = git_command_for_index(index_file)
189 .args(["reset", "-q", "HEAD", "--"])
190 .arg(path)
191 .current_dir(dir)
192 .output()
193 .map_err(|e| CommitGenError::git(format!("Failed to reset index entry {path}: {e}")))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr);
197 return Err(CommitGenError::git(format!("git reset failed for {path}: {stderr}")));
198 }
199
200 Ok(())
201}
202
203fn resolve_blob_oid(oid: &str, path: &str, dir: &str) -> Result<String> {
205 let output = git_command()
206 .args(["rev-parse", "--verify", "--quiet"])
207 .arg(format!("{oid}^{{blob}}"))
208 .current_dir(dir)
209 .output()
210 .map_err(|e| CommitGenError::git(format!("Failed to resolve base blob for {path}: {e}")))?;
211
212 let full = String::from_utf8_lossy(&output.stdout).trim().to_string();
213 if !output.status.success() || full.is_empty() {
214 return Err(CommitGenError::git(format!(
215 "Cannot resolve base blob {oid} for {path}: object not found"
216 )));
217 }
218
219 Ok(full)
220}
221
222fn cat_file_blob(oid: &str, path: &str, dir: &str) -> Result<Vec<u8>> {
224 let output = git_command()
225 .args(["cat-file", "blob", oid])
226 .current_dir(dir)
227 .output()
228 .map_err(|e| CommitGenError::git(format!("Failed to read base blob for {path}: {e}")))?;
229
230 if !output.status.success() {
231 let stderr = String::from_utf8_lossy(&output.stderr);
232 return Err(CommitGenError::git(format!("git cat-file blob failed for {path}: {stderr}")));
233 }
234
235 Ok(output.stdout)
236}
237
238fn resolve_base_blob(file: &ComposeFile, dir: &str) -> Result<(Vec<u8>, String)> {
242 let index_line = file
243 .patch_header
244 .lines()
245 .find(|line| line.starts_with("index "));
246
247 let base_oid = index_line.and_then(|line| {
248 let rest = line.strip_prefix("index ")?;
249 let range = rest.split_whitespace().next()?;
250 range.split_once("..").map(|(base, _)| base)
251 });
252
253 match base_oid {
254 Some(oid) if !oid.is_empty() && oid.bytes().any(|byte| byte != b'0') => {
255 let full = resolve_blob_oid(oid, &file.path, dir)?;
256 let bytes = cat_file_blob(&full, &file.path, dir)?;
257 let mode = index_line
258 .and_then(|line| line.strip_prefix("index "))
259 .and_then(|rest| rest.split_whitespace().nth(1))
260 .map(str::to_string)
261 .or_else(|| {
262 file.patch_header.lines().find_map(|line| {
263 line
264 .strip_prefix("old mode ")
265 .map(|mode| mode.trim().to_string())
266 })
267 })
268 .unwrap_or_else(|| "100644".to_string());
269 Ok((bytes, mode))
270 },
271 _ => {
272 let mode = new_file_mode(file).unwrap_or("100644").to_string();
273 Ok((Vec::new(), mode))
274 },
275 }
276}
277
278fn split_lines_keep_eol(data: &[u8]) -> Vec<&[u8]> {
281 let mut lines = Vec::new();
282 let mut start = 0usize;
283 while start < data.len() {
284 if let Some(rel) = data[start..].iter().position(|&byte| byte == b'\n') {
285 lines.push(&data[start..=start + rel]);
286 start += rel + 1;
287 } else {
288 lines.push(&data[start..]);
289 break;
290 }
291 }
292 lines
293}
294
295fn dominant_eol(lines: &[&[u8]]) -> &'static [u8] {
298 let mut crlf = 0usize;
299 let mut lf = 0usize;
300 for line in lines {
301 if line.ends_with(b"\r\n") {
302 crlf += 1;
303 } else if line.ends_with(b"\n") {
304 lf += 1;
305 }
306 }
307 if crlf > 0 && crlf >= lf {
308 b"\r\n"
309 } else {
310 b"\n"
311 }
312}
313
314fn strip_trailing_eol(buf: &mut Vec<u8>) {
316 if buf.last() == Some(&b'\n') {
317 buf.pop();
318 if buf.last() == Some(&b'\r') {
319 buf.pop();
320 }
321 }
322}
323
324fn splice_hunks_into_base(base: &[u8], hunks: &[&ComposeHunk]) -> Vec<u8> {
330 let base_lines = split_lines_keep_eol(base);
331 let eol = dominant_eol(&base_lines);
332
333 let mut ordered: Vec<&&ComposeHunk> = hunks.iter().collect();
334 ordered.sort_by_key(|hunk| hunk.old_start);
335
336 let mut out: Vec<u8> = Vec::with_capacity(base.len());
337 let mut cursor = 0usize; for hunk in ordered {
340 let start = hunk.old_start.saturating_sub(1);
341 while cursor < start && cursor < base_lines.len() {
342 out.extend_from_slice(base_lines[cursor]);
343 cursor += 1;
344 }
345
346 let mut prev: u8 = 0;
347 for (idx, line) in diff_lines_preserve_cr(&hunk.raw_patch).enumerate() {
348 if idx == 0 {
349 continue;
351 }
352 let bytes = line.as_bytes();
353 if bytes.first() == Some(&b'\\') {
354 if prev == b'+' || prev == b' ' {
358 strip_trailing_eol(&mut out);
359 }
360 continue;
361 }
362 match bytes.first() {
363 Some(b'-') => {
364 cursor += 1;
365 prev = b'-';
366 },
367 Some(b'+') => {
368 let mut content = &bytes[1..];
369 if content.last() == Some(&b'\r') {
370 content = &content[..content.len() - 1];
371 }
372 out.extend_from_slice(content);
373 out.extend_from_slice(eol);
374 prev = b'+';
375 },
376 _ => {
377 if cursor < base_lines.len() {
379 out.extend_from_slice(base_lines[cursor]);
380 cursor += 1;
381 }
382 prev = b' ';
383 },
384 }
385 }
386 }
387
388 while cursor < base_lines.len() {
389 out.extend_from_slice(base_lines[cursor]);
390 cursor += 1;
391 }
392
393 out
394}
395
396#[tracing::instrument(target = "lgit", name = "patch.force_stage_file_from_base", skip_all, fields(dir, file_id, hunk_count = selected_hunk_ids.len()))]
405pub fn force_stage_file_from_base(
406 snapshot: &ComposeSnapshot,
407 file_id: &str,
408 selected_hunk_ids: &[String],
409 dir: &str,
410) -> Result<()> {
411 force_stage_file_from_base_with_index(snapshot, file_id, selected_hunk_ids, dir, None)
412}
413
414#[tracing::instrument(target = "lgit", name = "patch.force_stage_file_from_base_in_index", skip_all, fields(dir, file_id, hunk_count = selected_hunk_ids.len(), index = %index_file.display()))]
415pub fn force_stage_file_from_base_in_index(
416 snapshot: &ComposeSnapshot,
417 file_id: &str,
418 selected_hunk_ids: &[String],
419 dir: &str,
420 index_file: &Path,
421) -> Result<()> {
422 force_stage_file_from_base_with_index(
423 snapshot,
424 file_id,
425 selected_hunk_ids,
426 dir,
427 Some(index_file),
428 )
429}
430
431fn force_stage_file_from_base_with_index(
432 snapshot: &ComposeSnapshot,
433 file_id: &str,
434 selected_hunk_ids: &[String],
435 dir: &str,
436 index_file: Option<&Path>,
437) -> Result<()> {
438 let file = snapshot
439 .file_by_id(file_id)
440 .ok_or_else(|| CommitGenError::Other(format!("Unknown compose file id {file_id}")))?;
441
442 let ordered: Vec<&ComposeHunk> = file
443 .hunk_ids
444 .iter()
445 .filter(|hunk_id| {
446 selected_hunk_ids
447 .iter()
448 .any(|selected| selected == *hunk_id)
449 })
450 .filter_map(|hunk_id| snapshot.hunk_by_id(hunk_id))
451 .filter(|hunk| !hunk.raw_patch.is_empty())
452 .collect();
453
454 if ordered.is_empty() {
455 return Ok(());
456 }
457
458 restore_index_path_to_head(&file.path, dir, index_file)?;
462 let (base_bytes, mode) = resolve_base_blob(file, dir)?;
463 let target = splice_hunks_into_base(&base_bytes, &ordered);
464 let blob = IndexBlob { path: file.path.clone(), mode, object: IndexObject::BlobBytes(target) };
465 stage_index_blob(&blob, dir, index_file)?;
466
467 Ok(())
468}
469
470#[tracing::instrument(target = "lgit", name = "patch.pin_worktree_state", skip_all, fields(dir, file_count = snapshot.files.len()))]
476pub fn pin_snapshot_worktree_state(snapshot: &mut ComposeSnapshot, dir: &str) -> Result<()> {
477 let mut regular_paths: Vec<String> = Vec::new();
478
479 for file in &snapshot.files {
480 let full_path = Path::new(dir).join(&file.path);
481 let metadata = match std::fs::symlink_metadata(&full_path) {
482 Ok(metadata) => metadata,
483 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
484 snapshot.pins.insert(file.path.clone(), WorktreePin::Deleted);
485 continue;
486 },
487 Err(err) => {
488 return Err(CommitGenError::git(format!(
489 "Failed to inspect worktree path {}: {err}",
490 file.path
491 )));
492 },
493 };
494
495 let file_type = metadata.file_type();
496 if file_type.is_symlink() {
497 let target = std::fs::read_link(&full_path).map_err(|err| {
498 CommitGenError::git(format!("Failed to read symlink {}: {err}", file.path))
499 })?;
500 let oid = hash_blob_bytes(target.as_os_str().as_encoded_bytes(), &file.path, dir)?;
501 snapshot
502 .pins
503 .insert(file.path.clone(), WorktreePin::Object { mode: "120000".to_string(), oid });
504 } else if file_type.is_dir() {
505 if let Some(oid) = submodule_head(&full_path) {
509 snapshot
510 .pins
511 .insert(file.path.clone(), WorktreePin::Object { mode: "160000".to_string(), oid });
512 }
513 } else if !file.path.contains('\n') {
514 regular_paths.push(file.path.clone());
515 }
516 }
519
520 let oids = hash_worktree_paths(®ular_paths, dir)?;
521 for (path, oid) in regular_paths.iter().zip(oids) {
522 let mode = worktree_file_mode(&Path::new(dir).join(path));
523 snapshot.pins.insert(path.clone(), WorktreePin::Object { mode, oid });
524 }
525
526 Ok(())
527}
528
529fn hash_worktree_paths(paths: &[String], dir: &str) -> Result<Vec<String>> {
532 if paths.is_empty() {
533 return Ok(Vec::new());
534 }
535
536 let mut child = git_command()
537 .args(["hash-object", "-w", "--stdin-paths"])
538 .current_dir(dir)
539 .stdin(std::process::Stdio::piped())
540 .stdout(std::process::Stdio::piped())
541 .stderr(std::process::Stdio::piped())
542 .spawn()
543 .map_err(|e| CommitGenError::git(format!("Failed to spawn git hash-object: {e}")))?;
544
545 {
546 let Some(mut stdin) = child.stdin.take() else {
547 return Err(CommitGenError::git("Failed to open git hash-object stdin".to_string()));
548 };
549
550 use std::io::Write;
551
552 for path in paths {
553 stdin
554 .write_all(path.as_bytes())
555 .and_then(|()| stdin.write_all(b"\n"))
556 .map_err(|e| CommitGenError::git(format!("Failed to write path {path}: {e}")))?;
557 }
558 }
559
560 let output = child
561 .wait_with_output()
562 .map_err(|e| CommitGenError::git(format!("Failed to wait for git hash-object: {e}")))?;
563
564 if !output.status.success() {
565 let stderr = String::from_utf8_lossy(&output.stderr);
566 return Err(CommitGenError::git(format!("git hash-object --stdin-paths failed: {stderr}")));
567 }
568
569 let oids: Vec<String> = String::from_utf8_lossy(&output.stdout)
570 .lines()
571 .map(str::to_string)
572 .collect();
573 if oids.len() != paths.len() {
574 return Err(CommitGenError::git(format!(
575 "git hash-object returned {} oids for {} paths",
576 oids.len(),
577 paths.len()
578 )));
579 }
580
581 Ok(oids)
582}
583
584fn worktree_file_mode(path: &Path) -> String {
587 #[cfg(unix)]
588 {
589 use std::os::unix::fs::PermissionsExt;
590 if let Ok(metadata) = std::fs::metadata(path)
591 && metadata.permissions().mode() & 0o111 != 0
592 {
593 return "100755".to_string();
594 }
595 }
596 #[cfg(not(unix))]
597 let _ = path;
598 "100644".to_string()
599}
600
601fn submodule_head(path: &Path) -> Option<String> {
603 let output = git_command()
604 .args(["rev-parse", "HEAD"])
605 .current_dir(path)
606 .output()
607 .ok()?;
608 if !output.status.success() {
609 return None;
610 }
611 let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
612 (!oid.is_empty()).then_some(oid)
613}
614
615fn remove_index_path(path: &str, dir: &str, index_file: Option<&Path>) -> Result<StageResult> {
617 let listed = git_command_for_index(index_file)
618 .args(["ls-files", "--"])
619 .arg(path)
620 .current_dir(dir)
621 .output()
622 .map_err(|e| CommitGenError::git(format!("Failed to inspect index entry {path}: {e}")))?;
623
624 if listed.status.success() && listed.stdout.is_empty() {
625 return Ok(StageResult::AlreadyApplied);
626 }
627
628 let output = git_command_for_index(index_file)
629 .args(["update-index", "--force-remove", "--"])
630 .arg(path)
631 .current_dir(dir)
632 .output()
633 .map_err(|e| CommitGenError::git(format!("Failed to remove index entry {path}: {e}")))?;
634
635 if !output.status.success() {
636 let stderr = String::from_utf8_lossy(&output.stderr);
637 return Err(CommitGenError::git(format!("git update-index failed for {path}: {stderr}")));
638 }
639
640 Ok(StageResult::Staged)
641}
642
643#[tracing::instrument(target = "lgit", name = "patch.stage_files", skip_all, fields(dir, file_count = files.len()))]
645pub fn stage_files(files: &[String], dir: &str) -> Result<()> {
646 stage_files_with_index(files, dir, None)
647}
648
649fn stage_files_with_index(files: &[String], dir: &str, index_file: Option<&Path>) -> Result<()> {
650 if files.is_empty() {
651 return Ok(());
652 }
653
654 let output = git_command_for_index(index_file)
655 .arg("add")
656 .arg("--")
657 .args(files)
658 .current_dir(dir)
659 .output()
660 .map_err(|e| CommitGenError::git(format!("Failed to stage files: {e}")))?;
661
662 if !output.status.success() {
663 let stderr = String::from_utf8_lossy(&output.stderr);
664 return Err(CommitGenError::git(format!("git add failed: {stderr}")));
665 }
666
667 Ok(())
668}
669
670fn hash_blob_bytes(contents: &[u8], path: &str, dir: &str) -> Result<String> {
671 let mut child = git_command()
672 .args(["hash-object", "-w", "--stdin"])
673 .current_dir(dir)
674 .stdin(std::process::Stdio::piped())
675 .stdout(std::process::Stdio::piped())
676 .stderr(std::process::Stdio::piped())
677 .spawn()
678 .map_err(|e| CommitGenError::git(format!("Failed to spawn git hash-object: {e}")))?;
679
680 {
681 let Some(mut stdin) = child.stdin.take() else {
682 return Err(CommitGenError::git("Failed to open git hash-object stdin".to_string()));
683 };
684
685 use std::io::Write;
686
687 stdin
688 .write_all(contents)
689 .map_err(|e| CommitGenError::git(format!("Failed to write blob for {path}: {e}")))?;
690 }
691
692 let output = child
693 .wait_with_output()
694 .map_err(|e| CommitGenError::git(format!("Failed to wait for git hash-object: {e}")))?;
695
696 if !output.status.success() {
697 let stderr = String::from_utf8_lossy(&output.stderr);
698 return Err(CommitGenError::git(format!("git hash-object failed for {path}: {stderr}")));
699 }
700
701 let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
702 if oid.is_empty() {
703 return Err(CommitGenError::git(format!("git hash-object returned empty oid for {path}")));
704 }
705
706 Ok(oid)
707}
708
709fn index_blob_oid<'a>(blob: &'a IndexBlob, dir: &str) -> Result<Cow<'a, str>> {
710 match &blob.object {
711 IndexObject::BlobContents(contents) => {
712 Ok(Cow::Owned(hash_blob_bytes(contents.as_bytes(), &blob.path, dir)?))
713 },
714 IndexObject::BlobBytes(bytes) => Ok(Cow::Owned(hash_blob_bytes(bytes, &blob.path, dir)?)),
715 IndexObject::ExistingObject(oid) => Ok(Cow::Borrowed(oid.as_str())),
716 }
717}
718
719fn index_entry_matches(
720 path: &str,
721 mode: &str,
722 oid: &str,
723 dir: &str,
724 index_file: Option<&Path>,
725) -> Result<bool> {
726 let output = git_command_for_index(index_file)
727 .args(["ls-files", "-s", "--"])
728 .arg(path)
729 .current_dir(dir)
730 .output()
731 .map_err(|e| CommitGenError::git(format!("Failed to inspect index entry {path}: {e}")))?;
732
733 if !output.status.success() {
734 let stderr = String::from_utf8_lossy(&output.stderr);
735 return Err(CommitGenError::git(format!("git ls-files failed for {path}: {stderr}")));
736 }
737
738 let stdout = String::from_utf8_lossy(&output.stdout);
739 let Some(line) = stdout.lines().next() else {
740 return Ok(false);
741 };
742 let mut parts = line.split_whitespace();
743 Ok(parts.next() == Some(mode) && parts.next() == Some(oid))
744}
745
746fn stage_index_blob(blob: &IndexBlob, dir: &str, index_file: Option<&Path>) -> Result<StageResult> {
747 let oid = index_blob_oid(blob, dir)?;
748 if index_entry_matches(&blob.path, &blob.mode, oid.as_ref(), dir, index_file)? {
749 return Ok(StageResult::AlreadyApplied);
750 }
751
752 let cacheinfo = format!("{},{},{}", blob.mode, oid, blob.path);
753 let output = git_command_for_index(index_file)
754 .args(["update-index", "--add", "--cacheinfo"])
755 .arg(cacheinfo)
756 .current_dir(dir)
757 .output()
758 .map_err(|e| CommitGenError::git(format!("Failed to stage blob {}: {e}", blob.path)))?;
759
760 if !output.status.success() {
761 let stderr = String::from_utf8_lossy(&output.stderr);
762 return Err(CommitGenError::git(format!(
763 "git update-index failed for {}: {stderr}",
764 blob.path
765 )));
766 }
767
768 Ok(StageResult::Staged)
769}
770
771#[tracing::instrument(target = "lgit", name = "patch.reset_staging", skip_all, fields(dir))]
773pub fn reset_staging(dir: &str) -> Result<()> {
774 let output = git_command()
775 .args(["reset", "HEAD"])
776 .current_dir(dir)
777 .output()
778 .map_err(|e| CommitGenError::git(format!("Failed to reset staging: {e}")))?;
779
780 if !output.status.success() {
781 let stderr = String::from_utf8_lossy(&output.stderr);
782 return Err(CommitGenError::git(format!("git reset HEAD failed: {stderr}")));
783 }
784
785 Ok(())
786}
787
788fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
789 let trimmed = header.trim();
790 if !trimmed.starts_with("@@") {
791 return None;
792 }
793
794 let after_first = trimmed.strip_prefix("@@")?;
795 let middle = after_first.split("@@").next()?.trim();
796 let parts: Vec<&str> = middle.split_whitespace().collect();
797 if parts.len() < 2 {
798 return None;
799 }
800
801 let old_part = parts[0].strip_prefix('-')?;
802 let new_part = parts[1].strip_prefix('+')?;
803
804 let parse_range = |s: &str| -> Option<(usize, usize)> {
805 if let Some((start, count)) = s.split_once(',') {
806 Some((start.parse().ok()?, count.parse().ok()?))
807 } else {
808 Some((s.parse().ok()?, 1))
809 }
810 };
811
812 let (old_start, old_count) = parse_range(old_part)?;
813 let (new_start, new_count) = parse_range(new_part)?;
814 Some((old_start, old_count, new_start, new_count))
815}
816
817fn parse_file_path(diff_header: &str) -> Result<String> {
818 diff_header
819 .split_whitespace()
820 .nth(3)
821 .and_then(|part| part.strip_prefix("b/"))
822 .map(str::to_string)
823 .ok_or_else(|| {
824 CommitGenError::Other(format!("Failed to parse file path from '{diff_header}'"))
825 })
826}
827
828fn finalize_current_hunk(file: &mut ParsedFile, current_hunk: &mut Option<ParsedHunk>) {
829 if let Some(hunk) = current_hunk.take() {
830 file.hunks.push(hunk);
831 }
832}
833
834fn finalize_current_file(
835 files: &mut Vec<ParsedFile>,
836 current_file: &mut Option<ParsedFile>,
837 current_hunk: &mut Option<ParsedHunk>,
838) {
839 if let Some(mut file) = current_file.take() {
840 finalize_current_hunk(&mut file, current_hunk);
841 files.push(file);
842 }
843}
844
845fn join_lines(lines: &[String]) -> String {
846 if lines.is_empty() {
847 String::new()
848 } else {
849 let mut joined = lines.join("\n");
850 joined.push('\n');
851 joined
852 }
853}
854
855fn diff_lines_preserve_cr(input: &str) -> impl Iterator<Item = &str> {
856 input
857 .split_inclusive('\n')
858 .map(|line| line.strip_suffix('\n').unwrap_or(line))
859}
860
861fn truncate_snippet(snippet: &str, max_chars: usize) -> String {
862 let trimmed = snippet.trim();
863 if trimmed.chars().count() <= max_chars {
864 return trimmed.to_string();
865 }
866
867 let mut truncated = trimmed.chars().take(max_chars).collect::<String>();
868 truncated.push_str("...");
869 truncated
870}
871
872fn build_hunk_snippet(lines: &[String], fallback: &str) -> String {
873 let interesting: Vec<String> = lines
874 .iter()
875 .skip(1)
876 .filter(|line| line.starts_with('+') || line.starts_with('-'))
877 .take(3)
878 .map(|line| truncate_snippet(line.trim_start_matches(['+', '-']), 80))
879 .collect();
880
881 if interesting.is_empty() {
882 truncate_snippet(fallback, 80)
883 } else {
884 interesting.join(" | ")
885 }
886}
887
888fn build_synthetic_snippet(file: &ParsedFile) -> String {
889 let header_text = file
890 .header_lines
891 .iter()
892 .skip(1)
893 .find(|line| {
894 !line.starts_with("index ")
895 && !line.starts_with("--- ")
896 && !line.starts_with("+++ ")
897 && !line.trim().is_empty()
898 })
899 .cloned()
900 .unwrap_or_else(|| format!("whole-file change in {}", file.path));
901
902 truncate_snippet(&header_text, 80)
903}
904
905fn fnv1a_64(input: &str) -> String {
906 let mut hash = 0xcbf29ce484222325_u64;
907 for byte in input.as_bytes() {
908 hash ^= u64::from(*byte);
909 hash = hash.wrapping_mul(0x100000001b3);
910 }
911 format!("{hash:016x}")
912}
913
914fn build_semantic_key(path: &str, lines: &[String], fallback: &str) -> String {
915 let mut changed = Vec::new();
916 for line in lines {
917 if (line.starts_with('+') && !line.starts_with("+++"))
918 || (line.starts_with('-') && !line.starts_with("---"))
919 {
920 changed.push(line.clone());
921 }
922 }
923
924 let source = if changed.is_empty() {
925 fallback.to_string()
926 } else {
927 changed.join("\n")
928 };
929
930 format!("{path}:{}", fnv1a_64(&source))
931}
932
933#[tracing::instrument(target = "lgit", name = "patch.build_compose_snapshot", skip_all, fields(diff_bytes = diff.len(), stat_bytes = stat.len()))]
934pub fn build_compose_snapshot(diff: &str, stat: &str) -> Result<ComposeSnapshot> {
935 let mut files = Vec::new();
936 let mut current_file: Option<ParsedFile> = None;
937 let mut current_hunk: Option<ParsedHunk> = None;
938
939 for line in diff_lines_preserve_cr(diff) {
940 if line.starts_with("diff --git ") {
941 finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
942 current_file = Some(ParsedFile {
943 path: parse_file_path(line)?,
944 header_lines: vec![line.to_string()],
945 hunks: Vec::new(),
946 additions: 0,
947 deletions: 0,
948 is_binary: false,
949 });
950 continue;
951 }
952
953 let Some(file) = &mut current_file else {
954 continue;
955 };
956
957 if line.starts_with("@@ ") {
958 finalize_current_hunk(file, &mut current_hunk);
959 let (old_start, old_count, new_start, new_count) =
960 parse_hunk_header(line).ok_or_else(|| {
961 CommitGenError::Other(format!("Failed to parse hunk header '{line}'"))
962 })?;
963 current_hunk = Some(ParsedHunk {
964 old_start,
965 old_count,
966 new_start,
967 new_count,
968 header: line.to_string(),
969 lines: vec![line.to_string()],
970 });
971 continue;
972 }
973
974 if let Some(hunk) = &mut current_hunk {
975 if line.starts_with('+') {
976 file.additions += 1;
977 } else if line.starts_with('-') {
978 file.deletions += 1;
979 }
980
981 hunk.lines.push(line.to_string());
982 continue;
983 }
984
985 if line.starts_with("Binary files ") {
986 file.is_binary = true;
987 }
988 file.header_lines.push(line.to_string());
989 }
990
991 finalize_current_file(&mut files, &mut current_file, &mut current_hunk);
992
993 let mut snapshot_files = Vec::new();
994 let mut snapshot_hunks = Vec::new();
995
996 for (file_index, file) in files.into_iter().enumerate() {
997 let file_id = format!("F{:03}", file_index + 1);
998 let patch_header = join_lines(&file.header_lines);
999 let mut full_patch = patch_header.clone();
1000 let mut hunk_ids = Vec::new();
1001
1002 if file.hunks.is_empty() {
1003 let hunk_id = format!("{file_id}-H001");
1004 let snippet = build_synthetic_snippet(&file);
1005 let semantic_key = build_semantic_key(&file.path, &file.header_lines, &snippet);
1006 hunk_ids.push(hunk_id.clone());
1007 snapshot_hunks.push(ComposeHunk {
1008 hunk_id,
1009 file_id: file_id.clone(),
1010 path: file.path.clone(),
1011 old_start: 0,
1012 old_count: 0,
1013 new_start: 0,
1014 new_count: 0,
1015 header: snippet.clone(),
1016 raw_patch: String::new(),
1017 snippet,
1018 semantic_key,
1019 synthetic: true,
1020 });
1021 } else {
1022 for (hunk_index, hunk) in file.hunks.iter().enumerate() {
1023 let hunk_id = format!("{file_id}-H{:03}", hunk_index + 1);
1024 let raw_patch = join_lines(&hunk.lines);
1025 let snippet = build_hunk_snippet(&hunk.lines, &hunk.header);
1026 let semantic_key = build_semantic_key(&file.path, &hunk.lines, &snippet);
1027
1028 full_patch.push_str(&raw_patch);
1029 hunk_ids.push(hunk_id.clone());
1030 snapshot_hunks.push(ComposeHunk {
1031 hunk_id,
1032 file_id: file_id.clone(),
1033 path: file.path.clone(),
1034 old_start: hunk.old_start,
1035 old_count: hunk.old_count,
1036 new_start: hunk.new_start,
1037 new_count: hunk.new_count,
1038 header: hunk.header.clone(),
1039 raw_patch,
1040 snippet,
1041 semantic_key,
1042 synthetic: false,
1043 });
1044 }
1045 }
1046
1047 let hunk_word = if hunk_ids.len() == 1 { "hunk" } else { "hunks" };
1048 let summary = format!(
1049 "{} (+{}/-{}, {} {})",
1050 file.path,
1051 file.additions,
1052 file.deletions,
1053 hunk_ids.len(),
1054 hunk_word
1055 );
1056
1057 snapshot_files.push(ComposeFile {
1058 file_id,
1059 path: file.path,
1060 patch_header,
1061 full_patch,
1062 summary,
1063 hunk_ids,
1064 additions: file.additions,
1065 deletions: file.deletions,
1066 is_binary: file.is_binary,
1067 synthetic_only: file.hunks.is_empty(),
1068 });
1069 }
1070
1071 Ok(ComposeSnapshot {
1072 diff: diff.to_string(),
1073 stat: stat.to_string(),
1074 files: snapshot_files,
1075 hunks: snapshot_hunks,
1076 pins: BTreeMap::new(),
1077 })
1078}
1079
1080fn create_patch_for_file(file: &ComposeFile, hunks: &[&ComposeHunk]) -> String {
1081 let mut patch = file.patch_header.clone();
1082 for hunk in hunks {
1083 patch.push_str(&hunk.raw_patch);
1084 }
1085 patch
1086}
1087
1088fn selected_hunks_by_file<'a>(
1089 snapshot: &'a ComposeSnapshot,
1090 group: &ComposeExecutableGroup,
1091) -> Result<BTreeMap<String, Vec<&'a ComposeHunk>>> {
1092 if group.hunk_ids.is_empty() {
1093 return Err(CommitGenError::Other(format!("Group {} has no assigned hunks", group.group_id)));
1094 }
1095
1096 let mut selected_by_file: BTreeMap<String, Vec<&ComposeHunk>> = BTreeMap::new();
1097 for hunk_id in &group.hunk_ids {
1098 let hunk = snapshot.hunk_by_id(hunk_id).ok_or_else(|| {
1099 CommitGenError::Other(format!(
1100 "Group {} references unknown hunk id {hunk_id}",
1101 group.group_id
1102 ))
1103 })?;
1104 selected_by_file
1105 .entry(hunk.file_id.clone())
1106 .or_default()
1107 .push(hunk);
1108 }
1109
1110 Ok(selected_by_file)
1111}
1112
1113fn ordered_selected_hunks<'a>(
1114 file: &ComposeFile,
1115 selected_for_file: &[&'a ComposeHunk],
1116) -> Result<Vec<&'a ComposeHunk>> {
1117 let ordered_hunks: Vec<&ComposeHunk> = file
1118 .hunk_ids
1119 .iter()
1120 .filter_map(|hunk_id| {
1121 selected_for_file
1122 .iter()
1123 .find(|hunk| hunk.hunk_id == *hunk_id)
1124 .copied()
1125 })
1126 .collect();
1127
1128 if ordered_hunks.is_empty() {
1129 return Err(CommitGenError::Other(format!("Selected no patchable hunks for {}", file.path)));
1130 }
1131
1132 Ok(ordered_hunks)
1133}
1134
1135fn selected_hunks_cover_file(file: &ComposeFile, selected_for_file: &[&ComposeHunk]) -> bool {
1136 let selected_ids: HashSet<&str> = selected_for_file
1137 .iter()
1138 .map(|hunk| hunk.hunk_id.as_str())
1139 .collect();
1140 let file_hunk_ids: HashSet<&str> = file.hunk_ids.iter().map(String::as_str).collect();
1141 selected_ids == file_hunk_ids
1142}
1143
1144fn count_hunk_changes(hunk: &ComposeHunk) -> (usize, usize) {
1145 let mut additions = 0_usize;
1146 let mut deletions = 0_usize;
1147
1148 for line in hunk.raw_patch.lines() {
1149 if line.starts_with('+') {
1150 additions += 1;
1151 } else if line.starts_with('-') {
1152 deletions += 1;
1153 }
1154 }
1155
1156 (additions, deletions)
1157}
1158
1159fn push_stat_line(
1160 stat: &mut String,
1161 path: &str,
1162 additions: usize,
1163 deletions: usize,
1164 is_binary: bool,
1165) {
1166 use std::fmt::Write;
1167
1168 if is_binary && additions == 0 && deletions == 0 {
1169 writeln!(stat, " {path} | Bin").unwrap();
1170 return;
1171 }
1172
1173 let change_count = additions + deletions;
1174 let pluses = "+".repeat(additions.min(50));
1175 let minuses = "-".repeat(deletions.min(50));
1176 writeln!(stat, " {path} | {change_count} {pluses}{minuses}").unwrap();
1177}
1178
1179fn new_file_mode(file: &ComposeFile) -> Option<&str> {
1180 file
1181 .patch_header
1182 .lines()
1183 .find_map(|line| line.strip_prefix("new file mode ").map(str::trim))
1184}
1185
1186fn validate_new_file_mode(file: &ComposeFile) -> Result<String> {
1187 let mode = new_file_mode(file).unwrap_or("100644");
1188 if matches!(mode, "100644" | "100755" | "120000" | "160000") {
1189 Ok(mode.to_string())
1190 } else {
1191 Err(CommitGenError::Other(format!("Invalid new file mode {mode:?} for {}", file.path)))
1192 }
1193}
1194
1195fn materialize_new_file_contents(hunks: &[&ComposeHunk]) -> String {
1196 let mut contents = String::new();
1197 let mut last_emitted_line_had_newline = false;
1198
1199 for hunk in hunks {
1200 for line in diff_lines_preserve_cr(&hunk.raw_patch) {
1201 if line.starts_with("@@") {
1202 last_emitted_line_had_newline = false;
1203 continue;
1204 }
1205
1206 if line == r"\ No newline at end of file" {
1207 if last_emitted_line_had_newline {
1208 contents.pop();
1209 last_emitted_line_had_newline = false;
1210 }
1211 continue;
1212 }
1213
1214 if let Some(added) = line.strip_prefix('+') {
1215 contents.push_str(added);
1216 contents.push('\n');
1217 last_emitted_line_had_newline = true;
1218 } else if let Some(context) = line.strip_prefix(' ') {
1219 contents.push_str(context);
1220 contents.push('\n');
1221 last_emitted_line_had_newline = true;
1222 } else {
1223 last_emitted_line_had_newline = false;
1224 }
1225 }
1226 }
1227
1228 contents
1229}
1230
1231fn new_file_index_oid(file: &ComposeFile) -> Option<&str> {
1232 file.patch_header.lines().find_map(|line| {
1233 let index_range = line.strip_prefix("index ")?;
1234 let (_, new_oid) = index_range.split_once("..")?;
1235 new_oid.split_whitespace().next()
1236 })
1237}
1238
1239fn validate_git_object_id(oid: &str, file: &ComposeFile) -> Result<String> {
1240 let oid = oid.trim();
1241 if !oid.is_empty()
1242 && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
1243 && oid.bytes().any(|byte| byte != b'0')
1244 {
1245 Ok(oid.to_string())
1246 } else {
1247 Err(CommitGenError::Other(format!("Invalid gitlink object id {oid:?} for {}", file.path)))
1248 }
1249}
1250
1251fn materialize_gitlink_oid(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<String> {
1252 let contents = materialize_new_file_contents(hunks);
1253 if let Some(oid) = contents.lines().find_map(|line| {
1254 line
1255 .strip_prefix("Subproject commit ")
1256 .and_then(|rest| rest.split_whitespace().next())
1257 }) {
1258 return validate_git_object_id(oid, file);
1259 }
1260
1261 if let Some(oid) = new_file_index_oid(file) {
1262 return validate_git_object_id(oid, file);
1263 }
1264
1265 Err(CommitGenError::Other(format!("Missing gitlink object id for {}", file.path)))
1266}
1267
1268fn new_file_index_blob(file: &ComposeFile, hunks: &[&ComposeHunk]) -> Result<IndexBlob> {
1269 let mode = validate_new_file_mode(file)?;
1270 let object = if mode == "160000" {
1271 IndexObject::ExistingObject(materialize_gitlink_oid(file, hunks)?)
1272 } else {
1273 IndexObject::BlobContents(materialize_new_file_contents(hunks))
1274 };
1275
1276 Ok(IndexBlob { path: file.path.clone(), mode, object })
1277}
1278
1279#[tracing::instrument(target = "lgit", name = "patch.create_executable_group_patch", skip_all, fields(group_id = %group.group_id, file_count = group.file_ids.len(), hunk_count = group.hunk_ids.len()))]
1280pub fn create_executable_group_patch(
1281 snapshot: &ComposeSnapshot,
1282 group: &ComposeExecutableGroup,
1283) -> Result<ComposeGroupPatch> {
1284 let selected_by_file = selected_hunks_by_file(snapshot, group)?;
1285 let mut fallback_files = Vec::new();
1286 let mut diff = String::new();
1287 let mut stat = String::new();
1288 let mut apply_patches: Vec<FilePatch> = Vec::new();
1289 let mut index_blobs = Vec::new();
1290
1291 for file in &snapshot.files {
1292 let Some(selected_for_file) = selected_by_file.get(&file.file_id) else {
1293 continue;
1294 };
1295
1296 let ordered_hunks = ordered_selected_hunks(file, selected_for_file).map_err(|_| {
1297 CommitGenError::Other(format!(
1298 "Group {} selected no patchable hunks for {}",
1299 group.group_id, file.path
1300 ))
1301 })?;
1302
1303 if file.synthetic_only || file.is_binary {
1304 if selected_hunks_cover_file(file, selected_for_file) {
1305 if file.synthetic_only && !file.is_binary && new_file_mode(file).is_some() {
1306 index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1307 } else {
1308 fallback_files.push(file.path.clone());
1309 }
1310 diff.push_str(&file.full_patch);
1311 push_stat_line(&mut stat, &file.path, file.additions, file.deletions, file.is_binary);
1312 continue;
1313 }
1314
1315 return Err(CommitGenError::Other(format!(
1316 "Group {} cannot partially stage unpatchable file {}",
1317 group.group_id, file.path
1318 )));
1319 }
1320
1321 let file_patch = create_patch_for_file(file, &ordered_hunks);
1322 let (additions, deletions) = ordered_hunks.iter().fold(
1323 (0_usize, 0_usize),
1324 |(total_additions, total_deletions), hunk| {
1325 let (hunk_additions, hunk_deletions) = count_hunk_changes(hunk);
1326 (total_additions + hunk_additions, total_deletions + hunk_deletions)
1327 },
1328 );
1329 diff.push_str(&file_patch);
1330 if new_file_mode(file).is_some() {
1331 if selected_hunks_cover_file(file, selected_for_file) {
1334 index_blobs.push(new_file_index_blob(file, &ordered_hunks)?);
1335 } else {
1336 apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1337 }
1338 } else if selected_hunks_cover_file(file, selected_for_file) {
1339 fallback_files.push(file.path.clone());
1343 } else {
1344 apply_patches.push(FilePatch { path: file.path.clone(), patch: file_patch });
1347 }
1348 push_stat_line(&mut stat, &file.path, additions, deletions, false);
1349 }
1350
1351 fallback_files.sort();
1352 fallback_files.dedup();
1353
1354 Ok(ComposeGroupPatch { diff, stat, apply_patches, fallback_files, index_blobs })
1355}
1356
1357#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group", skip_all, fields(dir, group_id = %group.group_id))]
1358pub fn stage_executable_group(
1359 snapshot: &ComposeSnapshot,
1360 group: &ComposeExecutableGroup,
1361 dir: &str,
1362) -> Result<ComposeStageOutcome> {
1363 stage_executable_group_with_index(snapshot, group, dir, None)
1364}
1365
1366#[tracing::instrument(target = "lgit", name = "patch.stage_executable_group_in_index", skip_all, fields(dir, group_id = %group.group_id, index = %index_file.display()))]
1367pub fn stage_executable_group_in_index(
1368 snapshot: &ComposeSnapshot,
1369 group: &ComposeExecutableGroup,
1370 dir: &str,
1371 index_file: &Path,
1372) -> Result<ComposeStageOutcome> {
1373 stage_executable_group_with_index(snapshot, group, dir, Some(index_file))
1374}
1375
1376fn stage_executable_group_with_index(
1377 snapshot: &ComposeSnapshot,
1378 group: &ComposeExecutableGroup,
1379 dir: &str,
1380 index_file: Option<&Path>,
1381) -> Result<ComposeStageOutcome> {
1382 let group_patch = create_executable_group_patch(snapshot, group)?;
1383 let mut result = StageResult::EmptyPatch;
1384 let mut skipped = Vec::new();
1385
1386 for file_patch in &group_patch.apply_patches {
1387 match apply_file_patch_to_index(&file_patch.patch, dir, index_file)? {
1388 FilePatchOutcome::Staged => result = result.combine(StageResult::Staged),
1389 FilePatchOutcome::AlreadyApplied => {
1390 result = result.combine(StageResult::AlreadyApplied);
1391 },
1392 FilePatchOutcome::Empty => result = result.combine(StageResult::EmptyPatch),
1393 FilePatchOutcome::Failed(reason) => {
1394 restore_index_path_to_head(&file_patch.path, dir, index_file)?;
1397 skipped.push(SkippedFile { path: file_patch.path.clone(), reason });
1398 },
1399 }
1400 }
1401
1402 for path in &group_patch.fallback_files {
1403 match snapshot.pins.get(path) {
1404 Some(WorktreePin::Object { mode, oid }) => {
1405 let blob = IndexBlob {
1406 path: path.clone(),
1407 mode: mode.clone(),
1408 object: IndexObject::ExistingObject(oid.clone()),
1409 };
1410 result = result.combine(stage_index_blob(&blob, dir, index_file)?);
1411 },
1412 Some(WorktreePin::Deleted) => {
1413 result = result.combine(remove_index_path(path, dir, index_file)?);
1414 },
1415 None => {
1418 stage_files_with_index(std::slice::from_ref(path), dir, index_file)?;
1419 result = result.combine(StageResult::Staged);
1420 },
1421 }
1422 }
1423
1424 for blob in &group_patch.index_blobs {
1425 result = result.combine(stage_index_blob(blob, dir, index_file)?);
1426 }
1427
1428 Ok(ComposeStageOutcome { result, skipped })
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use std::fs;
1434
1435 use tempfile::TempDir;
1436
1437 use super::*;
1438 use crate::{
1439 compose_types::ComposeExecutableGroup,
1440 git::{TempGitIndex, get_compose_diff, get_compose_stat, read_tree_into_index},
1441 types::CommitType,
1442 };
1443
1444 fn write_file(dir: &TempDir, path: &str, contents: &str) {
1445 let full_path = dir.path().join(path);
1446 if let Some(parent) = full_path.parent() {
1447 fs::create_dir_all(parent).unwrap();
1448 }
1449 fs::write(full_path, contents).unwrap();
1450 }
1451
1452 fn run_git(dir: &TempDir, args: &[&str]) -> String {
1453 let output = git_command()
1454 .args(args)
1455 .current_dir(dir.path())
1456 .output()
1457 .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}"));
1458
1459 assert!(
1460 output.status.success(),
1461 "git {:?} failed: stdout={} stderr={}",
1462 args,
1463 String::from_utf8_lossy(&output.stdout),
1464 String::from_utf8_lossy(&output.stderr)
1465 );
1466
1467 String::from_utf8_lossy(&output.stdout).to_string()
1468 }
1469
1470 fn init_repo() -> TempDir {
1471 let dir = TempDir::new().unwrap();
1472 run_git(&dir, &["init"]);
1473 run_git(&dir, &["config", "user.name", "Compose Test"]);
1474 run_git(&dir, &["config", "user.email", "compose@test.local"]);
1475 run_git(&dir, &["config", "commit.gpgsign", "false"]);
1476 dir
1477 }
1478
1479 fn fixture_file_original() -> String {
1480 [
1481 "fn alpha() {",
1482 " println!(\"alpha\");",
1483 "}",
1484 "",
1485 "// spacer 1",
1486 "// spacer 2",
1487 "// spacer 3",
1488 "// spacer 4",
1489 "// spacer 5",
1490 "// spacer 6",
1491 "// spacer 7",
1492 "// spacer 8",
1493 "fn beta() {",
1494 " println!(\"beta\");",
1495 "}",
1496 "",
1497 ]
1498 .join("\n")
1499 }
1500
1501 fn fixture_file_stage_only() -> String {
1502 fixture_file_original().replace("alpha", "alpha staged")
1503 }
1504
1505 fn fixture_file_stage_and_unstaged() -> String {
1506 fixture_file_stage_only().replace("beta", "beta unstaged")
1507 }
1508
1509 fn fixture_file_two_hunks() -> String {
1510 [
1511 "fn alpha() {",
1512 " println!(\"alpha changed\");",
1513 "}",
1514 "",
1515 "// spacer 1",
1516 "// spacer 2",
1517 "// spacer 3",
1518 "// spacer 4",
1519 "// spacer 5",
1520 "// spacer 6",
1521 "// spacer 7",
1522 "// spacer 8",
1523 "fn beta() {",
1524 " println!(\"beta changed\");",
1525 "}",
1526 "",
1527 ]
1528 .join("\n")
1529 }
1530
1531 fn commit_all(dir: &TempDir, message: &str) {
1532 run_git(dir, &["add", "."]);
1533 run_git(dir, &["commit", "-m", message]);
1534 }
1535
1536 fn staged_diff(dir: &TempDir) -> String {
1537 run_git(dir, &["diff", "--cached"])
1538 }
1539
1540 fn staged_diff_in_index(dir: &TempDir, index: &TempGitIndex) -> String {
1541 let output = crate::git::git_command_with_index(index.path())
1542 .args(["diff", "--cached"])
1543 .current_dir(dir.path())
1544 .output()
1545 .unwrap();
1546 assert!(
1547 output.status.success(),
1548 "git diff --cached with temp index failed: {}",
1549 String::from_utf8_lossy(&output.stderr)
1550 );
1551 String::from_utf8_lossy(&output.stdout).to_string()
1552 }
1553
1554 #[test]
1555 fn test_build_compose_snapshot_stable_ids() {
1556 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
1557index 1111111..2222222 100644
1558--- a/src/lib.rs
1559+++ b/src/lib.rs
1560@@ -1,3 +1,3 @@
1561-fn alpha() {
1562+fn alpha_changed() {
1563 println!("alpha");
1564 }
1565diff --git a/tests/lib.rs b/tests/lib.rs
1566index 3333333..4444444 100644
1567--- a/tests/lib.rs
1568+++ b/tests/lib.rs
1569@@ -10,3 +10,4 @@
1570 fn test_it() {
1571+ assert!(true);
1572 }
1573"#;
1574
1575 let stat = " src/lib.rs | 2 +-\n tests/lib.rs | 1 +\n";
1576 let first = build_compose_snapshot(diff, stat).unwrap();
1577 let second = build_compose_snapshot(diff, stat).unwrap();
1578
1579 assert_eq!(first.files.len(), 2);
1580 assert_eq!(
1581 first
1582 .files
1583 .iter()
1584 .map(|file| file.file_id.clone())
1585 .collect::<Vec<_>>(),
1586 second
1587 .files
1588 .iter()
1589 .map(|file| file.file_id.clone())
1590 .collect::<Vec<_>>()
1591 );
1592 assert_eq!(
1593 first
1594 .hunks
1595 .iter()
1596 .map(|hunk| hunk.hunk_id.clone())
1597 .collect::<Vec<_>>(),
1598 second
1599 .hunks
1600 .iter()
1601 .map(|hunk| hunk.hunk_id.clone())
1602 .collect::<Vec<_>>()
1603 );
1604 }
1605
1606 #[test]
1607 fn test_get_compose_diff_merges_staged_unstaged_and_untracked() {
1608 let dir = init_repo();
1609 write_file(&dir, "src/lib.rs", &fixture_file_original());
1610 commit_all(&dir, "initial");
1611
1612 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1613 run_git(&dir, &["add", "src/lib.rs"]);
1614 write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
1615 write_file(&dir, "notes.txt", "new untracked file\n");
1616
1617 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1618 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1619 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1620
1621 assert_eq!(snapshot.files.len(), 2);
1622 assert!(snapshot.file_by_path("src/lib.rs").is_some());
1623 assert!(snapshot.file_by_path("notes.txt").is_some());
1624
1625 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1626 assert!(
1627 source_file.hunk_ids.len() >= 2,
1628 "expected staged + unstaged edits in one file to produce multiple hunks"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_stage_executable_group_partial_hunk_from_one_file() {
1634 let dir = init_repo();
1635 write_file(&dir, "src/lib.rs", &fixture_file_original());
1636 commit_all(&dir, "initial");
1637 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1638
1639 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1640 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1641 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1642 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1643 assert_eq!(source_file.hunk_ids.len(), 2);
1644
1645 reset_staging(dir.path().to_str().unwrap()).unwrap();
1646 let group = ComposeExecutableGroup {
1647 group_id: "G1".to_string(),
1648 commit_type: CommitType::new("refactor").unwrap(),
1649 scope: None,
1650 file_ids: vec![source_file.file_id.clone()],
1651 rationale: "first hunk".to_string(),
1652 dependencies: vec![],
1653 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1654 };
1655 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1656
1657 let staged = staged_diff(&dir);
1658 assert!(staged.contains("alpha changed"));
1659 assert!(!staged.contains("beta changed"));
1660 }
1661
1662 #[test]
1663 fn test_stage_executable_group_across_sequential_commits_same_file() {
1664 let dir = init_repo();
1665 write_file(&dir, "src/lib.rs", &fixture_file_original());
1666 commit_all(&dir, "initial");
1667 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1668
1669 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1670 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1671 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1672 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1673 assert_eq!(source_file.hunk_ids.len(), 2);
1674
1675 let first_group = ComposeExecutableGroup {
1676 group_id: "G1".to_string(),
1677 commit_type: CommitType::new("refactor").unwrap(),
1678 scope: None,
1679 file_ids: vec![source_file.file_id.clone()],
1680 rationale: "first hunk".to_string(),
1681 dependencies: vec![],
1682 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1683 };
1684 let second_group = ComposeExecutableGroup {
1685 group_id: "G2".to_string(),
1686 commit_type: CommitType::new("refactor").unwrap(),
1687 scope: None,
1688 file_ids: vec![source_file.file_id.clone()],
1689 rationale: "second hunk".to_string(),
1690 dependencies: vec![],
1691 hunk_ids: vec![source_file.hunk_ids[1].clone()],
1692 };
1693
1694 reset_staging(dir.path().to_str().unwrap()).unwrap();
1695 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1696 run_git(&dir, &["commit", "-m", "first"]);
1697
1698 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1699 let staged = staged_diff(&dir);
1700 assert!(staged.contains("beta changed"));
1701 assert!(!staged.contains("alpha changed"));
1702 }
1703
1704 fn show_index_blob(dir: &TempDir, index: &TempGitIndex, path: &str) -> String {
1705 let output = crate::git::git_command_with_index(index.path())
1706 .args(["show", &format!(":{path}")])
1707 .current_dir(dir.path())
1708 .output()
1709 .unwrap();
1710 assert!(
1711 output.status.success(),
1712 "git show :{path} failed: {}",
1713 String::from_utf8_lossy(&output.stderr)
1714 );
1715 String::from_utf8_lossy(&output.stdout).to_string()
1716 }
1717
1718 #[test]
1719 fn test_pinned_staging_ignores_worktree_edits_after_snapshot() {
1720 let dir = init_repo();
1721 write_file(&dir, "src/lib.rs", &fixture_file_original());
1722 commit_all(&dir, "initial");
1723 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1724
1725 let dir_str = dir.path().to_str().unwrap();
1726 let diff = get_compose_diff(dir_str).unwrap();
1727 let stat = get_compose_stat(dir_str).unwrap();
1728 let mut snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1729 pin_snapshot_worktree_state(&mut snapshot, dir_str).unwrap();
1730
1731 write_file(&dir, "src/lib.rs", &fixture_file_stage_and_unstaged());
1733
1734 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1735 let group = ComposeExecutableGroup {
1736 group_id: "G1".to_string(),
1737 commit_type: CommitType::new("refactor").unwrap(),
1738 scope: None,
1739 file_ids: vec![source_file.file_id.clone()],
1740 rationale: "whole file".to_string(),
1741 dependencies: vec![],
1742 hunk_ids: source_file.hunk_ids.clone(),
1743 };
1744
1745 let index = TempGitIndex::new(dir_str).unwrap();
1746 read_tree_into_index(index.path(), "HEAD", dir_str).unwrap();
1747 let outcome =
1748 stage_executable_group_in_index(&snapshot, &group, dir_str, index.path()).unwrap();
1749 assert_eq!(outcome.result, StageResult::Staged);
1750 assert!(outcome.skipped.is_empty());
1751
1752 let staged = show_index_blob(&dir, &index, "src/lib.rs");
1753 assert_eq!(
1754 staged,
1755 fixture_file_stage_only(),
1756 "staged content must match the pinned snapshot, not the live worktree"
1757 );
1758
1759 let on_disk = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
1760 assert_eq!(
1761 on_disk,
1762 fixture_file_stage_and_unstaged(),
1763 "later edits stay untouched in the worktree"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_pinned_staging_stages_deletion_even_if_file_recreated() {
1769 let dir = init_repo();
1770 write_file(&dir, "src/lib.rs", &fixture_file_original());
1771 commit_all(&dir, "initial");
1772 fs::remove_file(dir.path().join("src/lib.rs")).unwrap();
1773
1774 let dir_str = dir.path().to_str().unwrap();
1775 let diff = get_compose_diff(dir_str).unwrap();
1776 let stat = get_compose_stat(dir_str).unwrap();
1777 let mut snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1778 pin_snapshot_worktree_state(&mut snapshot, dir_str).unwrap();
1779 assert_eq!(
1780 snapshot.pins.get("src/lib.rs"),
1781 Some(&crate::compose_types::WorktreePin::Deleted)
1782 );
1783
1784 write_file(&dir, "src/lib.rs", "fn revived() {}\n");
1786
1787 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1788 let group = ComposeExecutableGroup {
1789 group_id: "G1".to_string(),
1790 commit_type: CommitType::new("refactor").unwrap(),
1791 scope: None,
1792 file_ids: vec![source_file.file_id.clone()],
1793 rationale: "delete file".to_string(),
1794 dependencies: vec![],
1795 hunk_ids: source_file.hunk_ids.clone(),
1796 };
1797
1798 let index = TempGitIndex::new(dir_str).unwrap();
1799 read_tree_into_index(index.path(), "HEAD", dir_str).unwrap();
1800 let outcome =
1801 stage_executable_group_in_index(&snapshot, &group, dir_str, index.path()).unwrap();
1802 assert_eq!(outcome.result, StageResult::Staged);
1803
1804 let listed = crate::git::git_command_with_index(index.path())
1805 .args(["ls-files", "--", "src/lib.rs"])
1806 .current_dir(dir.path())
1807 .output()
1808 .unwrap();
1809 assert!(listed.stdout.is_empty(), "deletion must be staged from the pin");
1810 assert!(
1811 dir.path().join("src/lib.rs").exists(),
1812 "recreated file stays untouched in the worktree"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_create_executable_group_patch_derives_diff_without_staging() {
1818 let dir = init_repo();
1819 write_file(&dir, "src/lib.rs", &fixture_file_original());
1820 commit_all(&dir, "initial");
1821 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1822
1823 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1824 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1825 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1826 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1827 let group = ComposeExecutableGroup {
1828 group_id: "G1".to_string(),
1829 commit_type: CommitType::new("refactor").unwrap(),
1830 scope: None,
1831 file_ids: vec![source_file.file_id.clone()],
1832 rationale: "first hunk".to_string(),
1833 dependencies: vec![],
1834 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1835 };
1836
1837 reset_staging(dir.path().to_str().unwrap()).unwrap();
1838 let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
1839
1840 assert!(staged_diff(&dir).trim().is_empty());
1841 assert!(group_patch.diff.contains("alpha changed"));
1842 assert!(!group_patch.diff.contains("beta changed"));
1843 assert!(group_patch.stat.contains("src/lib.rs | 2 +-"));
1844 }
1845
1846 #[test]
1847 fn test_stage_executable_groups_ignore_unplanned_files_between_commits() {
1848 let dir = init_repo();
1849 write_file(&dir, "src/a.rs", "fn a() {}\n");
1850 write_file(&dir, "src/b.rs", "fn b() {}\n");
1851 commit_all(&dir, "initial");
1852 write_file(&dir, "src/a.rs", "fn a_changed() {}\n");
1853 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
1854
1855 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1856 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1857 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1858 let first_file = snapshot.file_by_path("src/a.rs").unwrap();
1859 let second_file = snapshot.file_by_path("src/b.rs").unwrap();
1860 let first_group = ComposeExecutableGroup {
1861 group_id: "G1".to_string(),
1862 commit_type: CommitType::new("refactor").unwrap(),
1863 scope: None,
1864 file_ids: vec![first_file.file_id.clone()],
1865 rationale: "first file".to_string(),
1866 dependencies: vec![],
1867 hunk_ids: first_file.hunk_ids.clone(),
1868 };
1869 let second_group = ComposeExecutableGroup {
1870 group_id: "G2".to_string(),
1871 commit_type: CommitType::new("refactor").unwrap(),
1872 scope: None,
1873 file_ids: vec![second_file.file_id.clone()],
1874 rationale: "second file".to_string(),
1875 dependencies: vec![],
1876 hunk_ids: second_file.hunk_ids.clone(),
1877 };
1878
1879 reset_staging(dir.path().to_str().unwrap()).unwrap();
1880 assert_eq!(
1881 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap())
1882 .unwrap()
1883 .result,
1884 StageResult::Staged
1885 );
1886 run_git(&dir, &["commit", "-m", "first"]);
1887 write_file(&dir, "Dockerfile", "FROM scratch\n");
1888
1889 assert_eq!(
1890 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap())
1891 .unwrap()
1892 .result,
1893 StageResult::Staged
1894 );
1895 let staged = staged_diff(&dir);
1896 assert!(staged.contains("b_changed"));
1897 assert!(!staged.contains("Dockerfile"));
1898 run_git(&dir, &["commit", "-m", "second"]);
1899
1900 assert!(
1901 get_compose_diff(dir.path().to_str().unwrap())
1902 .unwrap()
1903 .contains("Dockerfile")
1904 );
1905 }
1906
1907 #[test]
1908 fn test_stage_executable_group_ignores_same_file_local_edit_between_commits() {
1909 let dir = init_repo();
1910 write_file(&dir, "src/lib.rs", &fixture_file_original());
1911 commit_all(&dir, "initial");
1912 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
1913
1914 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1915 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1916 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1917 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1918 let first_group = ComposeExecutableGroup {
1919 group_id: "G1".to_string(),
1920 commit_type: CommitType::new("refactor").unwrap(),
1921 scope: None,
1922 file_ids: vec![source_file.file_id.clone()],
1923 rationale: "first hunk".to_string(),
1924 dependencies: vec![],
1925 hunk_ids: vec![source_file.hunk_ids[0].clone()],
1926 };
1927 let second_group = ComposeExecutableGroup {
1928 group_id: "G2".to_string(),
1929 commit_type: CommitType::new("refactor").unwrap(),
1930 scope: None,
1931 file_ids: vec![source_file.file_id.clone()],
1932 rationale: "second hunk".to_string(),
1933 dependencies: vec![],
1934 hunk_ids: vec![source_file.hunk_ids[1].clone()],
1935 };
1936
1937 reset_staging(dir.path().to_str().unwrap()).unwrap();
1938 stage_executable_group(&snapshot, &first_group, dir.path().to_str().unwrap()).unwrap();
1939 run_git(&dir, &["commit", "-m", "first"]);
1940 write_file(
1941 &dir,
1942 "src/lib.rs",
1943 &fixture_file_two_hunks().replace("// spacer 4", "// local edit"),
1944 );
1945
1946 stage_executable_group(&snapshot, &second_group, dir.path().to_str().unwrap()).unwrap();
1947 let staged = staged_diff(&dir);
1948 assert!(staged.contains("beta changed"));
1949 assert!(!staged.contains("local edit"));
1950 }
1951
1952 #[test]
1953 fn test_stage_executable_group_noops_when_snapshot_patch_already_applied() {
1954 let dir = init_repo();
1955 write_file(&dir, "src/lib.rs", &fixture_file_original());
1956 commit_all(&dir, "initial");
1957 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
1958
1959 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1960 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1961 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1962 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
1963 let group = ComposeExecutableGroup {
1964 group_id: "G1".to_string(),
1965 commit_type: CommitType::new("refactor").unwrap(),
1966 scope: None,
1967 file_ids: vec![source_file.file_id.clone()],
1968 rationale: "all hunks".to_string(),
1969 dependencies: vec![],
1970 hunk_ids: source_file.hunk_ids.clone(),
1971 };
1972
1973 reset_staging(dir.path().to_str().unwrap()).unwrap();
1974 let first_result =
1975 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1976 assert_eq!(first_result.result, StageResult::Staged);
1977 run_git(&dir, &["commit", "-m", "applied"]);
1978
1979 let second_result =
1982 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
1983 assert_eq!(second_result.result, StageResult::Staged);
1984 assert!(staged_diff(&dir).trim().is_empty());
1985 }
1986
1987 #[test]
1988 fn test_stage_executable_group_reuses_snapshot_patch_not_worktree_contents() {
1989 let dir = init_repo();
1990 write_file(&dir, "README.md", "initial\n");
1991 commit_all(&dir, "initial");
1992 write_file(&dir, "notes.txt", "planned\n");
1993
1994 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
1995 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
1996 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
1997 let notes_file = snapshot.file_by_path("notes.txt").unwrap();
1998 let group = ComposeExecutableGroup {
1999 group_id: "G1".to_string(),
2000 commit_type: CommitType::new("docs").unwrap(),
2001 scope: None,
2002 file_ids: vec![notes_file.file_id.clone()],
2003 rationale: "new notes".to_string(),
2004 dependencies: vec![],
2005 hunk_ids: notes_file.hunk_ids.clone(),
2006 };
2007
2008 reset_staging(dir.path().to_str().unwrap()).unwrap();
2009 let planned_result =
2010 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2011 assert_eq!(planned_result.result, StageResult::Staged);
2012 let planned_staged = staged_diff(&dir);
2013 assert!(planned_staged.contains("+planned"));
2014 assert!(!planned_staged.contains("local edit"));
2015
2016 reset_staging(dir.path().to_str().unwrap()).unwrap();
2017 write_file(&dir, "notes.txt", "planned\nlocal edit\n");
2018 let reused_result =
2019 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2020 assert_eq!(reused_result.result, StageResult::Staged);
2021 let reused_staged = staged_diff(&dir);
2022
2023 assert_eq!(reused_staged, planned_staged);
2024 assert!(!reused_staged.contains("local edit"));
2025 }
2026
2027 #[test]
2028 fn test_stage_executable_group_materializes_new_file_from_snapshot() {
2029 let dir = init_repo();
2030 write_file(&dir, "README.md", "initial\n");
2031 commit_all(&dir, "initial");
2032
2033 let diff = r"diff --git a/notes.txt b/notes.txt
2034new file mode 100644
2035index 0000000..0000000
2036--- /dev/null
2037+++ b/notes.txt
2038@@ -1,1 +1,3 @@
2039-old
2040+old
2041+new
2042+++literal plus
2043";
2044 let stat = " notes.txt | 4 +++-\n";
2045 let snapshot = build_compose_snapshot(diff, stat).unwrap();
2046 let notes_file = snapshot.file_by_path("notes.txt").unwrap();
2047 let group = ComposeExecutableGroup {
2048 group_id: "G1".to_string(),
2049 commit_type: CommitType::new("docs").unwrap(),
2050 scope: None,
2051 file_ids: vec![notes_file.file_id.clone()],
2052 rationale: "new notes".to_string(),
2053 dependencies: vec![],
2054 hunk_ids: notes_file.hunk_ids.clone(),
2055 };
2056
2057 write_file(&dir, "notes.txt", "worktree edit\n");
2058 reset_staging(dir.path().to_str().unwrap()).unwrap();
2059 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2060
2061 assert_eq!(result.result, StageResult::Staged);
2062 let staged = staged_diff(&dir);
2063 assert!(staged.contains("+old"));
2064 assert!(staged.contains("+new"));
2065 assert!(staged.contains("+++literal plus"));
2066 assert!(!staged.contains("worktree edit"));
2067 let second_result =
2068 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2069 assert_eq!(second_result.result, StageResult::AlreadyApplied);
2070 }
2071
2072 #[test]
2073 fn test_stage_executable_group_materializes_empty_new_file_from_snapshot() {
2074 let dir = init_repo();
2075 write_file(&dir, "README.md", "initial\n");
2076 commit_all(&dir, "initial");
2077
2078 let diff = r"diff --git a/empty.txt b/empty.txt
2079new file mode 100644
2080index 0000000..0000000
2081--- /dev/null
2082+++ b/empty.txt
2083";
2084 let stat = " empty.txt | 0\n";
2085 let snapshot = build_compose_snapshot(diff, stat).unwrap();
2086 let empty_file = snapshot.file_by_path("empty.txt").unwrap();
2087 let group = ComposeExecutableGroup {
2088 group_id: "G1".to_string(),
2089 commit_type: CommitType::new("docs").unwrap(),
2090 scope: None,
2091 file_ids: vec![empty_file.file_id.clone()],
2092 rationale: "empty notes".to_string(),
2093 dependencies: vec![],
2094 hunk_ids: empty_file.hunk_ids.clone(),
2095 };
2096
2097 write_file(&dir, "empty.txt", "worktree edit\n");
2098 reset_staging(dir.path().to_str().unwrap()).unwrap();
2099 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2100
2101 assert_eq!(result.result, StageResult::Staged);
2102 let staged = staged_diff(&dir);
2103 assert!(staged.contains("new file mode 100644"));
2104 assert!(!staged.contains("worktree edit"));
2105 }
2106
2107 #[test]
2108 fn test_stage_executable_group_materializes_new_gitlink_from_snapshot() {
2109 let dir = init_repo();
2110 write_file(&dir, "README.md", "initial\n");
2111 commit_all(&dir, "initial");
2112
2113 let oid = "1234567890abcdef1234567890abcdef12345678";
2114 let diff = format!(
2115 "diff --git a/vendor/lib b/vendor/lib\nnew file mode 160000\nindex 0000000..{oid}\n--- \
2116 /dev/null\n+++ b/vendor/lib\n@@ -0,0 +1 @@\n+Subproject commit {oid}\n"
2117 );
2118 let stat = " vendor/lib | 1 +\n";
2119 let snapshot = build_compose_snapshot(&diff, stat).unwrap();
2120 let gitlink_file = snapshot.file_by_path("vendor/lib").unwrap();
2121 let group = ComposeExecutableGroup {
2122 group_id: "G1".to_string(),
2123 commit_type: CommitType::new("chore").unwrap(),
2124 scope: None,
2125 file_ids: vec![gitlink_file.file_id.clone()],
2126 rationale: "add submodule".to_string(),
2127 dependencies: vec![],
2128 hunk_ids: gitlink_file.hunk_ids.clone(),
2129 };
2130
2131 reset_staging(dir.path().to_str().unwrap()).unwrap();
2132 let result = stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2133
2134 assert_eq!(result.result, StageResult::Staged);
2135 let staged = staged_diff(&dir);
2136 assert!(staged.contains("new file mode 160000"));
2137 assert!(staged.contains(&format!("+Subproject commit {oid}")));
2138 }
2139
2140 #[test]
2141 fn test_stage_executable_group_skips_file_whose_patch_no_longer_applies() {
2142 let dir = init_repo();
2143 write_file(&dir, "src/a.rs", &fixture_file_original());
2144 write_file(&dir, "src/b.rs", "fn b() {}\n");
2145 commit_all(&dir, "initial");
2146
2147 write_file(&dir, "src/a.rs", &fixture_file_two_hunks());
2148 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
2149
2150 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2151 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2152 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2153 let a_file = snapshot.file_by_path("src/a.rs").unwrap();
2154 let b_file = snapshot.file_by_path("src/b.rs").unwrap();
2155 let group = ComposeExecutableGroup {
2156 group_id: "G1".to_string(),
2157 commit_type: CommitType::new("refactor").unwrap(),
2158 scope: None,
2159 file_ids: vec![a_file.file_id.clone(), b_file.file_id.clone()],
2160 rationale: "both files".to_string(),
2161 dependencies: vec![],
2162 hunk_ids: std::iter::once(a_file.hunk_ids[0].clone())
2165 .chain(b_file.hunk_ids.iter().cloned())
2166 .collect(),
2167 };
2168
2169 write_file(&dir, "src/a.rs", &fixture_file_original().replace("alpha", "alpha diverged"));
2172 run_git(&dir, &["add", "src/a.rs"]);
2173 run_git(&dir, &["commit", "-m", "diverge a"]);
2174
2175 reset_staging(dir.path().to_str().unwrap()).unwrap();
2176 write_file(&dir, "src/b.rs", "fn b_changed() {}\n");
2177
2178 let outcome =
2179 stage_executable_group(&snapshot, &group, dir.path().to_str().unwrap()).unwrap();
2180
2181 assert_eq!(outcome.result, StageResult::Staged);
2183 assert_eq!(outcome.skipped.len(), 1);
2184 assert_eq!(outcome.skipped[0].path, "src/a.rs");
2185
2186 let staged = staged_diff(&dir);
2187 assert!(staged.contains("b_changed"));
2188 assert!(!staged.contains("alpha changed"));
2189 assert!(!staged.contains("src/a.rs"));
2191 }
2192
2193 #[test]
2194 fn test_covers_all_modified_file_routes_to_git_add() {
2195 let dir = init_repo();
2196 write_file(&dir, "src/lib.rs", &fixture_file_original());
2197 commit_all(&dir, "initial");
2198 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2199
2200 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2201 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2202 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2203 let file = snapshot.file_by_path("src/lib.rs").unwrap();
2204 let group = ComposeExecutableGroup {
2205 group_id: "G1".to_string(),
2206 commit_type: CommitType::new("refactor").unwrap(),
2207 scope: None,
2208 file_ids: vec![file.file_id.clone()],
2209 rationale: "all hunks".to_string(),
2210 dependencies: vec![],
2211 hunk_ids: file.hunk_ids.clone(),
2212 };
2213
2214 let group_patch = create_executable_group_patch(&snapshot, &group).unwrap();
2215 assert!(group_patch.apply_patches.is_empty());
2217 assert_eq!(group_patch.fallback_files, vec!["src/lib.rs".to_string()]);
2218 }
2219
2220 #[test]
2221 fn test_stage_executable_group_in_index_stages_crlf_file_via_git_add() {
2222 let dir = init_repo();
2223 run_git(&dir, &["config", "core.autocrlf", "false"]);
2224 let original = [
2225 "fn alpha() {",
2226 " println!(\"alpha\");",
2227 "}",
2228 "",
2229 "// spacer 1",
2230 "// spacer 2",
2231 "// spacer 3",
2232 "// spacer 4",
2233 "fn beta() {",
2234 " println!(\"beta\");",
2235 "}",
2236 "",
2237 ]
2238 .join("\r\n");
2239 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2240 write_file(&dir, "src/crlf.rs", &original);
2241 commit_all(&dir, "initial");
2242 write_file(&dir, "src/crlf.rs", &modified);
2243
2244 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2245 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2246 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2247 let file = snapshot.file_by_path("src/crlf.rs").unwrap();
2248 let group = ComposeExecutableGroup {
2249 group_id: "G1".to_string(),
2250 commit_type: CommitType::new("fix").unwrap(),
2251 scope: None,
2252 file_ids: vec![file.file_id.clone()],
2253 rationale: "crlf change".to_string(),
2254 dependencies: vec![],
2255 hunk_ids: file.hunk_ids.clone(),
2256 };
2257
2258 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2259 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2260 let outcome = stage_executable_group_in_index(
2261 &snapshot,
2262 &group,
2263 dir.path().to_str().unwrap(),
2264 index.path(),
2265 )
2266 .unwrap();
2267 assert!(outcome.skipped.is_empty());
2268
2269 let staged = crate::git::git_command_with_index(index.path())
2270 .args(["show", ":src/crlf.rs"])
2271 .current_dir(dir.path())
2272 .output()
2273 .unwrap();
2274 assert!(staged.status.success());
2275 assert_eq!(String::from_utf8_lossy(&staged.stdout), modified);
2277 }
2278
2279 #[test]
2280 fn test_force_stage_splice_partial_crlf_preserves_eol() {
2281 let dir = init_repo();
2282 run_git(&dir, &["config", "core.autocrlf", "false"]);
2283 let original = [
2284 "fn alpha() {",
2285 " println!(\"alpha\");",
2286 "}",
2287 "",
2288 "// spacer 1",
2289 "// spacer 2",
2290 "// spacer 3",
2291 "// spacer 4",
2292 "// spacer 5",
2293 "// spacer 6",
2294 "fn beta() {",
2295 " println!(\"beta\");",
2296 "}",
2297 "",
2298 ]
2299 .join("\r\n");
2300 let modified = original
2302 .replace("println!(\"alpha\")", "println!(\"alpha changed\")")
2303 .replace("println!(\"beta\")", "println!(\"beta changed\")");
2304 write_file(&dir, "src/crlf.rs", &original);
2305 commit_all(&dir, "initial");
2306 write_file(&dir, "src/crlf.rs", &modified);
2307
2308 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2309 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2310 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2311 let file = snapshot.file_by_path("src/crlf.rs").unwrap();
2312 assert!(file.hunk_ids.len() >= 2, "need at least two hunks for a partial test");
2313
2314 let first_hunk = vec![file.hunk_ids[0].clone()];
2316 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2317 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2318 force_stage_file_from_base_in_index(
2319 &snapshot,
2320 &file.file_id,
2321 &first_hunk,
2322 dir.path().to_str().unwrap(),
2323 index.path(),
2324 )
2325 .unwrap();
2326
2327 let staged = crate::git::git_command_with_index(index.path())
2328 .args(["show", ":src/crlf.rs"])
2329 .current_dir(dir.path())
2330 .output()
2331 .unwrap();
2332 let staged = String::from_utf8_lossy(&staged.stdout).to_string();
2333 let expected = original.replace("println!(\"alpha\")", "println!(\"alpha changed\")");
2334 assert_eq!(staged, expected);
2335 assert!(staged.contains("println!(\"alpha changed\");\r\n"));
2337 assert!(!staged.contains("beta changed"));
2338 assert!(staged.contains("println!(\"beta\");\r\n"));
2339 assert!(!staged.contains("\r\r"));
2340 }
2341
2342 #[test]
2343 fn test_splice_hunks_unit_lf_and_crlf() {
2344 use crate::compose_types::ComposeHunk;
2346 fn hunk(old_start: usize, raw: &str) -> ComposeHunk {
2347 ComposeHunk {
2348 hunk_id: "H".to_string(),
2349 file_id: "F".to_string(),
2350 path: "f".to_string(),
2351 old_start,
2352 old_count: 0,
2353 new_start: 0,
2354 new_count: 0,
2355 header: String::new(),
2356 raw_patch: raw.to_string(),
2357 snippet: String::new(),
2358 semantic_key: String::new(),
2359 synthetic: false,
2360 }
2361 }
2362 let base = b"a\nb\nc\n";
2364 let h = hunk(1, "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n");
2365 assert_eq!(splice_hunks_into_base(base, &[&h]), b"a\nB\nc\n");
2366
2367 let base_cr = b"a\r\nb\r\nc\r\n";
2369 let h_cr = hunk(1, "@@ -1,3 +1,3 @@\n a\r\n-b\r\n+B\r\n c\r\n");
2370 assert_eq!(splice_hunks_into_base(base_cr, &[&h_cr]), b"a\r\nB\r\nc\r\n");
2371
2372 let base2 = b"a\nb\nc\n";
2374 let h2 = hunk(3, "@@ -3 +3 @@\n-c\n+c2\n\\ No newline at end of file\n");
2375 assert_eq!(splice_hunks_into_base(base2, &[&h2]), b"a\nb\nc2");
2376 }
2377
2378 #[test]
2379 fn test_stage_executable_group_in_index_preserves_real_staged_diff() {
2380 let dir = init_repo();
2381 write_file(&dir, "src/lib.rs", &fixture_file_original());
2382 write_file(&dir, "sentinel.txt", "base\n");
2383 commit_all(&dir, "initial");
2384 write_file(&dir, "src/lib.rs", &fixture_file_stage_only());
2385
2386 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2387 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2388 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2389 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2390 let group = ComposeExecutableGroup {
2391 group_id: "G1".to_string(),
2392 commit_type: CommitType::new("refactor").unwrap(),
2393 scope: None,
2394 file_ids: vec![source_file.file_id.clone()],
2395 rationale: "source change".to_string(),
2396 dependencies: vec![],
2397 hunk_ids: source_file.hunk_ids.clone(),
2398 };
2399
2400 write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2401 run_git(&dir, &["add", "sentinel.txt"]);
2402 let real_staged_before = staged_diff(&dir);
2403 assert!(real_staged_before.contains("staged sentinel"));
2404
2405 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2406 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2407 let outcome = stage_executable_group_in_index(
2408 &snapshot,
2409 &group,
2410 dir.path().to_str().unwrap(),
2411 index.path(),
2412 )
2413 .unwrap();
2414
2415 assert_eq!(outcome.result, StageResult::Staged);
2416 assert_eq!(staged_diff(&dir), real_staged_before);
2417 let temp_staged = staged_diff_in_index(&dir, &index);
2418 assert!(temp_staged.contains("alpha staged"));
2419 assert!(!temp_staged.contains("staged sentinel"));
2420 }
2421
2422 #[test]
2423 fn test_force_stage_file_from_base_in_index_preserves_real_staged_diff() {
2424 let dir = init_repo();
2425 run_git(&dir, &["config", "core.autocrlf", "false"]);
2426 let original = [
2427 "fn alpha() {",
2428 " println!(\"alpha\");",
2429 "}",
2430 "",
2431 "fn beta() {",
2432 " println!(\"beta\");",
2433 "}",
2434 "",
2435 ]
2436 .join("\r\n");
2437 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2438 write_file(&dir, "src/crlf.rs", &original);
2439 write_file(&dir, "sentinel.txt", "base\n");
2440 commit_all(&dir, "initial");
2441 write_file(&dir, "src/crlf.rs", &modified);
2442
2443 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2444 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2445 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2446 let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2447
2448 write_file(&dir, "sentinel.txt", "base\nstaged sentinel\n");
2449 run_git(&dir, &["add", "sentinel.txt"]);
2450 let real_staged_before = staged_diff(&dir);
2451
2452 let index = TempGitIndex::new(dir.path().to_str().unwrap()).unwrap();
2453 read_tree_into_index(index.path(), "HEAD", dir.path().to_str().unwrap()).unwrap();
2454 force_stage_file_from_base_in_index(
2455 &snapshot,
2456 &source_file.file_id,
2457 &source_file.hunk_ids.clone(),
2458 dir.path().to_str().unwrap(),
2459 index.path(),
2460 )
2461 .unwrap();
2462
2463 assert_eq!(staged_diff(&dir), real_staged_before);
2464 let staged_blob = crate::git::git_command_with_index(index.path())
2465 .args(["show", ":src/crlf.rs"])
2466 .current_dir(dir.path())
2467 .output()
2468 .unwrap();
2469 assert!(staged_blob.status.success());
2470 assert_eq!(String::from_utf8_lossy(&staged_blob.stdout).to_string(), modified);
2471 }
2472
2473 #[test]
2474 fn test_force_stage_file_from_base_preserves_crlf_patch_lines() {
2475 let dir = init_repo();
2476 run_git(&dir, &["config", "core.autocrlf", "false"]);
2477 let original = [
2478 "fn alpha() {",
2479 " println!(\"alpha\");",
2480 "}",
2481 "",
2482 "fn beta() {",
2483 " println!(\"beta\");",
2484 "}",
2485 "",
2486 ]
2487 .join("\r\n");
2488 let modified = original.replace("println!(\"beta\")", "println!(\"beta changed\")");
2489 write_file(&dir, "src/crlf.rs", &original);
2490 commit_all(&dir, "initial");
2491 write_file(&dir, "src/crlf.rs", &modified);
2492
2493 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2494 assert!(diff.contains("- println!(\"beta\");\r\n"));
2495 assert!(diff.contains("+ println!(\"beta changed\");\r\n"));
2496 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2497 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2498 let source_file = snapshot.file_by_path("src/crlf.rs").unwrap();
2499
2500 reset_staging(dir.path().to_str().unwrap()).unwrap();
2501 force_stage_file_from_base(
2502 &snapshot,
2503 &source_file.file_id,
2504 &source_file.hunk_ids.clone(),
2505 dir.path().to_str().unwrap(),
2506 )
2507 .unwrap();
2508
2509 let staged_blob = run_git(&dir, &["show", ":src/crlf.rs"]);
2510 assert_eq!(staged_blob, modified);
2511 }
2512 #[test]
2513 fn test_force_stage_file_from_base_ignores_index_drift() {
2514 let dir = init_repo();
2515 write_file(&dir, "src/lib.rs", &fixture_file_original());
2516 commit_all(&dir, "initial");
2517 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2518
2519 let diff = get_compose_diff(dir.path().to_str().unwrap()).unwrap();
2520 let stat = get_compose_stat(dir.path().to_str().unwrap()).unwrap();
2521 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2522 let source_file = snapshot.file_by_path("src/lib.rs").unwrap();
2523 assert_eq!(source_file.hunk_ids.len(), 2);
2524
2525 write_file(&dir, "src/lib.rs", "fn totally_different() {}\n");
2528 run_git(&dir, &["add", "src/lib.rs"]);
2529
2530 force_stage_file_from_base(
2532 &snapshot,
2533 &source_file.file_id,
2534 &[source_file.hunk_ids[0].clone()],
2535 dir.path().to_str().unwrap(),
2536 )
2537 .unwrap();
2538
2539 let staged = staged_diff(&dir);
2540 assert!(staged.contains("alpha changed"));
2541 assert!(!staged.contains("beta changed"));
2542 assert!(!staged.contains("totally_different"));
2543
2544 force_stage_file_from_base(
2546 &snapshot,
2547 &source_file.file_id,
2548 &source_file.hunk_ids.clone(),
2549 dir.path().to_str().unwrap(),
2550 )
2551 .unwrap();
2552 let staged = staged_diff(&dir);
2553 assert!(staged.contains("alpha changed"));
2554 assert!(staged.contains("beta changed"));
2555 assert!(!staged.contains("totally_different"));
2556 }
2557
2558 #[test]
2559 fn test_force_stage_split_across_commits_leaves_worktree_clean() {
2560 let dir = init_repo();
2561 write_file(&dir, "src/lib.rs", &fixture_file_original());
2562 commit_all(&dir, "initial");
2563 write_file(&dir, "src/lib.rs", &fixture_file_two_hunks());
2565
2566 let dirs = dir.path().to_str().unwrap();
2567 let diff = get_compose_diff(dirs).unwrap();
2568 let stat = get_compose_stat(dirs).unwrap();
2569 let snapshot = build_compose_snapshot(&diff, &stat).unwrap();
2570 let file = snapshot.file_by_path("src/lib.rs").unwrap();
2571 assert_eq!(file.hunk_ids.len(), 2);
2572
2573 reset_staging(dirs).unwrap();
2574
2575 force_stage_file_from_base(&snapshot, &file.file_id, &[file.hunk_ids[0].clone()], dirs)
2577 .unwrap();
2578 run_git(&dir, &["commit", "-m", "first"]);
2579
2580 force_stage_file_from_base(&snapshot, &file.file_id, &file.hunk_ids.clone(), dirs).unwrap();
2582 run_git(&dir, &["commit", "-m", "second"]);
2583
2584 let status = run_git(&dir, &["status", "--porcelain"]);
2587 assert!(status.trim().is_empty(), "working tree should be clean, got: {status:?}");
2588 }
2589}