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