mod support;
use serde_json::{Value, json};
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Output},
};
struct Fixture(PathBuf);
impl Fixture {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Fixture {
fn drop(&mut self) {
support::remove_dir_all(&self.0);
}
}
fn fixture() -> Fixture {
let path = std::env::temp_dir().join(format!("shepherd-run-adoption-{}", uuid::Uuid::now_v7()));
fs::create_dir(&path).expect("fixture");
let temp = Fixture(fs::canonicalize(path).unwrap());
assert!(
Command::new("git")
.args(["init", "--quiet"])
.current_dir(temp.path())
.status()
.unwrap()
.success()
);
fs::create_dir_all(temp.path().join(".shepherd/runs/v657/dispatch")).unwrap();
fs::write(
temp.path().join(".shepherd/runs/v657/run.json"),
serde_json::to_vec(&json!({
"schema_version": 1, "run": "v657", "kind": "patch-arc", "branch": "v6.5.7",
"base": "main", "seed": "seed.md", "plan": "", "status": "planted", "lanes": [],
"updated_at": 42, "operator_note": {"preserve": true}
}))
.unwrap(),
)
.unwrap();
fs::write(
temp.path().join(".shepherd/runs/v657/seed.md"),
b"legacy seed, preserve exactly\n",
)
.unwrap();
temp
}
fn invoke(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_shepherd"))
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.output()
.unwrap()
}
fn state(root: &Path) -> Value {
serde_json::from_slice(&fs::read(root.join(".shepherd/runs/v657/run.json")).unwrap()).unwrap()
}
fn registry(root: &Path) -> Value {
serde_json::from_slice(
&fs::read(root.join(".shepherd/native-orientation-registry.json")).unwrap(),
)
.unwrap()
}
#[test]
fn explicit_legacy_adoption_preserves_artifacts_and_replays_one_native_identity() {
let temp = fixture();
let root = temp.path();
let original = state(root);
let seed = fs::read(root.join(".shepherd/runs/v657/seed.md")).unwrap();
let first = invoke(root, &["run", "migrate", "v657", "--adopt-native"]);
assert!(
first.status.success(),
"{}",
String::from_utf8_lossy(&first.stderr)
);
let adopted = state(root);
assert_eq!(adopted["status"], "planted");
assert_eq!(adopted["operator_note"], original["operator_note"]);
assert_eq!(adopted["updated_at"], original["updated_at"]);
assert_eq!(adopted["seed"], original["seed"]);
assert_eq!(adopted["orientation_epoch"], 0);
assert_eq!(adopted["run_incarnation"].as_str().unwrap().len(), 32);
let native = registry(root);
assert_eq!(
native["runs"]["v657"]["run_incarnation"],
adopted["run_incarnation"]
);
assert_eq!(
native["runs"]["v657"]["legacy_adoption_sha256"]
.as_str()
.unwrap()
.len(),
64
);
assert!(native["runs"]["v657"]["pre"].is_null());
assert!(native["runs"]["v657"]["post"].is_null());
assert_eq!(
seed,
fs::read(root.join(".shepherd/runs/v657/seed.md")).unwrap()
);
let second = invoke(root, &["run", "migrate", "v657", "--adopt-native"]);
assert!(
second.status.success(),
"{}",
String::from_utf8_lossy(&second.stderr)
);
assert_eq!(adopted, state(root));
assert_eq!(native, registry(root));
}
#[test]
fn adoption_cannot_import_execution_checkpoints_or_child_authority() {
for (field, value) in [
("status", json!("executing")),
("orientation_epoch", json!(1)),
("run_incarnation", json!("caller-chosen-incarnation")),
("lanes", json!([{"id":"old", "state":"pending"}])),
] {
let temp = fixture();
let mut document = state(temp.path());
document[field] = value;
let path = temp.path().join(".shepherd/runs/v657/run.json");
fs::write(&path, serde_json::to_vec(&document).unwrap()).unwrap();
let before = fs::read(&path).unwrap();
let result = invoke(temp.path(), &["run", "migrate", "v657", "--adopt-native"]);
assert!(!result.status.success(), "accepted {field}");
assert_eq!(before, fs::read(path).unwrap());
assert!(
!temp
.path()
.join(".shepherd/native-orientation-registry.json")
.exists()
);
}
for name in ["child.json", "pending-invalid.json"] {
let temp = fixture();
fs::write(
temp.path().join(".shepherd/runs/v657/dispatch").join(name),
b"{}",
)
.unwrap();
let before = state(temp.path());
let result = invoke(temp.path(), &["run", "migrate", "v657", "--adopt-native"]);
assert!(!result.status.success(), "accepted {name}");
assert_eq!(before, state(temp.path()));
assert!(
!temp
.path()
.join(".shepherd/native-orientation-registry.json")
.exists()
);
}
}
#[test]
fn ordinary_migration_does_not_adopt_and_bulk_adoption_is_refused() {
let temp = fixture();
let result = invoke(temp.path(), &["run", "migrate", "v657"]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(state(temp.path())["run_incarnation"], "");
assert!(
!temp
.path()
.join(".shepherd/native-orientation-registry.json")
.exists()
);
let before = state(temp.path());
let bulk = invoke(temp.path(), &["run", "migrate", "--all", "--adopt-native"]);
assert!(!bulk.status.success());
assert_eq!(before, state(temp.path()));
assert!(
!temp
.path()
.join(".shepherd/native-orientation-registry.json")
.exists()
);
}
#[test]
fn native_adoption_intent_cannot_be_reused_for_changed_or_unrelated_authority() {
let temp = fixture();
let result = invoke(temp.path(), &["run", "migrate", "v657", "--adopt-native"]);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let before_native = registry(temp.path());
let path = temp.path().join(".shepherd/runs/v657/run.json");
let mut changed = state(temp.path());
changed["run_incarnation"] = json!("");
changed["branch"] = json!("different-source");
fs::write(&path, serde_json::to_vec(&changed).unwrap()).unwrap();
let result = invoke(temp.path(), &["run", "migrate", "v657", "--adopt-native"]);
assert!(!result.status.success());
assert_eq!(changed, state(temp.path()));
assert_eq!(before_native, registry(temp.path()));
let mut unrelated = before_native.clone();
unrelated["runs"]["v657"]
.as_object_mut()
.unwrap()
.remove("legacy_adoption_sha256");
let registry_path = temp
.path()
.join(".shepherd/native-orientation-registry.json");
fs::write(®istry_path, serde_json::to_vec(&unrelated).unwrap()).unwrap();
let result = invoke(temp.path(), &["run", "migrate", "v657", "--adopt-native"]);
assert!(!result.status.success());
assert_eq!(unrelated, registry(temp.path()));
}