Skip to main content

git_surgeon/
diff.rs

1use anyhow::{Context, Result};
2use std::process::Command;
3
4#[derive(Debug, Clone)]
5pub struct DiffHunk {
6    /// The primary file path for display/matching. Prefers the new-side path,
7    /// but falls back to the old-side path for deletions (where new is /dev/null).
8    pub file: String,
9    /// Old-side file path (from `--- a/...`), or "/dev/null" for new files.
10    pub old_file: String,
11    /// New-side file path (from `+++ b/...`), or "/dev/null" for deleted files.
12    pub new_file: String,
13    /// The full file header (--- a/... and +++ b/... lines)
14    pub file_header: String,
15    /// The @@ line, e.g. "@@ -12,4 +12,6 @@ fn main"
16    pub header: String,
17    /// All lines in the hunk (context, +, -)
18    pub lines: Vec<String>,
19    /// Unsupported preamble metadata (rename, mode change, etc.) if present
20    pub unsupported_metadata: Option<String>,
21}
22
23const DIFF_FORMAT_ARGS: &[&str] = &[
24    "--no-color",
25    "--no-ext-diff",
26    "--src-prefix=a/",
27    "--dst-prefix=b/",
28];
29
30pub fn run_git_diff(staged: bool, file: Option<&str>) -> Result<String> {
31    let mut cmd = Command::new("git");
32    cmd.arg("diff");
33    cmd.args(DIFF_FORMAT_ARGS);
34    if staged {
35        cmd.arg("--cached");
36    }
37    if let Some(f) = file {
38        cmd.arg("--").arg(f);
39    }
40    run_git_cmd(&mut cmd)
41}
42
43pub fn run_git_diff_commit(commit: &str, file: Option<&str>) -> Result<String> {
44    let mut cmd = Command::new("git");
45    cmd.args(["show", "--pretty="]);
46    cmd.args(DIFF_FORMAT_ARGS);
47    cmd.arg(commit);
48    if let Some(f) = file {
49        cmd.arg("--").arg(f);
50    }
51    run_git_cmd(&mut cmd)
52}
53
54pub fn run_git_cmd(cmd: &mut Command) -> Result<String> {
55    let output = cmd.output().context("failed to run git command")?;
56    if !output.status.success() {
57        anyhow::bail!(
58            "git command failed: {}",
59            String::from_utf8_lossy(&output.stderr)
60        );
61    }
62    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
63}
64
65/// Extract a file path from a `--- a/...` or `+++ b/...` line.
66fn strip_diff_prefix(line: &str) -> &str {
67    line.strip_prefix("--- a/")
68        .or_else(|| line.strip_prefix("+++ b/"))
69        .or_else(|| line.strip_prefix("--- /"))
70        .or_else(|| line.strip_prefix("+++ /"))
71        .or_else(|| line.strip_prefix("+++ a/"))
72        .or_else(|| line.strip_prefix("--- "))
73        .or_else(|| line.strip_prefix("+++ "))
74        .unwrap_or(line)
75}
76
77/// Preamble lines that indicate unsupported metadata operations.
78/// Note: "new file mode" and "deleted file mode" are supported (they work fine
79/// with the --- /dev/null or +++ /dev/null headers we already capture).
80const UNSUPPORTED_PREAMBLE_PREFIXES: &[&str] = &[
81    "rename from ",
82    "rename to ",
83    "copy from ",
84    "copy to ",
85    "old mode ",
86    "new mode ",
87    "similarity index ",
88    "dissimilarity index ",
89];
90
91pub fn parse_diff(input: &str) -> Vec<DiffHunk> {
92    let mut hunks = Vec::new();
93    let mut current_old_file = String::new();
94    let mut current_new_file = String::new();
95    let mut current_file_header = String::new();
96    let mut current_header: Option<String> = None;
97    let mut current_lines: Vec<String> = Vec::new();
98    let mut current_unsupported: Option<String> = None;
99
100    for line in input.lines() {
101        if line.starts_with("diff --git") {
102            // Flush previous hunk
103            if let Some(header) = current_header.take() {
104                hunks.push(DiffHunk {
105                    file: display_file(&current_old_file, &current_new_file),
106                    old_file: current_old_file.clone(),
107                    new_file: current_new_file.clone(),
108                    file_header: current_file_header.clone(),
109                    header,
110                    lines: std::mem::take(&mut current_lines),
111                    unsupported_metadata: current_unsupported.clone(),
112                });
113            }
114            current_file_header.clear();
115            current_old_file.clear();
116            current_new_file.clear();
117            current_unsupported = None;
118        } else if current_unsupported.is_none() {
119            // Check for unsupported preamble metadata before --- line
120            if let Some(prefix) = UNSUPPORTED_PREAMBLE_PREFIXES
121                .iter()
122                .find(|p| line.starts_with(*p))
123            {
124                current_unsupported = Some(prefix.trim().to_string());
125            }
126        }
127
128        if line.starts_with("--- ") {
129            current_file_header = line.to_string();
130            current_old_file = strip_diff_prefix(line).to_string();
131        } else if line.starts_with("+++ ") {
132            current_file_header.push('\n');
133            current_file_header.push_str(line);
134            current_new_file = strip_diff_prefix(line).to_string();
135        } else if line.starts_with("@@ ") {
136            // Flush previous hunk in same file
137            if let Some(header) = current_header.take() {
138                hunks.push(DiffHunk {
139                    file: display_file(&current_old_file, &current_new_file),
140                    old_file: current_old_file.clone(),
141                    new_file: current_new_file.clone(),
142                    file_header: current_file_header.clone(),
143                    header,
144                    lines: std::mem::take(&mut current_lines),
145                    unsupported_metadata: current_unsupported.clone(),
146                });
147            }
148            current_header = Some(line.to_string());
149        } else if current_header.is_some() {
150            current_lines.push(line.to_string());
151        }
152    }
153
154    // Flush last hunk
155    if let Some(header) = current_header.take() {
156        hunks.push(DiffHunk {
157            file: display_file(&current_old_file, &current_new_file),
158            old_file: current_old_file,
159            new_file: current_new_file,
160            file_header: current_file_header,
161            header,
162            lines: current_lines,
163            unsupported_metadata: current_unsupported,
164        });
165    }
166
167    hunks
168}
169
170/// Choose the display path for a hunk. Prefer new-side, fall back to old-side
171/// for deletions where new is /dev/null.
172fn display_file(old: &str, new: &str) -> String {
173    if new == "dev/null" || new.is_empty() {
174        old.to_string()
175    } else {
176        new.to_string()
177    }
178}
179
180/// Check if a hunk has unsupported metadata and return an error if so.
181pub fn check_supported(hunk: &DiffHunk, id: &str) -> Result<()> {
182    if let Some(ref metadata) = hunk.unsupported_metadata {
183        anyhow::bail!(
184            "hunk {} involves '{}' which is not supported for hunk-level operations",
185            id,
186            metadata
187        );
188    }
189    Ok(())
190}