1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
12pub struct HistoryEntry {
13 pub id: usize,
14 pub hash: String,
15 pub subject: String,
16 pub operation: String,
17 pub target: String,
18 pub files: Vec<String>,
19 pub message: Option<String>,
20 pub workflow: Option<String>,
21 pub git_head: String,
22 pub timestamp: String,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
27#[serde(default)]
28pub struct ShadowConfig {
29 pub enabled: Option<bool>,
31 pub warn_on_delete: Option<bool>,
33}
34
35impl ShadowConfig {
36 pub fn enabled(&self) -> bool {
37 self.enabled.unwrap_or(true)
38 }
39
40 pub fn warn_on_delete(&self) -> bool {
41 self.warn_on_delete.unwrap_or(true)
42 }
43}
44
45pub struct EditInfo {
47 pub operation: String,
48 pub target: String,
49 pub files: Vec<PathBuf>,
50 pub message: Option<String>,
51 pub workflow: Option<String>,
52}
53
54pub struct ValidationResult {
56 pub success: bool,
57 pub exit_code: Option<i32>,
58 pub stdout: String,
59 pub stderr: String,
60}
61
62pub struct Shadow {
64 root: PathBuf,
66 shadow_dir: PathBuf,
68 worktree: PathBuf,
70}
71
72impl Shadow {
73 pub fn new(root: &Path) -> Self {
75 let shadow_dir = root.join(".normalize").join("shadow");
76 let worktree = shadow_dir.join("worktree");
77 Self {
78 root: root.to_path_buf(),
79 shadow_dir,
80 worktree,
81 }
82 }
83
84 pub fn exists(&self) -> bool {
86 self.shadow_dir.join(".git").exists()
87 }
88
89 fn init(&self) -> Result<(), ShadowError> {
92 if self.exists() {
93 return Ok(());
94 }
95
96 std::fs::create_dir_all(&self.worktree)
98 .map_err(|e| ShadowError::Init(format!("Failed to create shadow directory: {}", e)))?;
99
100 let status = Command::new("git")
103 .args([
104 "init",
105 "--quiet",
106 &format!(
107 "--separate-git-dir={}",
108 self.shadow_dir.join(".git").display()
109 ),
110 ])
111 .current_dir(&self.worktree)
112 .status()
113 .map_err(|e| ShadowError::Init(format!("Failed to run git init: {}", e)))?;
114
115 if !status.success() {
116 return Err(ShadowError::Init("git init failed".to_string()));
117 }
118
119 let _ = Command::new("git")
121 .args(["config", "user.email", "shadow@normalize.local"])
122 .current_dir(&self.worktree)
123 .status();
124 let _ = Command::new("git")
125 .args(["config", "user.name", "Normalize Shadow"])
126 .current_dir(&self.worktree)
127 .status();
128
129 Ok(())
130 }
131
132 fn get_real_git_head(&self) -> Option<String> {
134 let output = Command::new("git")
135 .args(["rev-parse", "--short", "HEAD"])
136 .current_dir(&self.root)
137 .output()
138 .ok()?;
139
140 if output.status.success() {
141 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
142 } else {
143 None
144 }
145 }
146
147 fn copy_to_worktree(&self, file: &Path) -> Result<PathBuf, ShadowError> {
149 let rel_path = file
150 .strip_prefix(&self.root)
151 .map_err(|_| ShadowError::Commit("File not under project root".to_string()))?;
152
153 let dest = self.worktree.join(rel_path);
154
155 if let Some(parent) = dest.parent() {
157 std::fs::create_dir_all(parent)
158 .map_err(|e| ShadowError::Commit(format!("Failed to create directories: {}", e)))?;
159 }
160
161 std::fs::copy(file, &dest)
163 .map_err(|e| ShadowError::Commit(format!("Failed to copy file: {}", e)))?;
164
165 Ok(rel_path.to_path_buf())
166 }
167
168 pub fn before_edit(&self, files: &[&Path]) -> Result<(), ShadowError> {
171 self.init()?;
172
173 for file in files {
174 if file.exists() {
175 self.copy_to_worktree(file)?;
176 }
177 }
178
179 Ok(())
180 }
181
182 pub fn after_edit(&self, info: &EditInfo) -> Result<(), ShadowError> {
185 for file in &info.files {
187 if file.exists() {
188 self.copy_to_worktree(file)?;
189 }
190 }
191
192 let status = Command::new("git")
194 .args(["add", "-A"])
195 .current_dir(&self.worktree)
196 .status()
197 .map_err(|e| ShadowError::Commit(format!("Failed to stage changes: {}", e)))?;
198
199 if !status.success() {
200 return Err(ShadowError::Commit("git add failed".to_string()));
201 }
202
203 let status = Command::new("git")
205 .args(["diff", "--cached", "--quiet"])
206 .current_dir(&self.worktree)
207 .status()
208 .map_err(|e| ShadowError::Commit(format!("Failed to check diff: {}", e)))?;
209
210 if status.success() {
211 return Ok(());
213 }
214
215 let git_head = self
217 .get_real_git_head()
218 .unwrap_or_else(|| "none".to_string());
219 let files_str: Vec<String> = info
220 .files
221 .iter()
222 .filter_map(|f| f.strip_prefix(&self.root).ok())
223 .map(|p| p.display().to_string())
224 .collect();
225
226 let mut commit_msg = format!("normalize edit: {} {}\n\n", info.operation, info.target);
227
228 if let Some(ref msg) = info.message {
229 commit_msg.push_str(&format!("Message: {}\n", msg));
230 }
231 if let Some(ref wf) = info.workflow {
232 commit_msg.push_str(&format!("Workflow: {}\n", wf));
233 }
234 commit_msg.push_str(&format!("Operation: {}\n", info.operation));
235 commit_msg.push_str(&format!("Target: {}\n", info.target));
236 commit_msg.push_str(&format!("Files: {}\n", files_str.join(", ")));
237 commit_msg.push_str(&format!("Git-HEAD: {}\n", git_head));
238
239 let status = Command::new("git")
241 .args(["commit", "-m", &commit_msg])
242 .current_dir(&self.worktree)
243 .status()
244 .map_err(|e| ShadowError::Commit(format!("Failed to commit: {}", e)))?;
245
246 if !status.success() {
247 return Err(ShadowError::Commit("git commit failed".to_string()));
248 }
249
250 Ok(())
251 }
252
253 pub fn history(&self, file_filter: Option<&str>, limit: usize) -> Vec<HistoryEntry> {
256 if !self.exists() {
257 return Vec::new();
258 }
259
260 let mut args = vec![
263 "log".to_string(),
264 "--format=%H%x1f%s%x1f%b%x1f%aI%x1e".to_string(),
265 format!("-{}", limit),
266 ];
267
268 if let Some(file) = file_filter {
270 args.push("--".to_string());
271 args.push(file.to_string());
272 }
273
274 let output = Command::new("git")
275 .args(&args)
276 .current_dir(&self.worktree)
277 .output();
278
279 let output = match output {
280 Ok(out) if out.status.success() => out,
281 _ => return Vec::new(),
282 };
283
284 let stdout = String::from_utf8_lossy(&output.stdout);
285 let mut entries = Vec::new();
286
287 let blocks: Vec<&str> = stdout
289 .split('\x1e')
290 .filter(|b| !b.trim().is_empty())
291 .collect();
292 let total = blocks.len();
293
294 for (idx, block) in blocks.into_iter().enumerate() {
295 let parts: Vec<&str> = block.split('\x1f').collect();
297 if parts.len() < 4 {
298 continue;
299 }
300
301 let hash = parts[0].trim();
302 let subject = parts[1].trim();
303 let body = parts[2].trim();
304 let timestamp = parts[3].trim();
305
306 let mut operation = String::new();
308 let mut target = String::new();
309 let mut files = Vec::new();
310 let mut message = None;
311 let mut workflow = None;
312 let mut git_head = String::new();
313
314 for line in body.lines() {
315 if let Some(val) = line.strip_prefix("Operation: ") {
316 operation = val.to_string();
317 } else if let Some(val) = line.strip_prefix("Target: ") {
318 target = val.to_string();
319 } else if let Some(val) = line.strip_prefix("Files: ") {
320 files = val.split(", ").map(String::from).collect();
321 } else if let Some(val) = line.strip_prefix("Message: ") {
322 message = Some(val.to_string());
323 } else if let Some(val) = line.strip_prefix("Workflow: ") {
324 workflow = Some(val.to_string());
325 } else if let Some(val) = line.strip_prefix("Git-HEAD: ") {
326 git_head = val.to_string();
327 }
328 }
329
330 entries.push(HistoryEntry {
331 id: total - idx, hash: hash.to_string(),
333 subject: subject.to_string(),
334 operation,
335 target,
336 files,
337 message,
338 workflow,
339 git_head,
340 timestamp: timestamp.to_string(),
341 });
342 }
343
344 entries
345 }
346
347 pub fn diff(&self, commit_ref: &str) -> Option<String> {
349 if !self.exists() {
350 return None;
351 }
352
353 let output = Command::new("git")
354 .args(["show", "--format=", commit_ref])
355 .current_dir(&self.worktree)
356 .output()
357 .ok()?;
358
359 if output.status.success() {
360 Some(String::from_utf8_lossy(&output.stdout).to_string())
361 } else {
362 None
363 }
364 }
365
366 pub fn tree(&self, limit: usize) -> Option<String> {
368 if !self.exists() {
369 return None;
370 }
371
372 let output = Command::new("git")
373 .args([
374 "log",
375 "--graph",
376 "--all",
377 "--oneline",
378 "--decorate",
379 &format!("-{}", limit),
380 ])
381 .current_dir(&self.worktree)
382 .output()
383 .ok()?;
384
385 if output.status.success() {
386 Some(String::from_utf8_lossy(&output.stdout).to_string())
387 } else {
388 None
389 }
390 }
391
392 pub fn checkpoint(&self) -> Option<String> {
394 self.history(None, 1)
395 .first()
396 .map(|e| e.git_head.clone())
397 .filter(|h| h != "none")
398 }
399
400 pub fn validate(&self, cmd: &str, args: &[&str]) -> Result<ValidationResult, ShadowError> {
404 if !self.exists() {
405 return Err(ShadowError::Init("No shadow worktree exists".to_string()));
406 }
407
408 let output = Command::new(cmd)
409 .args(args)
410 .current_dir(&self.worktree)
411 .output()
412 .map_err(|e| ShadowError::Validation {
413 message: format!("Failed to run {}: {}", cmd, e),
414 exit_code: -1,
415 })?;
416
417 Ok(ValidationResult {
418 success: output.status.success(),
419 exit_code: output.status.code(),
420 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
421 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
422 })
423 }
424
425 pub fn apply_to_real(&self) -> Result<Vec<PathBuf>, ShadowError> {
429 if !self.exists() {
430 return Err(ShadowError::Init("No shadow worktree exists".to_string()));
431 }
432
433 let output = Command::new("git")
435 .args(["diff", "--name-only", "HEAD~1", "HEAD"])
436 .current_dir(&self.worktree)
437 .output()
438 .map_err(|e| ShadowError::Validation {
439 message: format!("git diff failed: {}", e),
440 exit_code: -1,
441 })?;
442
443 if !output.status.success() {
444 return Err(ShadowError::Validation {
445 message: "Failed to get changed files".to_string(),
446 exit_code: -1,
447 });
448 }
449
450 let files: Vec<PathBuf> = String::from_utf8_lossy(&output.stdout)
451 .lines()
452 .map(|l| self.root.join(l))
453 .collect();
454
455 for file in &files {
457 let rel = file.strip_prefix(&self.root).unwrap_or(file.as_path());
458 let shadow_file = self.worktree.join(rel);
459 if shadow_file.exists() {
460 if let Some(parent) = file.parent() {
461 std::fs::create_dir_all(parent).map_err(|e| ShadowError::Validation {
462 message: format!("mkdir failed: {}", e),
463 exit_code: -1,
464 })?;
465 }
466 std::fs::copy(&shadow_file, file).map_err(|e| ShadowError::Validation {
467 message: format!("copy failed: {}", e),
468 exit_code: -1,
469 })?;
470 }
471 }
472
473 Ok(files)
474 }
475
476 pub fn edit_count(&self) -> usize {
478 if !self.exists() {
479 return 0;
480 }
481
482 let output = Command::new("git")
483 .args(["rev-list", "--count", "HEAD"])
484 .current_dir(&self.worktree)
485 .output();
486
487 match output {
488 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
489 .trim()
490 .parse()
491 .unwrap_or(0),
492 _ => 0,
493 }
494 }
495
496 pub fn prune(&self, keep: usize) -> Result<usize, ShadowError> {
499 if !self.exists() {
500 return Err(ShadowError::Init("No shadow history exists".to_string()));
501 }
502
503 let total = self.edit_count();
504 if total <= keep {
505 return Ok(0);
506 }
507
508 let to_prune = total - keep;
509
510 let new_root_output = Command::new("git")
512 .args(["rev-parse", &format!("HEAD~{}", keep - 1)])
513 .current_dir(&self.worktree)
514 .output()
515 .map_err(|e| ShadowError::Init(format!("Failed to find root commit: {}", e)))?;
516
517 if !new_root_output.status.success() {
518 return Err(ShadowError::Init(
519 "Failed to find commit to keep".to_string(),
520 ));
521 }
522
523 let new_root = String::from_utf8_lossy(&new_root_output.stdout)
524 .trim()
525 .to_string();
526
527 let _ = Command::new("git")
529 .args(["replace", "--graft", &new_root])
530 .current_dir(&self.worktree)
531 .output();
532
533 let filter_result = Command::new("git")
535 .args(["filter-branch", "--force", "--", "--all"])
536 .current_dir(&self.worktree)
537 .output();
538
539 if let Err(e) = filter_result {
540 return Err(ShadowError::Init(format!("Filter-branch failed: {}", e)));
541 }
542
543 let _ = Command::new("git")
545 .args(["for-each-ref", "--format=%(refname)", "refs/original/"])
546 .current_dir(&self.worktree)
547 .output()
548 .map(|out| {
549 for refname in String::from_utf8_lossy(&out.stdout).lines() {
550 let _ = Command::new("git")
551 .args(["update-ref", "-d", refname])
552 .current_dir(&self.worktree)
553 .output();
554 }
555 });
556
557 let _ = Command::new("git")
559 .args(["replace", "-d", &new_root])
560 .current_dir(&self.worktree)
561 .output();
562
563 let _ = Command::new("git")
565 .args(["gc", "--prune=now", "--aggressive"])
566 .current_dir(&self.worktree)
567 .output();
568
569 Ok(to_prune)
570 }
571
572 pub fn undo(
581 &self,
582 count: usize,
583 file_filter: Option<&str>,
584 cross_checkpoint: bool,
585 dry_run: bool,
586 force: bool,
587 ) -> Result<Vec<UndoResult>, ShadowError> {
588 if !self.exists() {
589 return Err(ShadowError::Undo("No shadow history exists".to_string()));
590 }
591
592 let entries = self.history(None, count);
593 if entries.is_empty() {
594 return Err(ShadowError::Undo("No edits to undo".to_string()));
595 }
596
597 let entries: Vec<_> = if let Some(filter) = file_filter {
599 entries
600 .into_iter()
601 .filter(|e| e.files.iter().any(|f| f.contains(filter) || f == filter))
602 .collect()
603 } else {
604 entries
605 };
606
607 if entries.is_empty() {
608 return Err(ShadowError::Undo(
609 "No edits found matching the file filter".to_string(),
610 ));
611 }
612
613 if !cross_checkpoint && entries.len() > 1 {
615 let first_git_head = &entries[0].git_head;
616 for entry in entries.iter().skip(1) {
617 if entry.git_head != *first_git_head && entry.git_head != "none" {
618 return Err(ShadowError::Undo(format!(
619 "Cannot undo past checkpoint (git commit {}). Use --cross-checkpoint to override.",
620 entry.git_head
621 )));
622 }
623 }
624 }
625
626 if !force && !dry_run {
628 let conflicts = self.detect_conflicts(&entries);
629 if !conflicts.is_empty() {
630 let files_str = conflicts.join(", ");
631 return Err(ShadowError::Undo(format!(
632 "Files modified externally since last edit: {}. Use --force to override.",
633 files_str
634 )));
635 }
636 }
637
638 let mut results = Vec::new();
639
640 for entry in entries.iter().take(count) {
641 let files_to_undo: Vec<_> = if let Some(filter) = file_filter {
643 entry
644 .files
645 .iter()
646 .filter(|f| f.contains(filter) || *f == filter)
647 .cloned()
648 .collect()
649 } else {
650 entry.files.clone()
651 };
652
653 if dry_run {
654 let conflicts = self.detect_conflicts(std::slice::from_ref(entry));
656 results.push(UndoResult {
657 files: files_to_undo.iter().map(PathBuf::from).collect(),
658 undone_commit: entry.hash.clone(),
659 description: format!("{}: {}", entry.operation, entry.target),
660 conflicts,
661 });
662 continue;
663 }
664
665 let parent_ref = format!("{}^", entry.hash);
667 self.restore_files_from_ref(&files_to_undo, &parent_ref)?;
668
669 let add_status = Command::new("git")
671 .args(["add", "-A"])
672 .current_dir(&self.worktree)
673 .status()
674 .map_err(|e| ShadowError::Commit(format!("Failed to stage undo: {}", e)))?;
675 if !add_status.success() {
676 return Err(ShadowError::Commit(
677 "git add failed during undo".to_string(),
678 ));
679 }
680
681 let undo_msg = format!(
682 "normalize edit: undo {}\n\nOperation: undo\nTarget: {}\nUndone-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
683 entry.target,
684 entry.target,
685 entry.hash,
686 files_to_undo.join(", "),
687 self.get_real_git_head()
688 .unwrap_or_else(|| "none".to_string())
689 );
690
691 let commit_status = Command::new("git")
692 .args(["commit", "-m", &undo_msg, "--allow-empty"])
693 .current_dir(&self.worktree)
694 .status()
695 .map_err(|e| ShadowError::Commit(format!("Failed to commit undo: {}", e)))?;
696 if !commit_status.success() {
697 return Err(ShadowError::Commit(
698 "git commit failed during undo".to_string(),
699 ));
700 }
701
702 results.push(UndoResult {
703 files: files_to_undo.iter().map(PathBuf::from).collect(),
704 undone_commit: entry.hash.clone(),
705 description: format!("{}: {}", entry.operation, entry.target),
706 conflicts: vec![], });
708 }
709
710 Ok(results)
711 }
712
713 fn restore_files_from_ref(&self, files: &[String], git_ref: &str) -> Result<(), ShadowError> {
716 for file_path in files {
717 let worktree_file = self.worktree.join(file_path);
718 let actual_file = self.root.join(file_path);
719
720 let show_output = Command::new("git")
721 .args(["show", &format!("{}:{}", git_ref, file_path)])
722 .current_dir(&self.worktree)
723 .output();
724
725 match show_output {
726 Ok(output) if output.status.success() => {
727 if let Some(parent) = actual_file.parent() {
728 let _ = std::fs::create_dir_all(parent);
729 }
730 std::fs::write(&actual_file, &output.stdout).map_err(|e| {
731 ShadowError::Undo(format!("Failed to write {}: {}", file_path, e))
732 })?;
733 if let Some(parent) = worktree_file.parent() {
734 let _ = std::fs::create_dir_all(parent);
735 }
736 let _ = std::fs::write(&worktree_file, &output.stdout);
737 }
738 _ => {
739 if actual_file.exists() {
740 std::fs::remove_file(&actual_file).map_err(|e| {
741 ShadowError::Undo(format!("Failed to delete {}: {}", file_path, e))
742 })?;
743 }
744 let _ = std::fs::remove_file(&worktree_file);
745 }
746 }
747 }
748 Ok(())
749 }
750
751 fn detect_conflicts(&self, entries: &[HistoryEntry]) -> Vec<String> {
754 let mut conflicts = Vec::new();
755
756 for entry in entries {
757 for file_path in &entry.files {
758 let actual_file = self.root.join(file_path);
759
760 let show_output = Command::new("git")
762 .args(["show", &format!("HEAD:{}", file_path)])
763 .current_dir(&self.worktree)
764 .output();
765
766 match show_output {
767 Ok(output) if output.status.success() => {
768 if actual_file.exists() {
770 if let Ok(actual_content) = std::fs::read(&actual_file)
771 && actual_content != output.stdout
772 {
773 conflicts.push(file_path.clone());
774 }
775 } else {
776 conflicts.push(file_path.clone());
778 }
779 }
780 _ => {
781 if actual_file.exists() {
783 conflicts.push(file_path.clone());
784 }
785 }
786 }
787 }
788 }
789
790 conflicts
791 }
792
793 pub fn redo(&self) -> Result<UndoResult, ShadowError> {
796 if !self.exists() {
797 return Err(ShadowError::Undo("No shadow history exists".to_string()));
798 }
799
800 let entries = self.history(None, 1);
802 let latest = entries
803 .first()
804 .ok_or_else(|| ShadowError::Undo("No history to redo".to_string()))?;
805
806 if latest.operation != "undo" {
807 return Err(ShadowError::Undo(
808 "Last operation was not an undo - nothing to redo".to_string(),
809 ));
810 }
811
812 let log_output = Command::new("git")
814 .args(["log", "-1", "--format=%B", &latest.hash])
815 .current_dir(&self.worktree)
816 .output()
817 .map_err(|e| ShadowError::Undo(format!("Failed to get log: {}", e)))?;
818
819 let body = String::from_utf8_lossy(&log_output.stdout);
820 let undone_hash = body
821 .lines()
822 .find_map(|line| line.strip_prefix("Undone-Commit: "))
823 .ok_or_else(|| ShadowError::Undo("Cannot find undone commit reference".to_string()))?;
824
825 let files_output = Command::new("git")
827 .args(["show", "--format=", "--name-only", undone_hash])
828 .current_dir(&self.worktree)
829 .output()
830 .map_err(|e| ShadowError::Undo(format!("Failed to get files: {}", e)))?;
831
832 let files: Vec<String> = String::from_utf8_lossy(&files_output.stdout)
833 .lines()
834 .filter(|l| !l.is_empty())
835 .map(String::from)
836 .collect();
837
838 self.restore_files_from_ref(&files, undone_hash)?;
840
841 let add_status = Command::new("git")
843 .args(["add", "-A"])
844 .current_dir(&self.worktree)
845 .status()
846 .map_err(|e| ShadowError::Commit(format!("Failed to stage redo: {}", e)))?;
847 if !add_status.success() {
848 return Err(ShadowError::Commit(
849 "git add failed during redo".to_string(),
850 ));
851 }
852
853 let redo_msg = format!(
854 "normalize edit: redo {}\n\nOperation: redo\nTarget: {}\nRedone-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
855 latest.target,
856 latest.target,
857 undone_hash,
858 files.join(", "),
859 self.get_real_git_head()
860 .unwrap_or_else(|| "none".to_string())
861 );
862
863 let commit_status = Command::new("git")
864 .args(["commit", "-m", &redo_msg, "--allow-empty"])
865 .current_dir(&self.worktree)
866 .status()
867 .map_err(|e| ShadowError::Commit(format!("Failed to commit redo: {}", e)))?;
868 if !commit_status.success() {
869 return Err(ShadowError::Commit(
870 "git commit failed during redo".to_string(),
871 ));
872 }
873
874 Ok(UndoResult {
875 files: files.iter().map(PathBuf::from).collect(),
876 undone_commit: undone_hash.to_string(),
877 description: format!("redo: {}", latest.target),
878 conflicts: vec![], })
880 }
881
882 pub fn goto(
885 &self,
886 ref_str: &str,
887 dry_run: bool,
888 force: bool,
889 ) -> Result<UndoResult, ShadowError> {
890 if !self.exists() {
891 return Err(ShadowError::Undo("No shadow history exists".to_string()));
892 }
893
894 let rev_parse = Command::new("git")
896 .args(["rev-parse", ref_str])
897 .current_dir(&self.worktree)
898 .output()
899 .map_err(|e| ShadowError::Undo(format!("Failed to resolve ref: {}", e)))?;
900
901 if !rev_parse.status.success() {
902 return Err(ShadowError::Undo(format!(
903 "Invalid ref '{}': not found in shadow history",
904 ref_str
905 )));
906 }
907
908 let target_hash = String::from_utf8_lossy(&rev_parse.stdout)
909 .trim()
910 .to_string();
911
912 let files_output = Command::new("git")
914 .args(["show", "--format=", "--name-only", &target_hash])
915 .current_dir(&self.worktree)
916 .output()
917 .map_err(|e| ShadowError::Undo(format!("Failed to get files: {}", e)))?;
918
919 let files: Vec<String> = String::from_utf8_lossy(&files_output.stdout)
920 .lines()
921 .filter(|l| !l.is_empty())
922 .map(String::from)
923 .collect();
924
925 let log_output = Command::new("git")
927 .args(["log", "-1", "--format=%s", &target_hash])
928 .current_dir(&self.worktree)
929 .output()
930 .map_err(|e| ShadowError::Undo(format!("Failed to get log: {}", e)))?;
931
932 let description = String::from_utf8_lossy(&log_output.stdout)
933 .trim()
934 .to_string();
935
936 if dry_run {
937 return Ok(UndoResult {
938 files: files.iter().map(PathBuf::from).collect(),
939 undone_commit: target_hash,
940 description,
941 conflicts: vec![],
942 });
943 }
944
945 if !force {
947 let fake_entry = HistoryEntry {
949 id: 0,
950 hash: target_hash.clone(),
951 subject: description.clone(),
952 operation: "goto".to_string(),
953 target: ref_str.to_string(),
954 files: files.clone(),
955 message: None,
956 workflow: None,
957 git_head: String::new(),
958 timestamp: String::new(),
959 };
960 let conflicts = self.detect_conflicts(&[fake_entry]);
961 if !conflicts.is_empty() {
962 let files_str = conflicts.join(", ");
963 return Err(ShadowError::Undo(format!(
964 "Files modified externally: {}. Use --force to override.",
965 files_str
966 )));
967 }
968 }
969
970 for file_path in &files {
972 let worktree_file = self.worktree.join(file_path);
973 let actual_file = self.root.join(file_path);
974
975 let show_output = Command::new("git")
976 .args(["show", &format!("{}:{}", target_hash, file_path)])
977 .current_dir(&self.worktree)
978 .output();
979
980 match show_output {
981 Ok(output) if output.status.success() => {
982 if let Some(parent) = actual_file.parent() {
983 let _ = std::fs::create_dir_all(parent);
984 }
985 std::fs::write(&actual_file, &output.stdout).map_err(|e| {
986 ShadowError::Undo(format!("Failed to write {}: {}", file_path, e))
987 })?;
988 if let Some(parent) = worktree_file.parent() {
989 let _ = std::fs::create_dir_all(parent);
990 }
991 let _ = std::fs::write(&worktree_file, &output.stdout);
992 }
993 _ => {
994 if actual_file.exists() {
996 std::fs::remove_file(&actual_file).map_err(|e| {
997 ShadowError::Undo(format!("Failed to delete {}: {}", file_path, e))
998 })?;
999 }
1000 let _ = std::fs::remove_file(&worktree_file);
1001 }
1002 }
1003 }
1004
1005 let add_status = Command::new("git")
1007 .args(["add", "-A"])
1008 .current_dir(&self.worktree)
1009 .status()
1010 .map_err(|e| ShadowError::Commit(format!("Failed to stage goto: {}", e)))?;
1011 if !add_status.success() {
1012 return Err(ShadowError::Commit(
1013 "git add failed during goto".to_string(),
1014 ));
1015 }
1016
1017 let goto_msg = format!(
1018 "normalize edit: goto {}\n\nOperation: goto\nTarget: {}\nGoto-Commit: {}\nFiles: {}\nGit-HEAD: {}\n",
1019 ref_str,
1020 ref_str,
1021 target_hash,
1022 files.join(", "),
1023 self.get_real_git_head()
1024 .unwrap_or_else(|| "none".to_string())
1025 );
1026
1027 let commit_status = Command::new("git")
1028 .args(["commit", "-m", &goto_msg, "--allow-empty"])
1029 .current_dir(&self.worktree)
1030 .status()
1031 .map_err(|e| ShadowError::Commit(format!("Failed to commit goto: {}", e)))?;
1032 if !commit_status.success() {
1033 return Err(ShadowError::Commit(
1034 "git commit failed during goto".to_string(),
1035 ));
1036 }
1037
1038 Ok(UndoResult {
1039 files: files.iter().map(PathBuf::from).collect(),
1040 undone_commit: target_hash,
1041 description,
1042 conflicts: vec![],
1043 })
1044 }
1045}
1046
1047pub struct UndoResult {
1049 pub files: Vec<PathBuf>,
1051 pub undone_commit: String,
1053 pub description: String,
1055 pub conflicts: Vec<String>,
1057}
1058
1059#[derive(Debug, thiserror::Error)]
1061pub enum ShadowError {
1062 #[error("failed to initialize shadow worktree: {0}")]
1063 Init(String),
1064 #[error("failed to commit in shadow worktree: {0}")]
1065 Commit(String),
1066 #[error("failed to undo shadow operation: {0}")]
1067 Undo(String),
1068 #[error("validation failed: {message} (exit code {exit_code})")]
1069 Validation { message: String, exit_code: i32 },
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use tempfile::TempDir;
1076
1077 #[test]
1078 fn test_shadow_new() {
1079 let dir = TempDir::new().unwrap();
1081 let shadow = Shadow::new(dir.path());
1082
1083 assert!(!shadow.exists());
1084 assert_eq!(
1085 shadow.shadow_dir,
1086 dir.path().join(".normalize").join("shadow")
1087 );
1088 }
1089
1090 #[test]
1091 fn test_shadow_init() {
1092 let dir = TempDir::new().unwrap();
1094 let shadow = Shadow::new(dir.path());
1095
1096 shadow.init().unwrap();
1099
1100 assert!(shadow.exists());
1101 assert!(shadow.worktree.exists());
1102 }
1103
1104 #[test]
1105 fn test_shadow_before_after_edit() {
1106 let dir = TempDir::new().unwrap();
1108
1109 let test_file = dir.path().join("test.rs");
1111 std::fs::write(&test_file, "fn foo() {}").unwrap();
1113
1114 let shadow = Shadow::new(dir.path());
1115
1116 shadow.before_edit(&[&test_file]).unwrap();
1119
1120 std::fs::write(&test_file, "fn bar() {}").unwrap();
1123
1124 let info = EditInfo {
1126 operation: "replace".to_string(),
1127 target: "test.rs/foo".to_string(),
1128 files: vec![test_file.clone()],
1129 message: Some("Renamed foo to bar".to_string()),
1130 workflow: None,
1131 };
1132 shadow.after_edit(&info).unwrap();
1134
1135 assert_eq!(shadow.edit_count(), 1);
1136 }
1137}