Skip to main content

panproto_git/
export.rs

1//! Export panproto-vcs repositories to git.
2//!
3//! Takes a panproto-vcs commit and creates corresponding git tree and commit
4//! objects. The schema is serialized as JSON (the authoritative structural
5//! representation) alongside any cached source text from the import.
6
7use panproto_vcs::{Object, ObjectId, Store};
8use rustc_hash::FxHashMap;
9
10use crate::error::GitBridgeError;
11
12/// Result of exporting a panproto-vcs commit to git.
13#[derive(Debug)]
14pub struct ExportResult {
15    /// The git commit OID that was created.
16    pub git_oid: git2::Oid,
17    /// Number of files exported.
18    pub file_count: usize,
19}
20
21/// Export a panproto-vcs commit as a git commit.
22///
23/// Loads the schema from the panproto commit and serializes it into the git tree.
24/// If a `parent_map` is provided (mapping panproto parent commit IDs to git OIDs),
25/// the exported git commit will have the correct parent pointers, preserving the
26/// DAG structure.
27///
28/// The schema is stored as a JSON file in the git tree. This is the authoritative
29/// representation; source text reconstruction requires re-parsing with the
30/// appropriate language parser.
31///
32/// # Errors
33///
34/// Returns [`GitBridgeError`] if VCS operations or git operations fail.
35pub fn export_to_git<S: Store, H: std::hash::BuildHasher>(
36    panproto_store: &S,
37    git_repo: &git2::Repository,
38    commit_id: ObjectId,
39    parent_map: &std::collections::HashMap<ObjectId, git2::Oid, H>,
40) -> Result<ExportResult, GitBridgeError> {
41    // Load the commit.
42    let commit_obj = panproto_store.get(&commit_id)?;
43    let commit = match &commit_obj {
44        Object::Commit(c) => c,
45        other => {
46            return Err(GitBridgeError::ObjectRead {
47                oid: commit_id.to_string(),
48                reason: format!("expected commit, got {}", other.type_name()),
49            });
50        }
51    };
52
53    // Load the schema.
54    let schema_obj = panproto_store.get(&commit.schema_id)?;
55    let schema = match &schema_obj {
56        Object::Schema(s) => s,
57        other => {
58            return Err(GitBridgeError::ObjectRead {
59                oid: commit.schema_id.to_string(),
60                reason: format!("expected schema, got {}", other.type_name()),
61            });
62        }
63    };
64
65    // Build the git tree.
66    // The schema is serialized as JSON, which is the authoritative structural
67    // representation of the project. Each vertex, edge, and constraint is preserved.
68    let mut tree_builder = git_repo.treebuilder(None)?;
69    let mut file_count = 0;
70
71    // Serialize the schema as pretty-printed JSON.
72    let schema_json =
73        serde_json::to_vec_pretty(schema.as_ref()).map_err(|e| GitBridgeError::ObjectRead {
74            oid: commit.schema_id.to_string(),
75            reason: format!("JSON serialization failed: {e}"),
76        })?;
77    let blob_oid = git_repo.blob(&schema_json)?;
78    tree_builder.insert("schema.json", blob_oid, 0o100_644)?;
79    file_count += 1;
80
81    // Also store commit metadata.
82    let commit_json =
83        serde_json::to_vec_pretty(commit).map_err(|e| GitBridgeError::ObjectRead {
84            oid: commit_id.to_string(),
85            reason: format!("commit JSON serialization failed: {e}"),
86        })?;
87    let commit_blob = git_repo.blob(&commit_json)?;
88    tree_builder.insert("commit.json", commit_blob, 0o100_644)?;
89    file_count += 1;
90
91    let files_fragments = collect_file_fragments(schema);
92    let mut file_blobs: FxHashMap<String, git2::Oid> = FxHashMap::default();
93
94    // Write reconstructed source files.
95    for (file_path, mut fragments) in files_fragments {
96        fragments.sort_by_key(|(s, _)| *s);
97
98        let mut content = Vec::new();
99        let mut cursor = 0;
100        for (pos, text) in &fragments {
101            if *pos >= cursor {
102                content.extend_from_slice(text.as_bytes());
103                cursor = pos + text.len();
104            }
105        }
106
107        if !content.is_empty() {
108            let blob_oid = git_repo.blob(&content)?;
109            file_blobs.insert(file_path, blob_oid);
110            file_count += 1;
111        }
112    }
113
114    // Build nested git tree structure from file paths.
115    // Group files by their directory prefix and create subtrees.
116    build_nested_tree(git_repo, &mut tree_builder, &file_blobs)?;
117
118    let tree_oid = tree_builder.write()?;
119    let tree = git_repo.find_tree(tree_oid)?;
120
121    // Create git commit signature.
122    let sig = git2::Signature::new(
123        &commit.author,
124        &format!("{}@panproto", commit.author),
125        &git2::Time::new(i64::try_from(commit.timestamp).unwrap_or(i64::MAX), 0),
126    )?;
127
128    // Resolve parent git commits from the mapping.
129    let mut parents: Vec<git2::Commit<'_>> = Vec::new();
130    for parent_panproto_id in &commit.parents {
131        if let Some(parent_git_oid) = parent_map.get(parent_panproto_id) {
132            if let Ok(parent_commit) = git_repo.find_commit(*parent_git_oid) {
133                parents.push(parent_commit);
134            }
135        }
136    }
137    let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
138
139    let git_oid = git_repo.commit(
140        Some("HEAD"),
141        &sig,
142        &sig,
143        &commit.message,
144        &tree,
145        &parent_refs,
146    )?;
147
148    Ok(ExportResult {
149        git_oid,
150        file_count,
151    })
152}
153
154/// Build a nested git tree structure from a flat map of file paths to blob OIDs.
155///
156/// For paths like `"src/main.ts"`, this creates a subtree `"src"` containing
157/// the blob `"main.ts"`. Deeply nested paths create multiple levels of subtrees.
158/// Collect all text fragments (leaf literals + interstitial text) per file
159/// from a schema, grouped by file prefix.
160fn collect_file_fragments(
161    schema: &panproto_schema::Schema,
162) -> FxHashMap<String, Vec<(usize, String)>> {
163    let mut files_fragments: FxHashMap<String, Vec<(usize, String)>> = FxHashMap::default();
164
165    for name in schema.vertices.keys() {
166        if let Some(constraints) = schema.constraints.get(name) {
167            let name_str = name.as_ref();
168            let file_prefix = name_str
169                .find("::")
170                .map_or(name_str, |pos| &name_str[..pos])
171                .to_owned();
172
173            let start_byte = constraints
174                .iter()
175                .find(|c| c.sort.as_ref() == "start-byte")
176                .and_then(|c| c.value.parse::<usize>().ok());
177            let literal = constraints
178                .iter()
179                .find(|c| c.sort.as_ref() == "literal-value")
180                .map(|c| c.value.clone());
181            if let (Some(start), Some(text)) = (start_byte, literal) {
182                files_fragments
183                    .entry(file_prefix.clone())
184                    .or_default()
185                    .push((start, text));
186            }
187
188            for c in constraints {
189                let sort_str = c.sort.as_ref();
190                if sort_str.starts_with("interstitial-") && !sort_str.ends_with("-start-byte") {
191                    let pos_sort = format!("{sort_str}-start-byte");
192                    let pos = constraints
193                        .iter()
194                        .find(|c2| c2.sort.as_ref() == pos_sort.as_str())
195                        .and_then(|c2| c2.value.parse::<usize>().ok());
196                    if let Some(p) = pos {
197                        files_fragments
198                            .entry(file_prefix.clone())
199                            .or_default()
200                            .push((p, c.value.clone()));
201                    }
202                }
203            }
204        }
205    }
206
207    files_fragments
208}
209
210fn build_nested_tree(
211    repo: &git2::Repository,
212    root_builder: &mut git2::TreeBuilder<'_>,
213    file_blobs: &FxHashMap<String, git2::Oid>,
214) -> Result<(), GitBridgeError> {
215    // Group files by top-level directory.
216    let mut dirs: FxHashMap<String, Vec<(String, git2::Oid)>> = FxHashMap::default();
217    let mut root_files: Vec<(String, git2::Oid)> = Vec::new();
218
219    for (path, oid) in file_blobs {
220        if let Some(slash_pos) = path.find('/') {
221            let dir = &path[..slash_pos];
222            let rest = &path[slash_pos + 1..];
223            dirs.entry(dir.to_owned())
224                .or_default()
225                .push((rest.to_owned(), *oid));
226        } else {
227            root_files.push((path.clone(), *oid));
228        }
229    }
230
231    // Insert root-level files directly.
232    for (name, oid) in &root_files {
233        root_builder.insert(name, *oid, 0o100_644)?;
234    }
235
236    // Recursively build subtrees for directories.
237    for (dir_name, entries) in &dirs {
238        let subtree_oid = build_subtree(repo, entries)?;
239        root_builder.insert(dir_name, subtree_oid, 0o040_000)?;
240    }
241
242    Ok(())
243}
244
245/// Recursively build a git subtree from a list of (`relative_path`, `blob_oid`) entries.
246fn build_subtree(
247    repo: &git2::Repository,
248    entries: &[(String, git2::Oid)],
249) -> Result<git2::Oid, GitBridgeError> {
250    let mut builder = repo.treebuilder(None)?;
251
252    // Separate files from subdirectories.
253    let mut subdirs: FxHashMap<String, Vec<(String, git2::Oid)>> = FxHashMap::default();
254    let mut files: Vec<(String, git2::Oid)> = Vec::new();
255
256    for (path, oid) in entries {
257        if let Some(slash_pos) = path.find('/') {
258            let dir = &path[..slash_pos];
259            let rest = &path[slash_pos + 1..];
260            subdirs
261                .entry(dir.to_owned())
262                .or_default()
263                .push((rest.to_owned(), *oid));
264        } else {
265            files.push((path.clone(), *oid));
266        }
267    }
268
269    for (name, oid) in &files {
270        builder.insert(name, *oid, 0o100_644)?;
271    }
272
273    for (dir_name, sub_entries) in &subdirs {
274        let subtree_oid = build_subtree(repo, sub_entries)?;
275        builder.insert(dir_name, subtree_oid, 0o040_000)?;
276    }
277
278    Ok(builder.write()?)
279}