///|
/// Apply a scaffolding edit under an exclusive workspace lock. Files and removed directories are
/// backed up until every replacement succeeds. A failed rollback preserves the lock for recovery.
pub fn apply_workspace_edit(
root : String,
tree : @workspace.Tree,
edit : @workspace.Edit,
) -> Unit raise {
apply_workspace_edit_with(root, tree, edit, @platform.link_file_new)
}
///|
fn apply_workspace_edit_with(
root : String,
tree : @workspace.Tree,
edit : @workspace.Edit,
publish_file : (String, String) -> Unit raise,
) -> Unit raise {
ignore(Mount::filesystem(root))
ignore(edit.apply(tree))
let lock = ".morphir-workspace-edit.lock"
for path in edit.writes.keys().collect() + edit.deletes + edit.directories {
if @workspace.within(lock, path) ||
path == "" ||
@workspace.normalize(path) != path {
raise @config.Invalid("Invalid workspace edit path: " + path)
}
}
@platform.mkdir(confined(root, lock))
let recovery = Ref(false)
defer (if !recovery.val { delete_owned(root, lock) })
let writes = edit.writes.keys().collect()
// Source files first, then member configs, then the workspace config.
writes.sort_by(fn(a, b) {
let a_existing = tree.files.contains(a)
let b_existing = tree.files.contains(b)
let a_config = a.has_suffix(".toml")
let b_config = b.has_suffix(".toml")
if a_existing != b_existing {
if a_existing {
1
} else {
-1
}
} else if a_config != b_config {
if a_config {
1
} else {
-1
}
} else if a_config {
b.length().compare(a.length())
} else {
a.compare(b)
}
})
let mut entries = 0
fn inspect_delete(path : String, depth : Int32) -> Unit raise {
entries += 1
if entries > 10000 || depth > 64 {
raise @config.Invalid("Workspace deletion entry/depth limit exceeded")
}
let full = confined(root, path)
match @platform.kind(full) {
1 => ()
2 =>
for child in @platform.list(full) {
inspect_delete(@workspace.join(path, child), depth + 1)
}
_ =>
raise @config.Invalid(
"Refusing to delete missing, linked or special entry: " + path,
)
}
}
// Complete preflight before writing any project files.
for path in writes {
let full = confined(root, path)
match tree.files.get(path) {
Some(expected) =>
if @platform.kind(full) != 1 || @platform.read(full) != expected {
raise @config.Invalid("Workspace changed since discovery: " + path)
}
None =>
if @platform.kind(full) != 0 {
raise @config.Invalid("Workspace edit would overwrite: " + path)
}
}
if edit.deletes.any(d => @workspace.within(d, path)) {
raise @config.Invalid(
"Workspace edit writes into a removed directory: " + path,
)
}
let staged = @workspace.join(lock + "/stage", path)
ensure_dirs(root, @workspace.parent(staged))
@platform.write(confined(root, staged), edit.writes[path])
}
for path in edit.deletes {
inspect_delete(path, 0)
}
let backups : Array[(String, String)] = []
let installed : Array[String] = []
let created_directories : Array[String] = []
fn ensure_edit_dirs(path : String) -> Unit raise {
let mut current = ""
for component in path.split("/") {
guard component != "" else { continue }
current = @workspace.join(current, component.to_owned())
let full = confined(root, current)
match @platform.kind(full) {
0 => {
@platform.mkdir(full)
created_directories.push(current)
}
2 => ()
_ => raise @config.Invalid("Expected output directory: " + full)
}
}
}
errdefer (try {
for i = installed.length() - 1; i >= 0; i = i - 1 {
let path = installed[i]
let full = confined(root, path)
if @platform.kind(full) != 1 || @platform.read(full) != edit.writes[path] {
raise @config.Invalid("Workspace changed during rollback: " + path)
}
@platform.remove_file(full)
}
for i = backups.length() - 1; i >= 0; i = i - 1 {
let (path, saved) = backups[i]
let source = confined(root, saved)
let destination = confined(root, path)
if @platform.kind(source) == 1 {
publish_file(source, destination)
} else {
@platform.rename(source, destination)
}
}
for i = created_directories.length() - 1; i >= 0; i = i - 1 {
// Remove only empty directories created by this edit. Concurrent contents
// refuse cleanup and keep the recovery lock rather than being deleted.
@platform.remove_dir(confined(root, created_directories[i]))
}
} catch {
rollback => {
recovery.val = true
raise @config.Invalid(
"Workspace edit rollback failed; preserved " +
lock +
": " +
error_message(rollback),
)
}
})
for path in edit.deletes {
let saved = @workspace.join(lock + "/deleted", path)
ensure_dirs(root, @workspace.parent(saved))
@platform.rename(confined(root, path), confined(root, saved))
backups.push((path, saved))
}
for directory in edit.directories {
ensure_edit_dirs(directory)
}
for path in writes {
ensure_edit_dirs(@workspace.parent(path))
let full = confined(root, path)
if tree.files.contains(path) {
if @platform.kind(full) != 1 || @platform.read(full) != tree.files[path] {
raise @config.Invalid("Workspace changed while applying edit: " + path)
}
let saved = @workspace.join(lock + "/backup", path)
ensure_dirs(root, @workspace.parent(saved))
@platform.rename(full, confined(root, saved))
backups.push((path, saved))
} else if @platform.kind(full) != 0 {
raise @config.Invalid("Workspace changed while applying edit: " + path)
}
// A hard link publishes the complete staged file only if the destination
// is absent. Keep its staging link until success or rollback cleanup.
publish_file(confined(root, @workspace.join(lock + "/stage", path)), full)
installed.push(path)
}
}