///|
pub(all) struct IndexEntry {
path : String
blob_id : ObjectId
} derive(Eq, Debug)
///|
pub(all) struct Index {
entries : Array[IndexEntry]
} derive(Eq, Debug)
///|
pub struct Repository {
root : String
store : LazyObjectStore
}
///|
pub fn Repository::at(
root : String,
offline~ : Bool = false,
fetch_events~ : Bool = false,
) -> Repository {
{ root, store: LazyObjectStore::new(root, offline~, fetch_events~), }
}
///|
pub fn Repository::root(self : Repository) -> String {
self.root
}
///|
fn parent_directory(path : String) -> String {
if path == "/" {
return path
}
let mut index = path.length() - 1
while index > 0 && path[index] != '/' {
index = index - 1
}
if index == 0 {
"/"
} else {
path[:index].to_owned()
}
}
///|
pub async fn find_repository_root(start : String) -> String {
let mut current = @fs.realpath(start)
while true {
if @fs.exists(current + "/.moonvcs") {
return current
}
let parent = parent_directory(current)
if parent == current {
raise VcsError::RepositoryNotFound(start)
}
current = parent
}
raise VcsError::RepositoryNotFound(start)
}
///|
fn encode_index(index : Index) -> Bytes raise {
let entries = index.entries.copy()
entries.sort_by((left, right) => compare_utf8(left.path, right.path))
let encoder = Encoder::new()
encoder.bytes(b"MOONIDX\x00")
encoder.byte(1)
encoder.u32(entries.length())
let mut previous : String? = None
for entry in entries {
let canonical = normalize_repository_path(entry.path)
if canonical != entry.path || previous == Some(entry.path) {
raise VcsError::InvalidPath(entry.path)
}
previous = Some(entry.path)
encoder.string(entry.path)
encoder.object_id(entry.blob_id)
}
encoder.finish()
}
///|
fn decode_index(data : Bytes) -> Index raise {
let decoder = Decoder::new(data)
if decoder.bytes(8) != b"MOONIDX\x00" || decoder.byte() != 1 {
raise VcsError::InvalidObject("invalid index format")
}
let entries : Array[IndexEntry] = []
for _ in 0.. String {
root + "/.moonvcs/index"
}
///|
async fn sync_storage_directory(path : String) -> Unit {
let directory = @fs.open(path, mode=ReadOnly)
defer directory.close()
if directory.kind() != Directory {
raise VcsError::InvalidPath("directory sync expected a directory")
}
directory.sync()
}
///|
async fn sync_published_directory(path : String) -> Unit {
@async.protect_from_cancel(() => sync_storage_directory(path)) catch {
_ =>
raise VcsError::PublicationUncertain(
"rename succeeded but directory sync failed; inspect state before retry",
)
}
}
///|
async fn write_atomic(path : String, data : Bytes) -> Unit {
let temporary = path + ".lock"
let file = @fs.open(
temporary,
mode=WriteOnly,
create_mode=CreateNew,
permission=0o644,
sync=Full,
)
@async.with_task_group() <| group => {
let mut published = false
group.add_defer() <| () => {
@async.protect_from_cancel(() => {
if !published && @fs.exists(temporary) {
@fs.remove(temporary)
}
})
}
{
defer file.close()
file.write_at(data, position=0L)
file.sync()
}
@fs.rename(temporary, path, replace=true)
published = true
sync_published_directory(parent_directory(path))
}
}
///|
pub async fn init_repository(
root : String,
policy_config? : String,
) -> Repository {
let configuration = match policy_config {
Some(text) => text
None => default_policy_configuration()
}
ignore(parse_policy_context(configuration))
let canonical_root = @fs.realpath(root)
let metadata = canonical_root + "/.moonvcs"
if @fs.exists(metadata) {
raise VcsError::RepositoryExists(canonical_root)
}
@fs.mkdir(metadata + "/objects", recursive=true, permission=0o755)
@fs.mkdir(metadata + "/refs/heads", recursive=true, permission=0o755)
@fs.write_file(
metadata + "/HEAD",
"ref: refs/heads/main\n",
create_mode=CreateNew,
permission=0o644,
sync=Full,
)
write_atomic(index_path(canonical_root), encode_index({ entries: [], }))
write_atomic(metadata + "/policies.json", @utf8.encode(configuration))
sync_storage_directory(metadata + "/refs/heads")
sync_storage_directory(metadata + "/refs")
sync_storage_directory(metadata)
sync_storage_directory(canonical_root)
Repository::at(canonical_root)
}
///|
async fn default_policy_configuration() -> String {
let program = @env.args()[0]
let mut executable : String? = None
if program.contains("/") {
executable = Some(@fs.realpath(program))
} else if @env.get_env_var("PATH") is Some(paths) {
for directory in paths.split(":") {
let path = directory.to_owned() + "/" + program
if @fs.exists(path) {
executable = Some(@fs.realpath(path))
break
}
}
}
guard executable is Some(path) else {
raise VcsError::PolicyExecutionFailed(
"cannot locate installed policy runtime",
)
}
let defaults = parent_directory(path) + "/moonvcs-default-policy.json"
@fs.read_file(defaults).text() catch {
_ =>
raise VcsError::PolicyExecutionFailed(
"policy runtime is missing; run scripts/build-vcs.sh",
)
}
}
///|
async fn Repository::policy_configuration(self : Repository) -> String {
@fs.read_file(self.root + "/.moonvcs/policies.json").text() catch {
_ =>
raise VcsError::PolicyExecutionFailed(
"required-policy configuration is missing or unreadable",
)
}
}
///|
pub async fn Repository::load_index(self : Repository) -> Index {
decode_index(@fs.read_file(index_path(self.root)).binary())
}
///|
async fn Repository::write_index(self : Repository, index : Index) -> Unit {
write_atomic(index_path(self.root), encode_index(index))
}
///|
pub fn normalize_repository_path(path : String) -> String raise {
if path.length() == 0 ||
path.has_prefix("/") ||
path.contains("\\") ||
path.contains("\u0000") {
raise VcsError::InvalidPath(path)
}
let parts : Array[String] = []
for raw in path.split("/") {
let part = raw.to_owned()
if part == "" || part == "." {
continue
}
if part == ".." {
raise VcsError::InvalidPath(path)
}
parts.push(part)
}
if parts.is_empty() || parts[0] == ".moonvcs" {
raise VcsError::InvalidPath(path)
}
parts.join("/")
}
///|
fn within_root(root : String, real : String) -> Bool {
real == root || real.has_prefix(root + "/")
}
///|
async fn reject_symlink_components(root : String, canonical : String) -> Unit {
let mut current = root
for part in canonical.split("/") {
current = current + "/" + part.to_owned()
if @fs.exists(current) && @fs.kind(current, follow_symlink=false) == SymLink {
raise VcsError::InvalidPath(
"symbolic links are not supported: \{canonical}",
)
}
}
}
///|
async fn collect_files(
root : String,
relative : String,
output : Array[String],
) -> Unit {
let canonical = if relative == "." {
""
} else {
normalize_repository_path(relative)
}
let full = if canonical == "" { root } else { root + "/" + canonical }
if !@fs.exists(full) {
raise VcsError::InvalidPath("path does not exist: \{relative}")
}
reject_symlink_components(root, canonical)
let real = @fs.realpath(full)
if !within_root(root, real) {
raise VcsError::InvalidPath("path escapes repository: \{relative}")
}
let kind = @fs.kind(full, follow_symlink=false)
match kind {
SymLink =>
raise VcsError::InvalidPath(
"symbolic links are not supported: \{relative}",
)
Regular => output.push(canonical)
Directory => {
let names = @fs.readdir(
full,
include_hidden=true,
include_special=false,
sort=true,
)
for name in names {
if canonical == "" && name == ".moonvcs" {
continue
}
let child = if canonical == "" { name } else { canonical + "/" + name }
collect_files(root, child, output)
}
}
_ => raise VcsError::InvalidPath("unsupported file type: \{relative}")
}
}
///|
async fn acquire_staging_lock(root : String) -> @fs.File {
// Shared writer domain: add/commit/branch/switch and receive publication.
assert_no_switch(root)
if checkout_kind(root + "/.moonvcs/staging-transaction.lock") is Some(kind) &&
kind != Regular {
raise VcsError::CheckoutFailed("transaction lock must be a regular file")
}
let file = @fs.open(
root + "/.moonvcs/staging-transaction.lock",
mode=ReadWrite,
create_mode=OpenOrCreate,
permission=0o600,
)
errdefer file.close()
@async.with_timeout(30000, () => {
while !file.try_lock(Exclusive) {
@async.sleep(5)
}
}) catch {
error => {
if @async.is_cancellation_error(error) {
raise error
}
raise VcsError::StagingBusy("staging transaction lock timed out")
}
}
assert_no_switch(root)
file
}
///|
pub async fn Repository::add(
self : Repository,
paths : Array[String],
) -> Array[IndexEntry] {
if paths.is_empty() {
raise VcsError::InvalidCommand("add requires at least one path")
}
let transaction = acquire_staging_lock(self.root)
defer transaction.close()
defer transaction.unlock()
let index = self.load_index()
let staged : Map[String, ObjectId] = Map([])
for entry in index.entries {
staged[entry.path] = entry.blob_id
}
let added : Array[IndexEntry] = []
for input in paths {
let canonical = if input == "." {
""
} else {
normalize_repository_path(input)
}
let stale : Array[String] = []
staged.each((path, _) => {
if canonical == "" ||
path == canonical ||
path.has_prefix(canonical + "/") {
stale.push(path)
}
})
for path in stale {
staged.remove(path)
}
let full = if canonical == "" {
self.root
} else {
self.root + "/" + canonical
}
if !@fs.exists(full) {
if stale.is_empty() {
raise VcsError::InvalidPath("path does not exist: \{input}")
}
continue
}
let files : Array[String] = []
collect_files(self.root, input, files)
for path in files {
let content = @fs.read_file(self.root + "/" + path).binary()
let id = self.store.put(BlobObject({ content, }))
staged[path] = id
added.push({ path, blob_id: id, })
}
}
let entries : Array[IndexEntry] = []
staged.each((path, blob_id) => entries.push({ path, blob_id, }))
self.write_index({ entries, })
added.sort_by((left, right) => compare(left.path, right.path))
added
}
///|
priv struct DirectoryNode {
files : Map[String, ObjectId]
directories : Map[String, DirectoryNode]
}
///|
fn DirectoryNode::new() -> DirectoryNode {
{ files: {}, directories: {}, }
}
///|
fn DirectoryNode::insert(
self : DirectoryNode,
parts : ArrayView[String],
id : ObjectId,
) -> Unit raise {
if parts.length() == 1 {
let name = parts[0]
if self.directories.contains(name) {
raise VcsError::InvalidTree("path is both file and directory: \{name}")
}
self.files[name] = id
return
}
let name = parts[0]
if self.files.contains(name) {
raise VcsError::InvalidTree("path is both file and directory: \{name}")
}
let child = self.directories.get_or_init(name, DirectoryNode::new)
child.insert(parts[1:], id)
}
///|
async fn[S : ObjectStore] write_directory(
store : S,
node : DirectoryNode,
) -> ObjectId {
let entries : Array[TreeEntry] = []
node.files.each((name, object_id) => {
entries.push({ name, kind: BlobEntry, object_id, })
})
for (name, child) in node.directories {
let object_id = write_directory(store, child)
entries.push({ name, kind: TreeEntry, object_id, })
}
store.put(TreeObject(Tree::new(entries)))
}
///|
async fn[S : ObjectStore] build_tree(store : S, index : Index) -> ObjectId {
let root = DirectoryNode::new()
for entry in index.entries {
let parts = entry.path.split("/").map(part => part.to_owned()).collect()
root.insert(parts, entry.blob_id)
}
write_directory(store, root)
}
///|
fn ref_path(root : String, name : String) -> String raise {
if !name.has_prefix("refs/heads/") ||
name.contains("..") ||
name.contains("\\") ||
name.contains("\u0000") {
raise VcsError::InvalidPath("invalid ref: \{name}")
}
for part in name.split("/") {
if part.is_empty() || part.has_prefix(".") || part.has_suffix(".lock") {
raise VcsError::InvalidPath("invalid ref: \{name}")
}
for char in part {
if char <= ' ' || char == '\u007f' {
raise VcsError::InvalidPath("invalid ref: \{name}")
}
}
}
root + "/.moonvcs/" + name
}
///|
pub async fn Repository::head_ref_name(self : Repository) -> String {
let content = @fs.read_file(self.root + "/.moonvcs/HEAD")
.text()
.trim()
.to_owned()
guard content.has_prefix("ref: ") else {
raise VcsError::InvalidObject("detached HEAD is not supported")
}
content[5:].to_owned()
}
///|
pub async fn Repository::read_ref(
self : Repository,
name : String,
) -> ObjectId? {
let path = ref_path(self.root, name)
if !@fs.exists(path) {
return None
}
Some(ObjectId::parse(@fs.read_file(path).text().trim().to_owned()))
}
///|
pub async fn Repository::head(self : Repository) -> ObjectId? {
self.read_ref(self.head_ref_name())
}
///|
async fn Repository::update_ref(
self : Repository,
name : String,
expected : ObjectId?,
target : ObjectId,
) -> Unit {
assert_no_switch(self.root)
guard self.store.get(target) is CommitObject(_) else {
raise VcsError::InvalidObject("ref target must be a stored commit")
}
let path = ref_path(self.root, name)
let lock = path + ".lock"
let file = @fs.open(
lock,
mode=WriteOnly,
create_mode=CreateNew,
permission=0o644,
sync=Full,
)
@async.with_task_group() <| group => {
let mut published = false
group.add_defer() <| () => {
@async.protect_from_cancel(() => {
if !published && @fs.exists(lock) {
@fs.remove(lock)
}
})
}
{
defer file.close()
file.write_at(@utf8.encode(target.hex + "\n"), position=0L)
file.sync()
}
let actual = self.read_ref(name)
if actual != expected {
raise VcsError::RefConflict(name)
}
@fs.rename(lock, path, replace=true)
published = true
sync_published_directory(parent_directory(path))
}
}
///|
pub async fn Repository::make_candidate(
self : Repository,
author : String,
timestamp : UInt64,
message : String,
) -> CommitCandidate {
assert_no_switch(self.root)
if message.trim().length() == 0 {
raise VcsError::InvalidCommand("commit message must not be empty")
}
let index = self.load_index()
let tree_id = build_tree(self.store, index)
let parents = match self.head() {
Some(id) =>
match self.store.get(id) {
CommitObject(parent) => {
if parent.tree_id == tree_id {
raise VcsError::InvalidCommand("nothing is staged")
}
[id]
}
_ => raise VcsError::InvalidObject("HEAD does not identify a commit")
}
None => {
if index.entries.is_empty() {
raise VcsError::InvalidCommand("nothing is staged")
}
[]
}
}
let staged_files : Array[CandidateFile] = []
for entry in index.entries {
match self.store.get(entry.blob_id) {
BlobObject(blob) =>
staged_files.push({
path: entry.path,
blob_id: entry.blob_id,
content: blob.content,
})
_ =>
raise VcsError::InvalidObject(
"index entry is not a blob: \{entry.path}",
)
}
}
{ tree_id, parents, author, timestamp, message, staged_files, }
}
///|
pub async fn[V : PolicyValidator] Repository::commit_with_validator(
self : Repository,
author : String,
timestamp : UInt64,
message : String,
validator : V,
) -> ObjectId {
let transaction = acquire_staging_lock(self.root)
defer transaction.close()
defer transaction.unlock()
let ref_name = self.head_ref_name()
let candidate = self.make_candidate(author, timestamp, message)
let configuration = self.policy_configuration()
let context = parse_policy_context(configuration)
// Freeze the persisted object before any caller-supplied validator runs.
let commit : Commit = {
tree_id: candidate.tree_id,
parents: candidate.parents.copy(),
author: candidate.author,
timestamp: candidate.timestamp,
message: candidate.message,
}
match validate_policies(candidate, context) {
Accept => ()
Warning(diagnostics) => println(format_policy_diagnostics(diagnostics))
Reject(diagnostics) =>
raise VcsError::PolicyRejected(format_policy_diagnostics(diagnostics))
EngineError(reason) => raise VcsError::PolicyExecutionFailed(reason)
}
match validator.validate(candidate) {
PolicyPass => ()
PolicyReject(reason) => raise VcsError::PolicyRejected(reason)
PolicyError(reason) =>
raise VcsError::PolicyRejected("policy error: \{reason}")
}
if self.policy_configuration() != configuration {
raise VcsError::PolicyExecutionFailed(
"required-policy configuration changed during validation",
)
}
let commit_id = self.store.put(CommitObject(commit))
let expected = match commit.parents {
[parent] => Some(parent)
_ => None
}
if self.head_ref_name() != ref_name {
raise VcsError::RefConflict("HEAD changed during policy validation")
}
self.update_ref(ref_name, expected, commit_id)
commit_id
}
///|
pub async fn Repository::commit(
self : Repository,
author : String,
timestamp : UInt64,
message : String,
) -> ObjectId {
self.commit_with_validator(
author,
timestamp,
message,
MockPolicyValidator::pass(),
)
}
///|
pub async fn Repository::log(
self : Repository,
max_count~ : Int32 = 2147483647,
) -> Array[(ObjectId, Commit)] {
assert_no_switch(self.root)
if max_count < 0 {
raise VcsError::InvalidCommand("negative log max-count")
}
let commits : Array[(ObjectId, Commit)] = []
let mut next = self.head()
while next is Some(id) && commits.length() < max_count {
match self.store.get(id) {
CommitObject(commit) => {
commits.push((id, commit))
next = match commit.parents {
[parent, ..] => Some(parent)
[] => None
}
}
_ =>
raise VcsError::InvalidObject(
"ref does not point to a commit: \{id.hex}",
)
}
}
commits
}
///|
async fn[S : ObjectStore] flatten_tree(
store : S,
tree_id : ObjectId,
prefix : String,
output : Map[String, ObjectId],
) -> Unit {
match store.get(tree_id) {
TreeObject(tree) =>
for entry in tree.entries {
let path = if prefix == "" {
entry.name
} else {
prefix + "/" + entry.name
}
match entry.kind {
BlobEntry => output[path] = entry.object_id
TreeEntry => flatten_tree(store, entry.object_id, path, output)
}
}
_ => raise VcsError::InvalidObject("tree id does not identify a tree")
}
}
///|
pub async fn Repository::status(self : Repository) -> Array[String] {
assert_no_switch(self.root)
let index = self.load_index()
let head_entries : Map[String, ObjectId] = Map([])
if self.head() is Some(head_id) {
match self.store.get(head_id) {
CommitObject(commit) =>
flatten_tree(self.store, commit.tree_id, "", head_entries)
_ => raise VcsError::InvalidObject("HEAD does not identify a commit")
}
}
let lines : Array[String] = []
// Validate every working path before reading tracked bytes, including ancestors.
let working_files : Array[String] = []
collect_files(self.root, ".", working_files)
let staged_entries : Map[String, ObjectId] = Map([])
for entry in index.entries {
staged_entries[entry.path] = entry.blob_id
if head_entries.get(entry.path) != Some(entry.blob_id) {
lines.push("staged " + entry.path)
}
let full = self.root + "/" + entry.path
if !@fs.exists(full) {
lines.push("deleted " + entry.path)
} else if @fs.kind(full, follow_symlink=false) == Regular {
let working = object_id_for(
BlobObject({ content: @fs.read_file(full).binary(), }),
)
if working != entry.blob_id {
lines.push("modified " + entry.path)
}
} else {
lines.push("typechanged " + entry.path)
}
}
head_entries.each((path, _) => {
if !staged_entries.contains(path) {
lines.push("staged delete " + path)
}
})
for path in working_files {
if !staged_entries.contains(path) {
lines.push("untracked " + path)
}
}
lines.sort()
lines
}
///|
pub async fn Repository::get_object(
self : Repository,
id : ObjectId,
) -> VcsObject {
assert_no_switch(self.root)
self.store.get(id)
}