Skip to main content

normalize_shadow/
lib.rs

1//! Shadow Git - automatic edit history tracking.
2//!
3//! Maintains a hidden git repository (`.normalize/shadow/`) that automatically
4//! commits after each `normalize edit` operation, preserving full edit history.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10/// A single entry in shadow git history.
11#[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/// Shadow git configuration.
26#[derive(Debug, Clone, Deserialize, Serialize, Default, schemars::JsonSchema)]
27#[serde(default)]
28pub struct ShadowConfig {
29    /// Whether shadow git is enabled. Default: true
30    pub enabled: Option<bool>,
31    /// Confirm before deleting symbols. Default: true
32    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
45/// Information about an edit operation for shadow commit.
46pub 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
54/// Result of running a validation command in shadow worktree.
55pub struct ValidationResult {
56    pub success: bool,
57    pub exit_code: Option<i32>,
58    pub stdout: String,
59    pub stderr: String,
60}
61
62/// Shadow git repository manager.
63pub struct Shadow {
64    /// Root of the project (where .normalize/ lives)
65    root: PathBuf,
66    /// Path to shadow git directory (.normalize/shadow/)
67    shadow_dir: PathBuf,
68    /// Path to shadow worktree (.normalize/shadow/worktree/)
69    worktree: PathBuf,
70}
71
72impl Shadow {
73    /// Create a new Shadow instance for a project root.
74    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    /// Check if shadow git exists for this project.
85    pub fn exists(&self) -> bool {
86        self.shadow_dir.join(".git").exists()
87    }
88
89    /// Initialize shadow git repository if it doesn't exist.
90    /// Called on first edit, not on `normalize init`.
91    fn init(&self) -> Result<(), ShadowError> {
92        if self.exists() {
93            return Ok(());
94        }
95
96        // Create worktree directory (git init will create .git inside shadow_dir)
97        std::fs::create_dir_all(&self.worktree)
98            .map_err(|e| ShadowError::Init(format!("Failed to create shadow directory: {}", e)))?;
99
100        // Initialize git repo with worktree in subdirectory
101        // Use --separate-git-dir to put .git in shadow_dir while worktree is in worktree/
102        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        // Configure git user for commits (shadow-specific, doesn't affect user's git)
120        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    /// Get the current git HEAD of the real repository.
133    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    /// Copy a file to the shadow worktree, preserving relative path.
148    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        // Create parent directories
156        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        // Copy file
162        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    /// Record file state before an edit.
169    /// Call this before applying the edit to capture "before" state.
170    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    /// Record file state after an edit and commit.
183    /// Call this after applying the edit to capture "after" state.
184    pub fn after_edit(&self, info: &EditInfo) -> Result<(), ShadowError> {
185        // Copy updated files to worktree
186        for file in &info.files {
187            if file.exists() {
188                self.copy_to_worktree(file)?;
189            }
190        }
191
192        // Stage all changes (run in worktree directory)
193        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        // Check if there are changes to commit
204        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            // No changes to commit
212            return Ok(());
213        }
214
215        // Build commit message
216        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        // Commit
240        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    /// Get history of shadow edits.
254    /// Returns list of edits in reverse chronological order (newest first).
255    pub fn history(&self, file_filter: Option<&str>, limit: usize) -> Vec<HistoryEntry> {
256        if !self.exists() {
257            return Vec::new();
258        }
259
260        // Get git log with custom format
261        // Use %x1e (record separator) between commits and %x1f (unit separator) between fields
262        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        // Filter by file if specified
269        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        // Split by record separator (0x1e)
288        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            // Parse the commit format: hash\x1fsubject\x1fbody\x1ftimestamp
296            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            // Parse body for structured fields
307            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, // newest first, so first entry gets highest ID
332                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    /// Get diff for a specific commit.
348    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    /// Get tree view of shadow history (shows all branches with graph).
367    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    /// Get current checkpoint (last git commit in real repo when shadow was updated).
393    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    /// Run a validation command in the shadow worktree.
401    /// Returns (success, stdout, stderr).
402    /// Used by agents to test changes before applying to real files.
403    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    /// Apply pending shadow changes to the real worktree.
426    /// Only call this after validation passes.
427    /// Returns list of files that were updated.
428    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        // Get list of changed files in shadow
434        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        // Copy each file from shadow to real
456        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    /// Get the number of shadow commits (edits tracked).
477    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    /// Prune shadow history, keeping only the last N commits.
497    /// Returns the number of commits pruned.
498    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        // Find the commit that will become the new root (the `keep`th commit from HEAD)
511        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        // Create a graft to make the new root appear as an initial commit
528        let _ = Command::new("git")
529            .args(["replace", "--graft", &new_root])
530            .current_dir(&self.worktree)
531            .output();
532
533        // Use filter-branch to bake in the graft (rewrite history)
534        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        // Clean up refs created by filter-branch
544        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        // Remove the replacement ref
558        let _ = Command::new("git")
559            .args(["replace", "-d", &new_root])
560            .current_dir(&self.worktree)
561            .output();
562
563        // Run gc to actually free space
564        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    /// Undo the most recent edit (or specified number of edits).
573    /// Returns information about what was undone.
574    ///
575    /// If `file_filter` is Some, only undo changes to files matching that path.
576    /// If `force` is false, checks for external modifications first and fails
577    /// if any files have been modified outside of normalize.
578    ///
579    /// If `cross_checkpoint` is false, refuses to undo past a git commit boundary.
580    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        // Filter entries to only those affecting the specified file
598        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        // Check for checkpoint boundaries (git commit changes) unless cross_checkpoint is set
614        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        // Check for external modifications unless force is set
627        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            // Filter files to only those matching the filter
642            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                // Also report conflicts in dry-run mode
655                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            // For each file in the commit, restore from the parent commit state
666            let parent_ref = format!("{}^", entry.hash);
667            self.restore_files_from_ref(&files_to_undo, &parent_ref)?;
668
669            // Stage and commit the undo
670            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![], // Already checked/forced above
707            });
708        }
709
710        Ok(results)
711    }
712
713    /// Restore a set of files from a given git ref into both the real root and the worktree.
714    /// Files that don't exist at `git_ref` are deleted; files that do are written.
715    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    /// Detect files that have been modified externally since last normalize edit.
752    /// Returns list of file paths that differ between actual filesystem and shadow git HEAD.
753    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                // Get expected content from shadow git HEAD
761                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                        // File exists in shadow - compare with actual
769                        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                            // File was deleted externally
777                            conflicts.push(file_path.clone());
778                        }
779                    }
780                    _ => {
781                        // File doesn't exist in shadow but might exist on disk
782                        if actual_file.exists() {
783                            conflicts.push(file_path.clone());
784                        }
785                    }
786                }
787            }
788        }
789
790        conflicts
791    }
792
793    /// Redo the most recently undone edit.
794    /// Only works if the last operation was an undo.
795    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        // Get the most recent entry to check if it's an undo
801        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        // Find the commit that was undone (from the undo commit message)
813        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        // Get file list from the undone commit
826        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        // For each file, restore from the undone commit state
839        self.restore_files_from_ref(&files, undone_hash)?;
840
841        // Stage and commit the redo
842        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![], // Redo doesn't check for conflicts
879        })
880    }
881
882    /// Jump to a specific commit in shadow history, restoring file state from that point.
883    /// Can use full SHA, short SHA, or relative refs like HEAD~2.
884    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        // Resolve the ref to a full commit hash
895        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        // Get files changed in the target commit
913        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        // Get the commit message for description
926        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        // Check for conflicts if not forcing
946        if !force {
947            // Create a fake HistoryEntry for conflict detection
948            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        // Restore files from target commit state
971        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                    // File doesn't exist in target commit
995                    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        // Stage and commit the goto
1006        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
1047/// Result of an undo operation.
1048pub struct UndoResult {
1049    /// Files that were modified by the undo
1050    pub files: Vec<PathBuf>,
1051    /// The commit that was undone
1052    pub undone_commit: String,
1053    /// Description of what was undone
1054    pub description: String,
1055    /// Files that have been modified externally (only populated in dry-run)
1056    pub conflicts: Vec<String>,
1057}
1058
1059/// Shadow git errors.
1060#[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        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1080        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        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1093        let dir = TempDir::new().unwrap();
1094        let shadow = Shadow::new(dir.path());
1095
1096        // Initialize as if it's the first edit
1097        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1098        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        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1107        let dir = TempDir::new().unwrap();
1108
1109        // Create a test file
1110        let test_file = dir.path().join("test.rs");
1111        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1112        std::fs::write(&test_file, "fn foo() {}").unwrap();
1113
1114        let shadow = Shadow::new(dir.path());
1115
1116        // Before edit
1117        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1118        shadow.before_edit(&[&test_file]).unwrap();
1119
1120        // Simulate edit
1121        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1122        std::fs::write(&test_file, "fn bar() {}").unwrap();
1123
1124        // After edit
1125        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        // normalize-syntax-allow: rust/unwrap-in-impl - test code, panic is appropriate
1133        shadow.after_edit(&info).unwrap();
1134
1135        assert_eq!(shadow.edit_count(), 1);
1136    }
1137}