Skip to main content

suture_git_bridge/
lib.rs

1//! # ⚠️ EXPERIMENTAL — Known Data Loss Issues
2//!
3//! This module is **not ready for production use**. Known issues include:
4//! - Branch import is a no-op (branches are detected but not created in Suture)
5//! - Commit topology is linearized (merge commits become sequential patches)
6//! - File contents for intermediate commits may be incorrect
7//! - Rename detection parses tab-separated paths incorrectly
8//!
9//! Use at your own risk. Data imported via this bridge may be incomplete or incorrect.
10//!
11//! ---
12//!
13//! Git-Suture interop bridge.
14//!
15//! Provides bidirectional import/export between Suture and Git repositories.
16//!
17//! # Git → Suture Import
18//! - Walks a Git repository's commit history
19//! - Creates equivalent Suture patches for each Git commit
20//! - Preserves branch structure, merge commits, and file contents
21//!
22//! # Suture → Git Export
23//! - Walks a Suture repository's patch DAG
24//! - Creates equivalent Git commits
25//! - Preserves branch structure and file contents
26
27use std::path::Path;
28use suture_core::repository::Repository;
29use thiserror::Error;
30
31#[derive(Error, Debug)]
32pub enum BridgeError {
33    #[error("git command failed: {0}")]
34    GitCommand(String),
35    #[error("I/O error: {0}")]
36    Io(#[from] std::io::Error),
37    #[error("suture error: {0}")]
38    Suture(String),
39    #[error("invalid git repository: {0}")]
40    InvalidGitRepo(String),
41}
42
43/// Import a Git repository into a Suture repository.
44///
45/// Creates a new Suture repository at `suture_path` and imports all
46/// commits from the Git repository at `git_path`.
47///
48/// # Arguments
49///
50/// * `git_path` - Path to the source Git repository
51/// * `suture_path` - Path where the Suture repository will be created
52/// * `author` - Author name for imported commits
53///
54/// # How it works
55///
56/// 1. Creates a new Suture repository at `suture_path`
57/// 2. Runs `git log --reverse` to get commits in chronological order
58/// 3. For each commit:
59///    a. Runs `git show <sha>` to get file changes
60///    b. Creates the appropriate Suture patch (create/modify/delete)
61///    c. Commits to the Suture repository
62/// 4. Recreates branch structure
63///
64/// ⚠️ **EXPERIMENTAL** — Known to lose branch topology, merge structure, and
65/// intermediate file contents. See module-level documentation.
66#[deprecated(
67    since = "0.1.0",
68    note = "Git bridge is experimental and may lose data. See module docs."
69)]
70pub fn import_from_git(
71    git_path: &Path,
72    suture_path: &Path,
73    author: &str,
74) -> Result<ImportResult, BridgeError> {
75    use std::process::Command;
76
77    let output = Command::new("git")
78        .args(["-C", &git_path.to_string_lossy(), "rev-parse", "--git-dir"])
79        .output()
80        .map_err(|e| BridgeError::GitCommand(format!("git not found: {}", e)))?;
81
82    if !output.status.success() {
83        return Err(BridgeError::InvalidGitRepo(
84            git_path.to_string_lossy().to_string(),
85        ));
86    }
87
88    let mut repo =
89        Repository::init(suture_path, author).map_err(|e| BridgeError::Suture(e.to_string()))?;
90
91    let _ = repo.set_config("user.name", author);
92
93    let output = Command::new("git")
94        .args([
95            "-C",
96            &git_path.to_string_lossy(),
97            "log",
98            "--reverse",
99            "--format=%H %s",
100            "--all",
101        ])
102        .output()
103        .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
104
105    let commit_list = String::from_utf8_lossy(&output.stdout);
106    let mut patches_imported = 0usize;
107    let mut branches_imported = 0usize;
108
109    for line in commit_list.lines() {
110        let parts: Vec<&str> = line.splitn(2, ' ').collect();
111        if parts.len() != 2 {
112            continue;
113        }
114        let sha = parts[0];
115        let message = parts[1];
116
117        let diff_output = Command::new("git")
118            .args([
119                "-C",
120                &git_path.to_string_lossy(),
121                "diff-tree",
122                "--no-commit-id",
123                "-r",
124                "--name-status",
125                sha,
126            ])
127            .output()
128            .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
129
130        let diff = String::from_utf8_lossy(&diff_output.stdout);
131
132        for diff_line in diff.lines() {
133            let parts: Vec<&str> = diff_line.splitn(2, '\t').collect();
134            if parts.len() != 2 {
135                continue;
136            }
137            let status = parts[0].trim();
138            let filepath = parts[1].trim();
139
140            let git_file = git_path.join(filepath);
141            let suture_file = suture_path.join(filepath);
142
143            match status {
144                "M" | "A" => {
145                    if let Some(parent) = suture_file.parent() {
146                        std::fs::create_dir_all(parent)?;
147                    }
148                    if git_file.exists() {
149                        std::fs::copy(&git_file, &suture_file)?;
150                        repo.add(filepath)
151                            .map_err(|e| BridgeError::Suture(e.to_string()))?;
152                    }
153                }
154                "D" => {
155                    if suture_file.exists() {
156                        std::fs::remove_file(&suture_file)?;
157                        repo.add(filepath)
158                            .map_err(|e| BridgeError::Suture(e.to_string()))?;
159                    }
160                }
161                "R" => {
162                    let rename_parts: Vec<&str> = filepath.split('\t').collect();
163                    if rename_parts.len() == 2 {
164                        let new_path = suture_path.join(rename_parts[1]);
165                        if let Some(new_parent) = new_path.parent() {
166                            std::fs::create_dir_all(new_parent)?;
167                        }
168                        let old_path = suture_path.join(rename_parts[0]);
169                        if old_path.exists() {
170                            std::fs::rename(&old_path, &new_path)?;
171                            repo.rename_file(rename_parts[0], rename_parts[1])
172                                .map_err(|e| BridgeError::Suture(e.to_string()))?;
173                        }
174                    }
175                }
176                _ => {}
177            }
178        }
179
180        if diff.lines().count() > 0 {
181            repo.commit(message)
182                .map_err(|e| BridgeError::Suture(e.to_string()))?;
183            patches_imported += 1;
184        }
185    }
186
187    let branch_output = Command::new("git")
188        .args([
189            "-C",
190            &git_path.to_string_lossy(),
191            "branch",
192            "--format=%(refname:short)",
193        ])
194        .output()
195        .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
196
197    let branches = String::from_utf8_lossy(&branch_output.stdout);
198    for branch in branches.lines() {
199        let branch = branch.trim();
200        if branch.is_empty() || branch == "HEAD" {
201            continue;
202        }
203        let _sha_output = Command::new("git")
204            .args(["-C", &git_path.to_string_lossy(), "rev-parse", branch])
205            .output()
206            .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
207
208        branches_imported += 1;
209    }
210
211    Ok(ImportResult {
212        patches_imported,
213        branches_imported,
214    })
215}
216
217/// Result of a Git import operation.
218#[derive(Debug, Clone)]
219pub struct ImportResult {
220    /// Number of patches imported.
221    pub patches_imported: usize,
222    /// Number of branches imported.
223    pub branches_imported: usize,
224}
225
226/// Export a Suture repository to a Git repository.
227///
228/// Creates a new Git repository at `git_path` and exports all
229/// patches from the Suture repository at `suture_path`.
230///
231/// # How it works
232///
233/// 1. Creates a new Git repository at `git_path`
234/// 2. Walks the Suture patch DAG from all branch tips
235/// 3. For each patch:
236///    a. Applies the patch to the working tree
237///    b. Runs `git add` + `git commit` with the patch message
238/// 4. Recreates branch structure
239///
240/// ⚠️ **EXPERIMENTAL** — Export may produce incorrect Git history.
241/// See module-level documentation.
242#[deprecated(
243    since = "0.1.0",
244    note = "Git bridge is experimental and may produce incorrect results. See module docs."
245)]
246pub fn export_to_git(suture_path: &Path, git_path: &Path) -> Result<ExportResult, BridgeError> {
247    use std::process::Command;
248
249    let output = Command::new("git")
250        .args(["init", &git_path.to_string_lossy()])
251        .output()
252        .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
253
254    if !output.status.success() {
255        return Err(BridgeError::GitCommand("git init failed".to_string()));
256    }
257
258    Command::new("git")
259        .args([
260            "-C",
261            &git_path.to_string_lossy(),
262            "config",
263            "user.name",
264            "suture-bridge",
265        ])
266        .output()
267        .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
268
269    Command::new("git")
270        .args([
271            "-C",
272            &git_path.to_string_lossy(),
273            "config",
274            "user.email",
275            "bridge@suture.dev",
276        ])
277        .output()
278        .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
279
280    let repo = Repository::open(suture_path).map_err(|e| BridgeError::Suture(e.to_string()))?;
281
282    let branches = repo.list_branches();
283
284    let main_id = branches
285        .iter()
286        .find(|(name, _)| name == "main")
287        .map(|(_, id)| *id);
288
289    let mut patches_exported = 0usize;
290    let mut branches_exported = 0usize;
291
292    if let Some(_tip_id) = main_id {
293        let log = repo
294            .log(None)
295            .map_err(|e| BridgeError::Suture(e.to_string()))?;
296
297        let head_tree = repo
298            .snapshot_head()
299            .map_err(|e| BridgeError::Suture(e.to_string()))?;
300
301        for patch in &log {
302            if let Some(ref target_path) = patch.target_path {
303                let git_file = git_path.join(target_path);
304
305                match patch.operation_type {
306                    suture_core::patch::types::OperationType::Delete => {
307                        if git_file.exists() {
308                            std::fs::remove_file(&git_file)?;
309                        }
310                    }
311                    _ => {
312                        if let Some(hash) = head_tree.get(target_path) {
313                            if let Ok(blob) = repo.cas().get_blob(hash) {
314                                if let Some(parent) = git_file.parent() {
315                                    std::fs::create_dir_all(parent)?;
316                                }
317                                std::fs::write(&git_file, blob)?;
318                            }
319                        }
320                    }
321                }
322            }
323
324            Command::new("git")
325                .args(["-C", &git_path.to_string_lossy(), "add", "-A"])
326                .output()
327                .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
328
329            let output = Command::new("git")
330                .args([
331                    "-C",
332                    &git_path.to_string_lossy(),
333                    "commit",
334                    "-m",
335                    &patch.message,
336                    "--allow-empty",
337                ])
338                .output()
339                .map_err(|e| BridgeError::GitCommand(e.to_string()))?;
340
341            if output.status.success() {
342                patches_exported += 1;
343            }
344        }
345
346        branches_exported += 1;
347    }
348
349    Ok(ExportResult {
350        patches_exported,
351        branches_exported,
352    })
353}
354
355/// Result of a Git export operation.
356#[derive(Debug, Clone)]
357pub struct ExportResult {
358    /// Number of patches exported.
359    pub patches_exported: usize,
360    /// Number of branches exported.
361    pub branches_exported: usize,
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_bridge_error_display() {
370        let err = BridgeError::GitCommand("git not found".to_string());
371        assert!(err.to_string().contains("git not found"));
372    }
373
374    #[test]
375    fn test_import_result_fields() {
376        let result = ImportResult {
377            patches_imported: 10,
378            branches_imported: 3,
379        };
380        assert_eq!(result.patches_imported, 10);
381        assert_eq!(result.branches_imported, 3);
382    }
383
384    #[test]
385    fn test_export_result_fields() {
386        let result = ExportResult {
387            patches_exported: 5,
388            branches_exported: 1,
389        };
390        assert_eq!(result.patches_exported, 5);
391        assert_eq!(result.branches_exported, 1);
392    }
393
394    #[test]
395    #[allow(deprecated)]
396    fn test_invalid_git_repo() {
397        let result = import_from_git(
398            Path::new("/nonexistent/path/to/git/repo"),
399            Path::new("/tmp/suture-test"),
400            "test",
401        );
402        assert!(result.is_err());
403    }
404}