harn_vm/orchestration/playground/
init.rs1use std::path::Path;
19
20use serde_json::json;
21
22use crate::value::VmError;
23
24use super::git::{bare_file_url, GitOps};
25use super::manifest::{ScenarioBranch, ScenarioManifest, ScenarioRepo};
26use super::state::{
27 manifest_path, playground_marker_path, PlaygroundMarker, PlaygroundState, PLAYGROUND_TYPE,
28};
29
30fn default_seed_files(repo: &ScenarioRepo) -> std::collections::BTreeMap<String, String> {
34 if !repo.files.is_empty() {
35 return repo.files.clone();
36 }
37 let mut files = std::collections::BTreeMap::new();
38 files.insert(
39 "README.md".to_string(),
40 format!("# {}\n\nMerge Captain playground seed.\n", repo.name),
41 );
42 files
43}
44
45pub struct InitOptions<'a> {
46 pub dir: &'a Path,
47 pub manifest: &'a ScenarioManifest,
48 pub allow_existing: bool,
51}
52
53pub fn init_playground_at(options: InitOptions<'_>) -> Result<PlaygroundState, VmError> {
54 let dir = options.dir;
55 let manifest = options.manifest;
56 let marker = playground_marker_path(dir);
57 if marker.exists() && !options.allow_existing {
58 return Err(VmError::Runtime(format!(
59 "playground already initialized at {} (run `harn merge-captain mock cleanup {0}` first)",
60 dir.display()
61 )));
62 }
63 std::fs::create_dir_all(dir).map_err(|error| {
64 VmError::Runtime(format!(
65 "failed to create playground dir {}: {error}",
66 dir.display()
67 ))
68 })?;
69 std::fs::create_dir_all(dir.join("remotes"))
70 .map_err(|error| VmError::Runtime(format!("failed to create remotes dir: {error}")))?;
71 std::fs::create_dir_all(dir.join("working"))
72 .map_err(|error| VmError::Runtime(format!("failed to create working dir: {error}")))?;
73
74 let git = GitOps::default();
75 let mut state = PlaygroundState::from_manifest(manifest);
76
77 for repo in &manifest.repos {
78 materialize_repo(&git, dir, repo, &mut state)?;
79 }
80
81 let manifest_bytes = serde_json::to_vec_pretty(manifest)
84 .map_err(|error| VmError::Runtime(format!("failed to serialize manifest copy: {error}")))?;
85 let mut manifest_with_newline = manifest_bytes;
86 manifest_with_newline.push(b'\n');
87 std::fs::write(manifest_path(dir), manifest_with_newline)
88 .map_err(|error| VmError::Runtime(format!("failed to write manifest copy: {error}")))?;
89
90 let marker_value = PlaygroundMarker::new(&manifest.scenario, state.now_ms);
91 let mut marker_bytes = serde_json::to_vec_pretty(&marker_value).map_err(|error| {
92 VmError::Runtime(format!("failed to serialize playground marker: {error}"))
93 })?;
94 marker_bytes.push(b'\n');
95 std::fs::write(playground_marker_path(dir), marker_bytes)
96 .map_err(|error| VmError::Runtime(format!("failed to write playground marker: {error}")))?;
97
98 state.record(
99 "init",
100 json!({
101 "scenario": manifest.scenario,
102 "repos": manifest.repos.iter().map(|r| r.name.clone()).collect::<Vec<_>>(),
103 "pull_requests": manifest.pull_requests.len(),
104 }),
105 );
106 state.save(dir)?;
107
108 Ok(state)
109}
110
111fn materialize_repo(
112 git: &GitOps,
113 dir: &Path,
114 repo: &ScenarioRepo,
115 state: &mut PlaygroundState,
116) -> Result<(), VmError> {
117 let bare = dir.join("remotes").join(format!("{}.git", repo.name));
118 let working = dir.join("working").join(&repo.name);
119 git.init_bare(&bare, &repo.default_branch)?;
120 git.clone(&bare, &working)?;
121
122 let seed_files = default_seed_files(repo);
124 let seed_message = format!("Initial seed for {}", repo.name);
125 let seed_sha = git.commit_overlay(
126 &working,
127 &seed_files,
128 &[],
129 &seed_message,
130 Some(&repo.default_branch),
131 )?;
132
133 for extra in &repo.default_branch_extra_commits {
137 git.commit_overlay(
138 &working,
139 &extra.files_set,
140 &extra.files_delete,
141 &extra.message,
142 Some(&repo.default_branch),
143 )?;
144 }
145
146 for branch in &repo.branches {
148 materialize_branch(git, &working, repo, branch, &seed_sha)?;
149 }
150
151 let url = bare_file_url(&bare);
153 if let Some(repo_state) = state.repos.get_mut(&repo.name) {
154 repo_state.remote_url = url;
155 repo_state.remote_path = path_relative_to(&bare, dir);
156 repo_state.working_path = path_relative_to(&working, dir);
157 }
158
159 for pr in state
161 .pull_requests
162 .values_mut()
163 .filter(|pr| pr.repo == repo.name)
164 {
165 if pr.head_sha.is_some() {
166 continue;
167 }
168 let sha = git
169 .rev_parse(&working, &format!("origin/{}", pr.head_branch))
170 .ok();
171 pr.head_sha = sha;
172 }
173
174 Ok(())
175}
176
177fn materialize_branch(
178 git: &GitOps,
179 working: &Path,
180 repo: &ScenarioRepo,
181 branch: &ScenarioBranch,
182 seed_sha: &str,
183) -> Result<String, VmError> {
184 let base_branch = branch
185 .base
186 .clone()
187 .unwrap_or_else(|| repo.default_branch.clone());
188 let from_ref = if branch.fork_before_extra_commits {
189 seed_sha.to_string()
190 } else {
191 format!("origin/{base_branch}")
192 };
193 git.create_branch(working, &branch.name, &from_ref)?;
194 let message = branch
195 .commit_message
196 .clone()
197 .unwrap_or_else(|| format!("{} on {}", branch.name, repo.name));
198 let sha = git.commit_overlay(
199 working,
200 &branch.files_set,
201 &branch.files_delete,
202 &message,
203 Some(&branch.name),
204 )?;
205 git.checkout(working, &repo.default_branch)?;
208 Ok(sha)
209}
210
211fn path_relative_to(target: &Path, base: &Path) -> String {
212 target
213 .strip_prefix(base)
214 .map(|rel| rel.to_string_lossy().to_string())
215 .unwrap_or_else(|_| target.to_string_lossy().to_string())
216}
217
218pub fn cleanup_playground_at(dir: &Path) -> Result<bool, VmError> {
223 let marker = playground_marker_path(dir);
224 if !marker.exists() {
225 if !dir.exists() {
227 return Ok(false);
228 }
229 if dir
232 .read_dir()
233 .map(|mut r| r.next().is_none())
234 .unwrap_or(true)
235 {
236 return Ok(false);
238 }
239 return Err(VmError::Runtime(format!(
240 "{} does not look like a Merge Captain playground (missing playground.json marker); refusing to remove",
241 dir.display()
242 )));
243 }
244 let bytes = std::fs::read(&marker).map_err(|error| {
246 VmError::Runtime(format!(
247 "failed to read playground marker {}: {error}",
248 marker.display()
249 ))
250 })?;
251 let parsed: PlaygroundMarker = serde_json::from_slice(&bytes).map_err(|error| {
252 VmError::Runtime(format!(
253 "playground marker {} is malformed: {error}",
254 marker.display()
255 ))
256 })?;
257 if parsed.type_name != PLAYGROUND_TYPE {
258 return Err(VmError::Runtime(format!(
259 "playground marker {} has wrong _type {:?}; refusing to remove",
260 marker.display(),
261 parsed.type_name
262 )));
263 }
264 std::fs::remove_dir_all(dir).map_err(|error| {
265 VmError::Runtime(format!(
266 "failed to remove playground dir {}: {error}",
267 dir.display()
268 ))
269 })?;
270 Ok(true)
271}
272
273pub fn load_playground(dir: &Path) -> Result<(PlaygroundState, ScenarioManifest), VmError> {
276 let marker = playground_marker_path(dir);
277 if !marker.exists() {
278 return Err(VmError::Runtime(format!(
279 "{} is not a Merge Captain playground (missing playground.json)",
280 dir.display()
281 )));
282 }
283 let manifest_bytes = std::fs::read(manifest_path(dir)).map_err(|error| {
284 VmError::Runtime(format!(
285 "failed to read playground manifest {}: {error}",
286 manifest_path(dir).display()
287 ))
288 })?;
289 let manifest: ScenarioManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| {
290 VmError::Runtime(format!("failed to parse playground manifest copy: {error}"))
291 })?;
292 let state = PlaygroundState::load(dir)?;
293 Ok((state, manifest))
294}