Skip to main content

harn_vm/orchestration/playground/
init.rs

1//! Materialize a Merge Captain mock-repos playground (#1020).
2//!
3//! `init_playground_at(dir, manifest)` creates:
4//!
5//! ```text
6//! <dir>/
7//!   playground.json   (marker — versioned, idempotent rejection key)
8//!   manifest.json     (canonicalized seed manifest)
9//!   state.json        (mutable playground state — what the fake server reads)
10//!   remotes/<repo>.git/   (bare remotes)
11//!   working/<repo>/       (working clones)
12//! ```
13//!
14//! The captain (and, in the harn-github-connector repo, real connector code)
15//! exercises `file://` remotes so every `git fetch / push / rebase /
16//! force-with-lease` runs against real git.
17
18use 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
30/// Default contents written for a repo's seed README when the manifest
31/// doesn't supply any files. Keeps the bare clone non-empty so the first
32/// `git push` lands on a real commit.
33fn 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    /// When true, rejects re-init of an existing playground. Default for the
49    /// CLI; tests typically clear the dir first.
50    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    // Persist a canonicalized manifest copy so subsequent `step` and `serve`
82    // commands work without the original file path.
83    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    // Seed the default branch — first commit of the bare remote.
123    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    // Optional extra commits *before* feature branches are forked. We keep
134    // `seed_sha` so any branch that wants `fork_before_extra_commits == true`
135    // can reset to it instead of `origin/<default>`.
136    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    // Feature branches.
147    for branch in &repo.branches {
148        materialize_branch(git, &working, repo, branch, &seed_sha)?;
149    }
150
151    // Update state for this repo.
152    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    // Resolve head_sha for any PRs that reference branches in this repo.
160    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    // Return to the default branch so subsequent operations don't accumulate
206    // on a feature branch.
207    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
218/// Idempotent cleanup. Removes the playground directory entirely if it
219/// looks like one (i.e. has a `playground.json` marker with the right
220/// `_type`). Refuses to delete arbitrary directories — returns an error
221/// instead of removing anything.
222pub fn cleanup_playground_at(dir: &Path) -> Result<bool, VmError> {
223    let marker = playground_marker_path(dir);
224    if !marker.exists() {
225        // Idempotent: cleanup of a non-playground or already-cleaned dir is OK.
226        if !dir.exists() {
227            return Ok(false);
228        }
229        // If the dir exists but has no marker, refuse — don't `rm -rf`
230        // arbitrary user content.
231        if dir
232            .read_dir()
233            .map(|mut r| r.next().is_none())
234            .unwrap_or(true)
235        {
236            // empty dir is fine to leave alone (and idempotent).
237            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    // Verify the marker before removing.
245    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
273/// Verify that a directory hosts a playground; load and return the
274/// state and manifest if so.
275pub 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}