Skip to main content

void_core/ops/
merge_state.rs

1//! Merge state file helpers.
2//!
3//! This module manages the state files that track an in-progress merge.
4//! Files are stored in `.void/` and include:
5//! - MERGE_HEAD: hex-encoded CID of commit being merged
6//! - MERGE_BASE: hex-encoded CID of merge base (optional)
7//! - MERGE_MSG: commit message text
8//! - MERGE_CONFLICTS: JSON array of conflicted paths
9//! - ORIG_HEAD: hex-encoded CID of HEAD before merge
10
11use std::fs;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15use void_crypto::CommitCid;
16
17use crate::support::util::atomic_write_str;
18use crate::support::Result;
19
20/// State of an in-progress merge.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MergeState {
23    /// CID of commit being merged (theirs)
24    pub merge_head: CommitCid,
25    /// CID of common ancestor (optional)
26    pub merge_base: Option<CommitCid>,
27    /// HEAD before merge started
28    pub orig_head: CommitCid,
29    /// Conflicted paths
30    pub conflicts: Vec<String>,
31    /// Pre-populated commit message
32    pub message: String,
33}
34
35const MERGE_HEAD: &str = "MERGE_HEAD";
36const MERGE_BASE: &str = "MERGE_BASE";
37const MERGE_MSG: &str = "MERGE_MSG";
38const MERGE_CONFLICTS: &str = "MERGE_CONFLICTS";
39const ORIG_HEAD: &str = "ORIG_HEAD";
40
41/// Read the current merge state from the void directory.
42///
43/// Returns `Ok(None)` if no merge is in progress.
44pub fn read_merge_state(void_dir: &Path) -> Result<Option<MergeState>> {
45    let merge_head_path = void_dir.join(MERGE_HEAD);
46
47    // If MERGE_HEAD doesn't exist, no merge is in progress
48    if !merge_head_path.exists() {
49        return Ok(None);
50    }
51
52    // Read MERGE_HEAD (required)
53    let merge_head_hex = fs::read_to_string(&merge_head_path)?;
54    let merge_head = CommitCid::from_bytes(hex::decode(merge_head_hex.trim())
55        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?);
56
57    // Read ORIG_HEAD (required if MERGE_HEAD exists)
58    let orig_head_path = void_dir.join(ORIG_HEAD);
59    let orig_head_hex = fs::read_to_string(orig_head_path)?;
60    let orig_head = CommitCid::from_bytes(hex::decode(orig_head_hex.trim())
61        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?);
62
63    // Read MERGE_BASE (optional)
64    let merge_base_path = void_dir.join(MERGE_BASE);
65    let merge_base = if merge_base_path.exists() {
66        let hex_str = fs::read_to_string(&merge_base_path)?;
67        Some(CommitCid::from_bytes(
68            hex::decode(hex_str.trim())
69                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
70        ))
71    } else {
72        None
73    };
74
75    // Read MERGE_MSG (default to empty string if missing)
76    let message_path = void_dir.join(MERGE_MSG);
77    let message = if message_path.exists() {
78        fs::read_to_string(&message_path)?
79    } else {
80        String::new()
81    };
82
83    // Read MERGE_CONFLICTS (default to empty array if missing)
84    let conflicts_path = void_dir.join(MERGE_CONFLICTS);
85    let conflicts: Vec<String> = if conflicts_path.exists() {
86        let content = fs::read_to_string(&conflicts_path)?;
87        serde_json::from_str(&content)
88            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?
89    } else {
90        Vec::new()
91    };
92
93    Ok(Some(MergeState {
94        merge_head,
95        merge_base,
96        orig_head,
97        conflicts,
98        message,
99    }))
100}
101
102/// Write merge state to the void directory.
103///
104/// Uses atomic writes to prevent corruption.
105pub fn write_merge_state(void_dir: &Path, state: &MergeState) -> Result<()> {
106    // Write MERGE_HEAD
107    let merge_head_hex = hex::encode(state.merge_head.as_bytes());
108    atomic_write_str(void_dir.join(MERGE_HEAD), &merge_head_hex)?;
109
110    // Write ORIG_HEAD
111    let orig_head_hex = hex::encode(state.orig_head.as_bytes());
112    atomic_write_str(void_dir.join(ORIG_HEAD), &orig_head_hex)?;
113
114    // Write MERGE_BASE (if present)
115    if let Some(ref base) = state.merge_base {
116        let base_hex = hex::encode(base.as_bytes());
117        atomic_write_str(void_dir.join(MERGE_BASE), &base_hex)?;
118    }
119
120    // Write MERGE_MSG
121    atomic_write_str(void_dir.join(MERGE_MSG), &state.message)?;
122
123    // Write MERGE_CONFLICTS
124    let conflicts_json = serde_json::to_string(&state.conflicts)
125        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
126    atomic_write_str(void_dir.join(MERGE_CONFLICTS), &conflicts_json)?;
127
128    Ok(())
129}
130
131/// Clear all merge state files from the void directory.
132///
133/// This should be called after a merge is completed or aborted.
134/// Silently ignores missing files.
135pub fn clear_merge_state(void_dir: &Path) -> Result<()> {
136    let files = [
137        MERGE_HEAD,
138        MERGE_BASE,
139        MERGE_MSG,
140        MERGE_CONFLICTS,
141        ORIG_HEAD,
142    ];
143
144    for file in files {
145        let path = void_dir.join(file);
146        if path.exists() {
147            fs::remove_file(&path)?;
148        }
149    }
150
151    Ok(())
152}
153
154/// Check if a merge is in progress.
155///
156/// Returns true if MERGE_HEAD exists in the void directory.
157pub fn is_merge_in_progress(void_dir: &Path) -> bool {
158    void_dir.join(MERGE_HEAD).exists()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use tempfile::TempDir;
165
166    fn sample_state() -> MergeState {
167        MergeState {
168            merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03, 0x04]),
169            merge_base: Some(CommitCid::from_bytes(vec![0x05, 0x06, 0x07, 0x08])),
170            orig_head: CommitCid::from_bytes(vec![0x09, 0x0a, 0x0b, 0x0c]),
171            conflicts: vec!["src/main.rs".to_string(), "README.md".to_string()],
172            message: "Merge branch 'feature' into main".to_string(),
173        }
174    }
175
176    #[test]
177    fn write_read_roundtrip() {
178        let temp = TempDir::new().unwrap();
179        let void_dir = temp.path();
180
181        let state = sample_state();
182        write_merge_state(void_dir, &state).unwrap();
183
184        let read_state = read_merge_state(void_dir).unwrap().unwrap();
185
186        assert_eq!(read_state.merge_head, state.merge_head);
187        assert_eq!(read_state.merge_base, state.merge_base);
188        assert_eq!(read_state.orig_head, state.orig_head);
189        assert_eq!(read_state.conflicts, state.conflicts);
190        assert_eq!(read_state.message, state.message);
191    }
192
193    #[test]
194    fn write_read_roundtrip_no_base() {
195        let temp = TempDir::new().unwrap();
196        let void_dir = temp.path();
197
198        let mut state = sample_state();
199        state.merge_base = None;
200        write_merge_state(void_dir, &state).unwrap();
201
202        let read_state = read_merge_state(void_dir).unwrap().unwrap();
203
204        assert_eq!(read_state.merge_base, None);
205    }
206
207    #[test]
208    fn write_read_roundtrip_empty_conflicts() {
209        let temp = TempDir::new().unwrap();
210        let void_dir = temp.path();
211
212        let mut state = sample_state();
213        state.conflicts = Vec::new();
214        write_merge_state(void_dir, &state).unwrap();
215
216        let read_state = read_merge_state(void_dir).unwrap().unwrap();
217
218        assert!(read_state.conflicts.is_empty());
219    }
220
221    #[test]
222    fn clear_removes_all_files() {
223        let temp = TempDir::new().unwrap();
224        let void_dir = temp.path();
225
226        let state = sample_state();
227        write_merge_state(void_dir, &state).unwrap();
228
229        // Verify files exist
230        assert!(void_dir.join(MERGE_HEAD).exists());
231        assert!(void_dir.join(MERGE_BASE).exists());
232        assert!(void_dir.join(MERGE_MSG).exists());
233        assert!(void_dir.join(MERGE_CONFLICTS).exists());
234        assert!(void_dir.join(ORIG_HEAD).exists());
235
236        clear_merge_state(void_dir).unwrap();
237
238        // Verify all files removed
239        assert!(!void_dir.join(MERGE_HEAD).exists());
240        assert!(!void_dir.join(MERGE_BASE).exists());
241        assert!(!void_dir.join(MERGE_MSG).exists());
242        assert!(!void_dir.join(MERGE_CONFLICTS).exists());
243        assert!(!void_dir.join(ORIG_HEAD).exists());
244    }
245
246    #[test]
247    fn clear_handles_missing_files() {
248        let temp = TempDir::new().unwrap();
249        let void_dir = temp.path();
250
251        // Should not error when no files exist
252        clear_merge_state(void_dir).unwrap();
253    }
254
255    #[test]
256    fn is_merge_in_progress_true() {
257        let temp = TempDir::new().unwrap();
258        let void_dir = temp.path();
259
260        let state = sample_state();
261        write_merge_state(void_dir, &state).unwrap();
262
263        assert!(is_merge_in_progress(void_dir));
264    }
265
266    #[test]
267    fn is_merge_in_progress_false() {
268        let temp = TempDir::new().unwrap();
269        let void_dir = temp.path();
270
271        assert!(!is_merge_in_progress(void_dir));
272    }
273
274    #[test]
275    fn is_merge_in_progress_after_clear() {
276        let temp = TempDir::new().unwrap();
277        let void_dir = temp.path();
278
279        let state = sample_state();
280        write_merge_state(void_dir, &state).unwrap();
281        assert!(is_merge_in_progress(void_dir));
282
283        clear_merge_state(void_dir).unwrap();
284        assert!(!is_merge_in_progress(void_dir));
285    }
286
287    #[test]
288    fn read_when_no_merge_returns_none() {
289        let temp = TempDir::new().unwrap();
290        let void_dir = temp.path();
291
292        let result = read_merge_state(void_dir).unwrap();
293        assert!(result.is_none());
294    }
295}