2033 lines
70 KiB
Rust
2033 lines
70 KiB
Rust
//! Repository-side commands: init, track, forget, commit, etc.
|
|
|
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{anyhow, bail, Result};
|
|
|
|
use levcs_core::object::{ObjectType, SignedObject};
|
|
use levcs_core::refs::Head;
|
|
use levcs_core::{
|
|
Blob, Commit, CommitFlags, Index, IndexEntry, IndexEntryFlags, ObjectId, Refs, Release,
|
|
Repository, Tree, ZERO_ID,
|
|
};
|
|
use levcs_identity::authority::{
|
|
AuthorityBody, MemberEntry, PolicyEntry, Role, AUTHORITY_SCHEMA_VERSION,
|
|
};
|
|
use levcs_identity::keys::{PublicKey, SecretKey};
|
|
use levcs_identity::sign::{sign_authority, sign_commit, sign_release};
|
|
use levcs_identity::verify::{verify_authority_chain, verify_commit};
|
|
use levcs_merge::engine::validate_record_against_policy;
|
|
use levcs_merge::{
|
|
CascadeEngine, FileRecord, FileStatus, MergeConfig, MergeRecord, MergeResult, MergeStatus,
|
|
};
|
|
|
|
use crate::cli::*;
|
|
use crate::ctx::{load_keychain, load_secret, now_micros, open_repo, save_keychain};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn init(args: InitArgs) -> Result<()> {
|
|
let path = args.path.unwrap_or_else(|| PathBuf::from("."));
|
|
let path = if path.is_absolute() {
|
|
path
|
|
} else {
|
|
std::env::current_dir()?.join(path)
|
|
};
|
|
fs::create_dir_all(&path)?;
|
|
if path.join(".levcs").exists() {
|
|
bail!("repository already exists at {:?}", path);
|
|
}
|
|
// Pick or create a key.
|
|
let label = args.key.as_deref().unwrap_or("personal").to_string();
|
|
let mut kc = load_keychain()?;
|
|
let sk: SecretKey = if let Some(_) = kc.entry(&label) {
|
|
let (_, sk) = load_secret(Some(&label))?;
|
|
sk
|
|
} else {
|
|
let sk = SecretKey::generate();
|
|
kc.add_plaintext(&label, &sk)?;
|
|
save_keychain(&kc)?;
|
|
eprintln!(
|
|
"generated new key '{label}' at {:?}",
|
|
crate::ctx::keychain_path()
|
|
);
|
|
sk
|
|
};
|
|
let pk = sk.public();
|
|
|
|
// Build genesis authority: single owner = `pk`.
|
|
let now = now_micros();
|
|
let mut auth = AuthorityBody {
|
|
schema_version: AUTHORITY_SCHEMA_VERSION,
|
|
repo_id: ZERO_ID,
|
|
previous_authority: ZERO_ID,
|
|
version: 1,
|
|
created_micros: now,
|
|
members: vec![MemberEntry {
|
|
key: pk,
|
|
handle: label.clone(),
|
|
role: Role::Owner,
|
|
added_micros: now,
|
|
added_by: pk,
|
|
}],
|
|
policy: vec![
|
|
PolicyEntry {
|
|
key: "public_read".into(),
|
|
value: vec![0x01],
|
|
},
|
|
PolicyEntry {
|
|
key: "require_signed_releases".into(),
|
|
value: vec![0x01],
|
|
},
|
|
PolicyEntry {
|
|
key: "allowed_handlers".into(),
|
|
value: b"builtin".to_vec(),
|
|
},
|
|
],
|
|
};
|
|
auth.normalize()?;
|
|
auth.assign_genesis_repo_id()?;
|
|
let signed = sign_authority(&auth, &sk)?;
|
|
|
|
// Materialize repo.
|
|
let repo = Repository::init_skeleton(&path)?;
|
|
let auth_id = repo.write_signed(&signed)?;
|
|
repo.set_genesis_authority(auth_id)?;
|
|
repo.set_current_authority(auth_id)?;
|
|
// Create empty `main` branch HEAD pointer.
|
|
repo.refs
|
|
.write_head(&Head::Branch("refs/branches/main".into()))?;
|
|
eprintln!(
|
|
"initialized levcs repository at {:?}\n repo_id = blake3:{}\n authority = {}",
|
|
path,
|
|
auth.repo_id.to_hex(),
|
|
auth_id
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// track / forget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn track(args: TrackArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let mut idx = repo.read_index()?;
|
|
let mut targets: Vec<PathBuf> = Vec::new();
|
|
if args.all || args.paths.iter().any(|p| p.as_os_str() == ".") {
|
|
targets.extend(repo.walk_workdir()?);
|
|
} else {
|
|
for p in args.paths {
|
|
let abs = if p.is_absolute() {
|
|
p
|
|
} else {
|
|
repo.workdir.join(p)
|
|
};
|
|
if abs.is_dir() {
|
|
walk_dir(&abs, &repo.workdir, &mut targets)?;
|
|
} else if abs.is_file() {
|
|
targets.push(abs);
|
|
} else {
|
|
bail!("path not found: {:?}", abs);
|
|
}
|
|
}
|
|
}
|
|
for path in targets {
|
|
let rel = path
|
|
.strip_prefix(&repo.workdir)?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
let bytes = fs::read(&path)?;
|
|
let blob = Blob::new(bytes.clone());
|
|
let id = repo.objects.write_raw(&blob.serialize())?;
|
|
let meta = fs::metadata(&path)?;
|
|
let mtime = file_mtime_micros(&meta);
|
|
let size = meta.len();
|
|
let mode = file_mode_bits(&meta);
|
|
idx.upsert(IndexEntry {
|
|
path: rel,
|
|
blob_hash: id,
|
|
mode,
|
|
flags: IndexEntryFlags::TRACKED,
|
|
mtime_micros: mtime,
|
|
size,
|
|
});
|
|
}
|
|
repo.write_index(&idx)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn forget(args: ForgetArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let mut idx = repo.read_index()?;
|
|
for p in args.paths {
|
|
let abs = if p.is_absolute() {
|
|
p
|
|
} else {
|
|
repo.workdir.join(&p)
|
|
};
|
|
let rel = abs
|
|
.strip_prefix(&repo.workdir)?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
idx.remove(&rel);
|
|
if !args.keep_file {
|
|
let _ = fs::remove_file(&abs);
|
|
}
|
|
}
|
|
repo.write_index(&idx)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// status / log
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn status() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let idx = repo.read_index()?;
|
|
let head = repo.refs.resolve_head()?;
|
|
let branch_ref = repo.current_branch()?;
|
|
let branch = branch_ref
|
|
.as_deref()
|
|
.map(|s| s.trim_start_matches("refs/branches/").to_string())
|
|
.unwrap_or_else(|| "(detached)".into());
|
|
println!("On branch {branch}");
|
|
if let Some(h) = head {
|
|
let signed = repo.read_signed(h)?;
|
|
let commit = Commit::from_signed(&signed)?;
|
|
let subject = commit.message.lines().next().unwrap_or("").to_string();
|
|
println!("HEAD {h}");
|
|
if !subject.is_empty() {
|
|
println!(" {subject}");
|
|
}
|
|
} else {
|
|
println!("(no commits yet)");
|
|
}
|
|
let releases = repo.refs.list_releases()?;
|
|
if let Some((label, id)) = releases.last() {
|
|
println!("Release {label} ({id})");
|
|
}
|
|
if repo.levcs_dir.join("MERGE_HEAD").exists() {
|
|
let mh = fs::read_to_string(repo.levcs_dir.join("MERGE_HEAD"))?;
|
|
println!("Merge in progress (theirs={})", mh.trim());
|
|
}
|
|
let workdir_files = repo.walk_workdir()?;
|
|
let mut tracked = HashMap::<String, &IndexEntry>::new();
|
|
for e in &idx.entries {
|
|
tracked.insert(e.path.clone(), e);
|
|
}
|
|
let mut modified = Vec::new();
|
|
let mut untracked = Vec::new();
|
|
for path in workdir_files {
|
|
let rel = path
|
|
.strip_prefix(&repo.workdir)?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
match tracked.get(&rel) {
|
|
None => untracked.push(rel),
|
|
Some(entry) => {
|
|
let bytes = fs::read(&path)?;
|
|
let blob_id = Blob::new(bytes).object_id();
|
|
if blob_id != entry.blob_hash {
|
|
modified.push(rel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let work_set: HashSet<String> = repo
|
|
.walk_workdir()?
|
|
.iter()
|
|
.map(|p| {
|
|
p.strip_prefix(&repo.workdir)
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
})
|
|
.collect();
|
|
let mut deleted: Vec<String> = idx
|
|
.entries
|
|
.iter()
|
|
.filter(|e| !work_set.contains(&e.path))
|
|
.map(|e| e.path.clone())
|
|
.collect();
|
|
deleted.sort();
|
|
if !modified.is_empty() {
|
|
println!("\nmodified:");
|
|
for m in &modified {
|
|
println!(" {m}");
|
|
}
|
|
}
|
|
if !deleted.is_empty() {
|
|
println!("\ndeleted:");
|
|
for d in &deleted {
|
|
println!(" {d}");
|
|
}
|
|
}
|
|
if !untracked.is_empty() {
|
|
println!("\nuntracked:");
|
|
for u in &untracked {
|
|
println!(" {u}");
|
|
}
|
|
}
|
|
if modified.is_empty() && deleted.is_empty() && untracked.is_empty() {
|
|
println!("\nworking tree clean.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn log(_args: LogArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let head = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no commits yet"))?;
|
|
let mut id = head;
|
|
let mut count = 0;
|
|
while count < 100 {
|
|
let signed = repo.read_signed(id)?;
|
|
let commit = Commit::from_signed(&signed)?;
|
|
let pk = PublicKey(commit.author_key);
|
|
println!("commit {}", id);
|
|
println!("Author: {}", pk);
|
|
println!("Date: {} (us since epoch)", commit.timestamp_micros);
|
|
if commit.flags.modifies_authority() {
|
|
println!("Flags: authority-modifying");
|
|
}
|
|
if commit.flags.is_fork() {
|
|
println!("Flags: fork");
|
|
}
|
|
println!();
|
|
for line in commit.message.lines() {
|
|
println!(" {line}");
|
|
}
|
|
println!();
|
|
if commit.parents.is_empty() {
|
|
break;
|
|
}
|
|
id = commit.parents[0];
|
|
count += 1;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn root() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
println!("{}", repo.workdir.display());
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// commit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn commit(args: CommitArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let (label, sk) = load_secret(args.key.as_deref())?;
|
|
let pk = sk.public();
|
|
let _ = label;
|
|
|
|
// Detect a merge in progress so we can attach the second parent and bake
|
|
// the merge-record into the resulting tree.
|
|
let merge_head_path = repo.levcs_dir.join("MERGE_HEAD");
|
|
let merge_head_id: Option<ObjectId> = if merge_head_path.exists() {
|
|
let s = fs::read_to_string(&merge_head_path)?;
|
|
Some(ObjectId::from_hex(s.trim())?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Update index from working tree (for tracked files only). With --all,
|
|
// also include any modified-but-tracked-via-prior-commit files (we
|
|
// approximate: if the file is on disk and previously in index, refresh).
|
|
let mut idx = repo.read_index()?;
|
|
let mut new_entries = Vec::new();
|
|
for e in &idx.entries {
|
|
let abs = repo.workdir.join(&e.path);
|
|
if abs.is_file() {
|
|
let bytes = fs::read(&abs)?;
|
|
// Refuse to commit content that still has unresolved conflict
|
|
// markers — this is the last guard before a half-finished merge
|
|
// ends up in the object store.
|
|
if has_conflict_markers(&bytes) {
|
|
bail!(
|
|
"{} still contains conflict markers; resolve before committing",
|
|
e.path
|
|
);
|
|
}
|
|
let blob_id = repo
|
|
.objects
|
|
.write_raw(&Blob::new(bytes.clone()).serialize())?;
|
|
let meta = fs::metadata(&abs)?;
|
|
new_entries.push(IndexEntry {
|
|
path: e.path.clone(),
|
|
blob_hash: blob_id,
|
|
mode: file_mode_bits(&meta),
|
|
flags: IndexEntryFlags::TRACKED,
|
|
mtime_micros: file_mtime_micros(&meta),
|
|
size: meta.len(),
|
|
});
|
|
}
|
|
// (deleted from disk → drop entry)
|
|
let _ = args.all; // already implicit
|
|
}
|
|
idx.entries = new_entries;
|
|
repo.write_index(&idx)?;
|
|
|
|
// Build the staged tree. For a merge commit, splice the merge-record
|
|
// blob into `.levcs/merge-record` (§6.5).
|
|
let mut staged_tree = repo.build_tree_from_index(&idx)?;
|
|
if merge_head_id.is_some() {
|
|
let record_path = repo.levcs_dir.join("merge-record");
|
|
if !record_path.exists() {
|
|
bail!("MERGE_HEAD set but no merge-record found");
|
|
}
|
|
let record_bytes = fs::read(&record_path)?;
|
|
// Defensive re-check: somebody could have hand-edited the record
|
|
// after `levcs merge` produced it. Refuse to seal a record that
|
|
// names a handler not in this repository's policy.
|
|
let allowed = load_merge_policy_allowed(&repo);
|
|
let record_str = std::str::from_utf8(&record_bytes)
|
|
.map_err(|_| anyhow!("merge-record is not valid UTF-8"))?;
|
|
let parsed = MergeRecord::from_toml(record_str)
|
|
.map_err(|e| anyhow!("merge-record is malformed: {e}"))?;
|
|
let bad = validate_record_against_policy(&parsed, &allowed);
|
|
if !bad.is_empty() {
|
|
bail!(
|
|
"merge-record references handlers not in repository policy: {}",
|
|
bad.join(", ")
|
|
);
|
|
}
|
|
let blob_id = repo
|
|
.objects
|
|
.write_raw(&Blob::new(record_bytes).serialize())?;
|
|
staged_tree = crate::tree_helpers::put_merge_record_in_tree(&repo, staged_tree, blob_id)?;
|
|
}
|
|
|
|
let parent = repo.refs.resolve_head()?;
|
|
if merge_head_id.is_none() {
|
|
// Skip the "nothing to commit" check for merge commits — a successful
|
|
// three-way merge whose tree happens to equal HEAD's still needs a
|
|
// second-parent commit to record the union.
|
|
if let Some(p) = parent {
|
|
let p_signed = repo.read_signed(p)?;
|
|
let p_commit = Commit::from_signed(&p_signed)?;
|
|
if p_commit.tree == staged_tree {
|
|
bail!("nothing to commit, working tree matches HEAD");
|
|
}
|
|
}
|
|
}
|
|
|
|
let authority = repo
|
|
.current_authority()?
|
|
.ok_or_else(|| anyhow!("repository has no current authority"))?;
|
|
// Verify the author is in the authority and has at least contributor.
|
|
let auth_signed = repo.read_signed(authority)?;
|
|
let auth_body = AuthorityBody::parse(&auth_signed.body)?;
|
|
let member = auth_body
|
|
.find_member(&pk)
|
|
.ok_or_else(|| anyhow!("your key is not in the current authority"))?;
|
|
if member.role < Role::Contributor {
|
|
bail!(
|
|
"your key has role '{}', need at least contributor",
|
|
member.role.name()
|
|
);
|
|
}
|
|
|
|
let default_message = match merge_head_id {
|
|
Some(m) => format!("merge {m}"),
|
|
None => "(no message)".into(),
|
|
};
|
|
let message = args.message.unwrap_or(default_message);
|
|
let mut parents = parent.map(|p| vec![p]).unwrap_or_default();
|
|
if let Some(m) = merge_head_id {
|
|
parents.push(m);
|
|
}
|
|
let commit_obj = Commit {
|
|
tree: staged_tree,
|
|
parents,
|
|
authority,
|
|
author_key: pk.0,
|
|
timestamp_micros: now_micros(),
|
|
flags: CommitFlags::NONE,
|
|
message,
|
|
};
|
|
let signed = sign_commit(commit_obj, &sk)?;
|
|
let id = repo.write_signed(&signed)?;
|
|
// Advance HEAD's branch ref.
|
|
if let Some(branch) = repo.current_branch()? {
|
|
repo.refs.write(&branch, id)?;
|
|
} else {
|
|
repo.refs.write_head(&Head::Detached(id))?;
|
|
}
|
|
// Tear down merge state on success.
|
|
if merge_head_id.is_some() {
|
|
let _ = fs::remove_file(&merge_head_path);
|
|
let _ = fs::remove_file(repo.levcs_dir.join("MERGE_BASE"));
|
|
let _ = fs::remove_file(repo.levcs_dir.join("merge-record"));
|
|
}
|
|
println!("[{}] {}", id, summarize_message(&signed));
|
|
Ok(())
|
|
}
|
|
|
|
fn has_conflict_markers(bytes: &[u8]) -> bool {
|
|
let s = match std::str::from_utf8(bytes) {
|
|
Ok(s) => s,
|
|
Err(_) => return false, // binary file; skip
|
|
};
|
|
let mut saw_open = false;
|
|
let mut saw_sep = false;
|
|
for line in s.lines() {
|
|
if line.starts_with("<<<<<<<") {
|
|
saw_open = true;
|
|
} else if saw_open && line == "=======" {
|
|
saw_sep = true;
|
|
} else if saw_sep && line.starts_with(">>>>>>>") {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn summarize_message(signed: &SignedObject) -> String {
|
|
let c = match Commit::parse_body(&signed.body) {
|
|
Ok(c) => c,
|
|
Err(_) => return "(unreadable)".into(),
|
|
};
|
|
c.message.lines().next().unwrap_or("").to_string()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// construct, diff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn construct(args: ConstructArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
// The first positional arg may be either a hash or a path. If it doesn't
|
|
// parse as a 64-char hex blake3 hash, fold it into the path list and
|
|
// resolve the target from HEAD (or latest release with --release).
|
|
let mut paths = args.paths.clone();
|
|
let parsed_hash: Option<ObjectId> = match args.hash.as_deref() {
|
|
Some(s) => match ObjectId::from_hex(s) {
|
|
Ok(id) => Some(id),
|
|
Err(_) => {
|
|
paths.insert(0, PathBuf::from(s));
|
|
None
|
|
}
|
|
},
|
|
None => None,
|
|
};
|
|
let target_id = match (parsed_hash, args.release) {
|
|
(Some(id), _) => id,
|
|
(None, false) => repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("HEAD has no commits"))?,
|
|
(None, true) => {
|
|
let mut releases = repo.refs.list_releases()?;
|
|
// Newest by timestamp_micros within the release object body.
|
|
releases.sort_by_key(|(_, id)| {
|
|
repo.read_signed(*id)
|
|
.ok()
|
|
.and_then(|s| Release::parse_body(&s.body).ok())
|
|
.map(|r| r.timestamp_micros)
|
|
.unwrap_or(0)
|
|
});
|
|
let (_, id) = releases
|
|
.into_iter()
|
|
.last()
|
|
.ok_or_else(|| anyhow!("no releases on this repository"))?;
|
|
id
|
|
}
|
|
};
|
|
let raw = repo.read_raw_object(target_id)?;
|
|
let tree_id = match raw.object_type {
|
|
ObjectType::Commit => Commit::parse_body(&raw.body)?.tree,
|
|
ObjectType::Release => Release::parse_body(&raw.body)?.tree,
|
|
ObjectType::Tree => target_id,
|
|
other => bail!("cannot construct from {} object", other.name()),
|
|
};
|
|
|
|
if paths.is_empty() {
|
|
// Whole-tree reconstruction.
|
|
if args.all {
|
|
// No-op: --all only matters when restricting paths; full tree is
|
|
// always rewritten.
|
|
}
|
|
repo.checkout_tree(tree_id, &repo.workdir)?;
|
|
eprintln!("constructed tree {} into {:?}", tree_id, repo.workdir);
|
|
return Ok(());
|
|
}
|
|
|
|
// Path-restricted reconstruction.
|
|
for p in paths {
|
|
let abs = if p.is_absolute() {
|
|
p.clone()
|
|
} else {
|
|
repo.workdir.join(&p)
|
|
};
|
|
let rel = abs
|
|
.strip_prefix(&repo.workdir)
|
|
.map_err(|_| anyhow!("path {:?} is outside the repository", p))?;
|
|
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
|
let entry = repo
|
|
.lookup_path(tree_id, &rel_str)?
|
|
.ok_or_else(|| anyhow!("path not in tree: {rel_str}"))?;
|
|
match entry.0 {
|
|
levcs_core::EntryType::Blob => {
|
|
let blob = repo.objects.read_typed(entry.1, ObjectType::Blob)?;
|
|
if !args.all && abs.is_file() {
|
|
let cur = fs::read(&abs)?;
|
|
if cur == blob.body {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(parent) = abs.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::write(&abs, blob.body)?;
|
|
}
|
|
levcs_core::EntryType::Tree => {
|
|
fs::create_dir_all(&abs)?;
|
|
repo.checkout_tree(entry.1, &abs)?;
|
|
}
|
|
}
|
|
}
|
|
eprintln!("constructed paths from tree {}", tree_id);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn diff(args: DiffArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let head = repo.refs.resolve_head()?;
|
|
// Fold a non-hex "commit" positional into the path restriction list.
|
|
let mut paths = args.paths.clone();
|
|
let parsed_commit: Option<ObjectId> = match args.commit.as_deref() {
|
|
Some(s) => match ObjectId::from_hex(s) {
|
|
Ok(id) => Some(id),
|
|
Err(_) => {
|
|
paths.insert(0, PathBuf::from(s));
|
|
None
|
|
}
|
|
},
|
|
None => None,
|
|
};
|
|
let baseline_tree = if let Some(id) = parsed_commit {
|
|
let raw = repo.read_raw_object(id)?;
|
|
match raw.object_type {
|
|
ObjectType::Commit => Commit::parse_body(&raw.body)?.tree,
|
|
ObjectType::Release => Release::parse_body(&raw.body)?.tree,
|
|
ObjectType::Tree => id,
|
|
_ => bail!("not a commit/release/tree"),
|
|
}
|
|
} else if args.release {
|
|
let mut releases = repo.refs.list_releases()?;
|
|
releases.sort_by_key(|(_, id)| {
|
|
repo.read_signed(*id)
|
|
.ok()
|
|
.and_then(|s| Release::parse_body(&s.body).ok())
|
|
.map(|r| r.timestamp_micros)
|
|
.unwrap_or(0)
|
|
});
|
|
let (_, id) = releases
|
|
.into_iter()
|
|
.last()
|
|
.ok_or_else(|| anyhow!("no releases on this repository"))?;
|
|
Release::parse_body(&repo.read_signed(id)?.body)?.tree
|
|
} else if let Some(h) = head {
|
|
Commit::from_signed(&repo.read_signed(h)?)?.tree
|
|
} else {
|
|
ZERO_ID
|
|
};
|
|
|
|
// Normalize path restrictions to repository-relative strings.
|
|
let restrict: Vec<String> = paths
|
|
.iter()
|
|
.map(|p| -> Result<String> {
|
|
let abs = if p.is_absolute() {
|
|
p.clone()
|
|
} else {
|
|
repo.workdir.join(p)
|
|
};
|
|
let rel = abs
|
|
.strip_prefix(&repo.workdir)
|
|
.map_err(|_| anyhow!("path {:?} is outside the repository", p))?;
|
|
Ok(rel.to_string_lossy().replace('\\', "/"))
|
|
})
|
|
.collect::<Result<_>>()?;
|
|
let path_matches = |p: &str| -> bool {
|
|
if restrict.is_empty() {
|
|
return true;
|
|
}
|
|
restrict
|
|
.iter()
|
|
.any(|r| p == r || p.starts_with(&format!("{r}/")))
|
|
};
|
|
|
|
let baseline = collect_tree_files(&repo, baseline_tree, "")?;
|
|
let work: HashMap<String, Vec<u8>> = repo
|
|
.walk_workdir()?
|
|
.into_iter()
|
|
.map(|p| -> Result<_> {
|
|
let rel = p
|
|
.strip_prefix(&repo.workdir)?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
let bytes = fs::read(&p)?;
|
|
Ok((rel, bytes))
|
|
})
|
|
.collect::<Result<_>>()?;
|
|
use similar::{ChangeTag, TextDiff};
|
|
let mut keys: Vec<&String> = baseline.keys().chain(work.keys()).collect();
|
|
keys.sort();
|
|
keys.dedup();
|
|
for k in keys {
|
|
if !path_matches(k) {
|
|
continue;
|
|
}
|
|
let a = baseline.get(k).cloned().unwrap_or_default();
|
|
let b = work.get(k).cloned().unwrap_or_default();
|
|
if a == b {
|
|
continue;
|
|
}
|
|
println!("--- a/{k}\n+++ b/{k}");
|
|
let a_s = String::from_utf8_lossy(&a);
|
|
let b_s = String::from_utf8_lossy(&b);
|
|
let diff = TextDiff::from_lines(&a_s, &b_s);
|
|
for change in diff.iter_all_changes() {
|
|
let prefix = match change.tag() {
|
|
ChangeTag::Equal => " ",
|
|
ChangeTag::Insert => "+",
|
|
ChangeTag::Delete => "-",
|
|
};
|
|
print!("{prefix}{}", change.value());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_tree_files(
|
|
repo: &Repository,
|
|
tree_id: ObjectId,
|
|
prefix: &str,
|
|
) -> Result<HashMap<String, Vec<u8>>> {
|
|
let mut out = HashMap::new();
|
|
if tree_id.is_zero() {
|
|
return Ok(out);
|
|
}
|
|
let raw = repo.objects.read_typed(tree_id, ObjectType::Tree)?;
|
|
let tree = Tree::parse_body(&raw.body)?;
|
|
for e in tree.entries {
|
|
let path = if prefix.is_empty() {
|
|
e.name.clone()
|
|
} else {
|
|
format!("{prefix}/{}", e.name)
|
|
};
|
|
match e.entry_type {
|
|
levcs_core::EntryType::Blob => {
|
|
let blob = repo.objects.read_typed(e.hash, ObjectType::Blob)?;
|
|
out.insert(path, blob.body);
|
|
}
|
|
levcs_core::EntryType::Tree => {
|
|
let sub = collect_tree_files(repo, e.hash, &path)?;
|
|
out.extend(sub);
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// branch / merge / release / cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn branch(args: BranchArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
if args.list || (args.create.is_none() && args.switch.is_none() && args.delete.is_none()) {
|
|
let cur = repo.current_branch()?.unwrap_or_default();
|
|
for (name, id) in repo.refs.list_branches()? {
|
|
let marker = if cur == format!("refs/branches/{name}") {
|
|
"*"
|
|
} else {
|
|
" "
|
|
};
|
|
println!("{marker} {name}\t{id}");
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(name) = args.create {
|
|
let from = match args.from {
|
|
Some(s) => ObjectId::from_hex(&s)?,
|
|
None => repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?,
|
|
};
|
|
repo.refs.write(&format!("refs/branches/{name}"), from)?;
|
|
eprintln!("created branch {name} at {from}");
|
|
}
|
|
if let Some(name) = args.switch {
|
|
let target = repo
|
|
.refs
|
|
.read(&format!("refs/branches/{name}"))?
|
|
.ok_or_else(|| anyhow!("no such branch: {name}"))?;
|
|
repo.refs
|
|
.write_head(&Head::Branch(format!("refs/branches/{name}")))?;
|
|
let raw = repo.read_raw_object(target)?;
|
|
let tree_id = match raw.object_type {
|
|
ObjectType::Commit => Commit::parse_body(&raw.body)?.tree,
|
|
_ => bail!("branch tip is not a commit"),
|
|
};
|
|
repo.checkout_tree(tree_id, &repo.workdir)?;
|
|
// Refresh the index from the new tree. Without this the index
|
|
// keeps the previous branch's blob hashes — invisible to most
|
|
// workflows because the next `commit` rebuilds the index from
|
|
// the working tree, but visible to anything that compares
|
|
// index-vs-workdir (e.g., the merge command's dirty-tree
|
|
// precondition, which would otherwise false-positive on every
|
|
// branch switch).
|
|
let mut idx = Index::new();
|
|
rebuild_index_from_tree(&repo, tree_id, "", &mut idx)?;
|
|
repo.write_index(&idx)?;
|
|
eprintln!("switched to branch {name}");
|
|
}
|
|
if let Some(name) = args.delete {
|
|
repo.refs.delete(&format!("refs/branches/{name}"))?;
|
|
eprintln!("deleted branch {name}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn merge(args: MergeArgs) -> Result<()> {
|
|
let _ = args.key; // resolution is signed at commit-time, not merge-time
|
|
if args.abort {
|
|
return merge_abort();
|
|
}
|
|
if args.explain {
|
|
return merge_explain();
|
|
}
|
|
if args.review {
|
|
return merge_review();
|
|
}
|
|
merge_run(args)
|
|
}
|
|
|
|
/// Return the list of tracked paths whose working-tree contents differ from
|
|
/// the index, including paths that are tracked but missing from disk. Used
|
|
/// as a precondition for any operation that overwrites the working tree
|
|
/// (currently: `merge`, both fast-forward and three-way). Callers should
|
|
/// refuse to proceed when the returned list is non-empty so users don't
|
|
/// silently lose uncommitted work.
|
|
fn dirty_tracked_paths(repo: &Repository) -> Result<Vec<String>> {
|
|
let idx = repo.read_index()?;
|
|
let mut workdir_set: HashSet<String> = HashSet::new();
|
|
for path in repo.walk_workdir()? {
|
|
let rel = path
|
|
.strip_prefix(&repo.workdir)?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
workdir_set.insert(rel);
|
|
}
|
|
let mut dirty = Vec::new();
|
|
for entry in &idx.entries {
|
|
if !entry.flags.is_tracked() {
|
|
continue;
|
|
}
|
|
let abs = repo.workdir.join(&entry.path);
|
|
if !workdir_set.contains(&entry.path) {
|
|
// Tracked file removed from working tree without `levcs commit`
|
|
// — counts as dirty for merge purposes since the merge would
|
|
// resurrect it (or compute against stale on-disk state).
|
|
dirty.push(entry.path.clone());
|
|
continue;
|
|
}
|
|
let bytes = match fs::read(&abs) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
dirty.push(entry.path.clone());
|
|
continue;
|
|
}
|
|
};
|
|
let id = Blob::new(bytes).object_id();
|
|
if id != entry.blob_hash {
|
|
dirty.push(entry.path.clone());
|
|
}
|
|
}
|
|
dirty.sort();
|
|
Ok(dirty)
|
|
}
|
|
|
|
fn merge_run(args: MergeArgs) -> Result<()> {
|
|
let branch_name = args
|
|
.branch
|
|
.clone()
|
|
.ok_or_else(|| anyhow!("missing branch to merge"))?;
|
|
let repo = open_repo()?;
|
|
if repo.levcs_dir.join("MERGE_HEAD").exists() {
|
|
bail!("a merge is already in progress; run `levcs merge --abort` to cancel");
|
|
}
|
|
// Refuse to start a merge when tracked files have uncommitted changes —
|
|
// both the fast-forward and three-way paths overwrite the working
|
|
// tree, and silently clobbering local edits is the kind of bug that
|
|
// costs users hours of work. Mirrors git's `Your local changes to
|
|
// the following files would be overwritten by merge` precondition.
|
|
let dirty = dirty_tracked_paths(&repo)?;
|
|
if !dirty.is_empty() {
|
|
let listing = dirty
|
|
.iter()
|
|
.take(10)
|
|
.map(|p| format!(" {p}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let more = if dirty.len() > 10 {
|
|
format!("\n ... and {} more", dirty.len() - 10)
|
|
} else {
|
|
String::new()
|
|
};
|
|
bail!(
|
|
"uncommitted changes to tracked files would be overwritten by merge:\n{listing}{more}\n\
|
|
commit them (or revert to HEAD) before merging — see `levcs status`."
|
|
);
|
|
}
|
|
let head = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD on current branch"))?;
|
|
let theirs_id = resolve_target(&repo, &branch_name)?;
|
|
if head == theirs_id {
|
|
eprintln!("already up to date.");
|
|
return Ok(());
|
|
}
|
|
|
|
let base_id = find_common_ancestor(&repo, head, theirs_id)?
|
|
.ok_or_else(|| anyhow!("no common ancestor between {head} and {theirs_id}"))?;
|
|
|
|
// Fast-forward: HEAD is an ancestor of theirs, no merge commit needed.
|
|
if base_id == head {
|
|
let theirs_commit = Commit::from_signed(&repo.read_signed(theirs_id)?)?;
|
|
if let Some(branch_ref) = repo.current_branch()? {
|
|
repo.refs.write(&branch_ref, theirs_id)?;
|
|
} else {
|
|
repo.refs.write_head(&Head::Detached(theirs_id))?;
|
|
}
|
|
repo.checkout_tree(theirs_commit.tree, &repo.workdir)?;
|
|
// Refresh index from the new tree.
|
|
let mut idx = Index::new();
|
|
rebuild_index_from_tree(&repo, theirs_commit.tree, "", &mut idx)?;
|
|
repo.write_index(&idx)?;
|
|
eprintln!("fast-forward to {theirs_id}");
|
|
return Ok(());
|
|
}
|
|
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head)?)?;
|
|
let theirs_commit = Commit::from_signed(&repo.read_signed(theirs_id)?)?;
|
|
let base_commit = Commit::from_signed(&repo.read_signed(base_id)?)?;
|
|
|
|
let base_files = collect_tree_files(&repo, base_commit.tree, "")?;
|
|
let ours_files = collect_tree_files(&repo, head_commit.tree, "")?;
|
|
let theirs_files = collect_tree_files(&repo, theirs_commit.tree, "")?;
|
|
|
|
// Layered config per §6.6.3 — `.levcs/merge.local.toml` over
|
|
// `.levcs/merge.toml`. Promotions in the local override are
|
|
// rejected before we touch the working tree.
|
|
let cfg = load_effective_merge_config(&repo)?;
|
|
let engine = CascadeEngine::new().with_config(cfg);
|
|
let mut record = MergeRecord {
|
|
schema_version: 1,
|
|
base: format!("blake3:{}", base_id),
|
|
ours: format!("blake3:{}", head),
|
|
theirs: format!("blake3:{}", theirs_id),
|
|
files: Vec::new(),
|
|
};
|
|
let mut paths: BTreeSet<String> = BTreeSet::new();
|
|
for k in base_files
|
|
.keys()
|
|
.chain(ours_files.keys())
|
|
.chain(theirs_files.keys())
|
|
{
|
|
// .levcs/* synthetic tree entries (authority, merge-record) live in
|
|
// commits but never on disk; skip them when reconciling files.
|
|
if k.starts_with(".levcs/") || k == ".levcs" {
|
|
continue;
|
|
}
|
|
paths.insert(k.clone());
|
|
}
|
|
|
|
let json_mode = args.format == "json";
|
|
if !json_mode && args.format != "text" {
|
|
bail!(
|
|
"unknown --format value: {} (allowed: text, json)",
|
|
args.format
|
|
);
|
|
}
|
|
let mut auto_resolved = 0usize;
|
|
let mut conflict_count = 0usize;
|
|
let mut merged_files: HashMap<String, Vec<u8>> = HashMap::new();
|
|
let mut deleted_files: HashSet<String> = HashSet::new();
|
|
// Collected eagerly because MergeResult is not Clone — we capture
|
|
// the JSON projection inside the loop and stash it for the final
|
|
// report.
|
|
let mut json_files: Vec<(String, JsonReportData)> = Vec::new();
|
|
|
|
for path in paths {
|
|
let base = base_files.get(&path).cloned().unwrap_or_default();
|
|
let ours = ours_files.get(&path).cloned().unwrap_or_default();
|
|
let theirs = theirs_files.get(&path).cloned().unwrap_or_default();
|
|
|
|
// Both sides agree: take the value (handles deletes-on-both).
|
|
if ours == theirs {
|
|
if ours.is_empty() && !ours_files.contains_key(&path) {
|
|
deleted_files.insert(path);
|
|
} else {
|
|
merged_files.insert(path, ours);
|
|
}
|
|
continue;
|
|
}
|
|
// One-sided edits.
|
|
if base == ours {
|
|
// Only theirs changed.
|
|
if args.no_auto {
|
|
let result = make_no_auto_conflict(path.clone(), &ours, &theirs);
|
|
conflict_count += 1;
|
|
emit_outcome(&path, &result, json_mode);
|
|
if json_mode {
|
|
json_files.push((path.clone(), JsonReportData::from(&result)));
|
|
}
|
|
record.files.push(file_record_from(&path, &result));
|
|
merged_files.insert(path, partial_from(result.status, &ours));
|
|
continue;
|
|
}
|
|
if theirs_files.contains_key(&path) {
|
|
merged_files.insert(path.clone(), theirs.clone());
|
|
record.files.push(FileRecord {
|
|
path,
|
|
handler: "theirs-only".into(),
|
|
handler_hash: String::new(),
|
|
status: FileStatus::Theirs,
|
|
notes: String::new(),
|
|
});
|
|
} else {
|
|
deleted_files.insert(path.clone());
|
|
record.files.push(FileRecord {
|
|
path,
|
|
handler: "delete".into(),
|
|
handler_hash: String::new(),
|
|
status: FileStatus::Theirs,
|
|
notes: "deleted by theirs".into(),
|
|
});
|
|
}
|
|
auto_resolved += 1;
|
|
continue;
|
|
}
|
|
if base == theirs {
|
|
// Only ours changed.
|
|
if ours_files.contains_key(&path) {
|
|
merged_files.insert(path.clone(), ours.clone());
|
|
record.files.push(FileRecord {
|
|
path,
|
|
handler: "ours-only".into(),
|
|
handler_hash: String::new(),
|
|
status: FileStatus::Ours,
|
|
notes: String::new(),
|
|
});
|
|
} else {
|
|
deleted_files.insert(path.clone());
|
|
record.files.push(FileRecord {
|
|
path,
|
|
handler: "delete".into(),
|
|
handler_hash: String::new(),
|
|
status: FileStatus::Ours,
|
|
notes: "deleted by ours".into(),
|
|
});
|
|
}
|
|
auto_resolved += 1;
|
|
continue;
|
|
}
|
|
// Both sides changed: cascade.
|
|
let result = if args.no_auto {
|
|
make_no_auto_conflict(path.clone(), &ours, &theirs)
|
|
} else {
|
|
engine.merge_file(Path::new(&path), &base, &ours, &theirs)
|
|
};
|
|
emit_outcome(&path, &result, json_mode);
|
|
if json_mode {
|
|
json_files.push((path.clone(), JsonReportData::from(&result)));
|
|
}
|
|
let fr = file_record_from(&path, &result);
|
|
let status = result.status;
|
|
match &status {
|
|
MergeStatus::Merged { content, .. } => {
|
|
auto_resolved += 1;
|
|
merged_files.insert(path.clone(), content.clone());
|
|
}
|
|
MergeStatus::Conflict {
|
|
partial,
|
|
regions: _,
|
|
} => {
|
|
conflict_count += 1;
|
|
merged_files.insert(path.clone(), partial.clone());
|
|
}
|
|
MergeStatus::NotApplicable => {
|
|
conflict_count += 1;
|
|
merged_files.insert(path.clone(), ours.clone());
|
|
}
|
|
}
|
|
record.files.push(fr);
|
|
}
|
|
|
|
// Repo-side policy ceiling: every handler reference in the record must
|
|
// be permitted by `.levcs/merge.toml` (§6.6). This used to run *after*
|
|
// we applied the merge to the working tree, which left the user's
|
|
// files clobbered when the policy check then bailed. Validate up
|
|
// front, before any disk write, so a rejected merge leaves the
|
|
// working tree exactly as we found it.
|
|
let allowed = load_merge_policy_allowed(&repo);
|
|
let bad = validate_record_against_policy(&record, &allowed);
|
|
if !bad.is_empty() {
|
|
bail!(
|
|
"merge produced records referencing handlers not in repository policy: {}",
|
|
bad.join(", ")
|
|
);
|
|
}
|
|
|
|
// Apply to working tree.
|
|
for (path, bytes) in &merged_files {
|
|
let abs = repo.workdir.join(path);
|
|
if let Some(parent) = abs.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::write(&abs, bytes)?;
|
|
}
|
|
for path in &deleted_files {
|
|
let abs = repo.workdir.join(path);
|
|
let _ = fs::remove_file(&abs);
|
|
}
|
|
|
|
// Refresh the index to reflect post-merge content. Tracked entries are
|
|
// reset to the new blob hashes so that `levcs commit` can build a tree
|
|
// without re-reading the working directory's view of every file.
|
|
let mut idx = Index::new();
|
|
for (path, bytes) in &merged_files {
|
|
let id = repo
|
|
.objects
|
|
.write_raw(&Blob::new(bytes.clone()).serialize())?;
|
|
let mut flags = IndexEntryFlags::TRACKED;
|
|
if let Some(fr) = record.files.iter().find(|fr| fr.path == *path) {
|
|
if matches!(fr.status, FileStatus::Manual) {
|
|
flags = flags.with(IndexEntryFlags::CONFLICTED);
|
|
}
|
|
}
|
|
idx.upsert(IndexEntry {
|
|
path: path.clone(),
|
|
blob_hash: id,
|
|
mode: 0,
|
|
flags,
|
|
mtime_micros: 0,
|
|
size: bytes.len() as u64,
|
|
});
|
|
}
|
|
repo.write_index(&idx)?;
|
|
|
|
// Persist merge state and the in-progress merge-record.
|
|
fs::write(repo.levcs_dir.join("MERGE_HEAD"), theirs_id.to_hex())?;
|
|
fs::write(repo.levcs_dir.join("MERGE_BASE"), base_id.to_hex())?;
|
|
let toml = record
|
|
.to_toml()
|
|
.map_err(|e| anyhow!("serialize merge-record: {e}"))?;
|
|
fs::write(repo.levcs_dir.join("merge-record"), toml)?;
|
|
|
|
if json_mode {
|
|
// One JSON object on stdout. Ordering matches the input
|
|
// iteration so consumers can correlate by index.
|
|
let report = JsonMergeReport {
|
|
schema_version: 1,
|
|
base: &record.base,
|
|
ours: &record.ours,
|
|
theirs: &record.theirs,
|
|
auto_resolved,
|
|
conflicts: conflict_count,
|
|
files: json_files
|
|
.iter()
|
|
.map(|(path, data)| JsonFileReport { path, data })
|
|
.collect(),
|
|
};
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&report).map_err(|e| anyhow!("serialize report: {e}"))?
|
|
);
|
|
if conflict_count > 0 {
|
|
std::process::exit(1);
|
|
}
|
|
return Ok(());
|
|
}
|
|
println!();
|
|
println!("merge summary:");
|
|
println!(" auto-resolved: {auto_resolved}");
|
|
println!(" conflicts: {conflict_count}");
|
|
println!();
|
|
if conflict_count > 0 {
|
|
println!(
|
|
"review with `levcs merge --review`, or edit conflicted files and run `levcs commit`."
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
println!("clean merge. run `levcs commit` to finalize.");
|
|
Ok(())
|
|
}
|
|
|
|
/// Read `.levcs/merge.toml` into a `MergeConfig`. Missing file or
|
|
/// malformed contents both produce a default config — the spec's
|
|
/// "absence means permissive" semantics.
|
|
fn read_repo_merge_config(repo: &Repository) -> MergeConfig {
|
|
let path = repo.levcs_dir.join("merge.toml");
|
|
let raw = match fs::read_to_string(&path) {
|
|
Ok(s) => s,
|
|
Err(_) => return MergeConfig::default(),
|
|
};
|
|
toml::from_str(&raw).unwrap_or_default()
|
|
}
|
|
|
|
/// Load the effective merge config for this repository, layering
|
|
/// `.levcs/merge.local.toml` (gitignored, never pushed) over
|
|
/// `.levcs/merge.toml` per §6.6.3. The local layer can demote handler
|
|
/// aggressiveness but not promote it; promotions are rejected with a
|
|
/// hard error so the user knows their override is being silently
|
|
/// dropped instead of quietly ignored.
|
|
fn load_effective_merge_config(repo: &Repository) -> Result<MergeConfig> {
|
|
let base = read_repo_merge_config(repo);
|
|
let local_path = repo.levcs_dir.join("merge.local.toml");
|
|
if !local_path.exists() {
|
|
return Ok(base);
|
|
}
|
|
let local_raw =
|
|
fs::read_to_string(&local_path).map_err(|e| anyhow!("read merge.local.toml: {e}"))?;
|
|
let local: MergeConfig =
|
|
toml::from_str(&local_raw).map_err(|e| anyhow!("parse merge.local.toml: {e}"))?;
|
|
levcs_merge::engine::layer_local_over(&base, &local)
|
|
.map_err(|e| anyhow!("merge.local.toml: {e}"))
|
|
}
|
|
|
|
/// Load `.levcs/merge.toml`'s `policy.allowed_handlers`. Returns an empty
|
|
/// vec (permissive) if the file is absent or has no policy block.
|
|
/// `merge.local.toml` does not influence policy — local overrides
|
|
/// can't widen what's permitted.
|
|
fn load_merge_policy_allowed(repo: &Repository) -> Vec<String> {
|
|
let cfg = read_repo_merge_config(repo);
|
|
cfg.policy.map(|p| p.allowed_handlers).unwrap_or_default()
|
|
}
|
|
|
|
fn resolve_target(repo: &Repository, name: &str) -> Result<ObjectId> {
|
|
if let Some(id) = repo.refs.read(&format!("refs/branches/{name}"))? {
|
|
return Ok(id);
|
|
}
|
|
if let Ok(id) = ObjectId::from_hex(name) {
|
|
return Ok(id);
|
|
}
|
|
bail!("unknown branch or commit: {name}")
|
|
}
|
|
|
|
fn emit_outcome(path: &str, result: &MergeResult, json: bool) {
|
|
if json {
|
|
// JSON mode collects results into a final report — no per-file
|
|
// line goes to stdout, since stdout is reserved for the single
|
|
// structured object.
|
|
return;
|
|
}
|
|
match &result.status {
|
|
MergeStatus::Merged { .. } => {
|
|
eprintln!("AUTO {path} ({})", result.handler);
|
|
}
|
|
MergeStatus::Conflict { regions, .. } => {
|
|
eprintln!(
|
|
"CONFLICT {path} ({}, {} region{})",
|
|
result.handler,
|
|
regions.len(),
|
|
if regions.len() == 1 { "" } else { "s" }
|
|
);
|
|
}
|
|
MergeStatus::NotApplicable => {
|
|
eprintln!("CONFLICT {path} (no applicable handler)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// JSON projection of a `MergeResult` — extracted eagerly inside the
|
|
/// merge loop so we don't need MergeResult to be Clone. Holds owned
|
|
/// strings so it can outlive the result it was derived from.
|
|
#[derive(serde::Serialize)]
|
|
struct JsonReportData {
|
|
handler: String,
|
|
/// One of "merged", "conflict", "not_applicable".
|
|
status: &'static str,
|
|
conflict_regions: usize,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
regions: Vec<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
notes: Vec<String>,
|
|
}
|
|
|
|
impl From<&MergeResult> for JsonReportData {
|
|
fn from(r: &MergeResult) -> Self {
|
|
match &r.status {
|
|
MergeStatus::Merged { notes, .. } => JsonReportData {
|
|
handler: r.handler.clone(),
|
|
status: "merged",
|
|
conflict_regions: 0,
|
|
regions: Vec::new(),
|
|
notes: notes.iter().map(|n| n.message.clone()).collect(),
|
|
},
|
|
MergeStatus::Conflict { regions, .. } => JsonReportData {
|
|
handler: r.handler.clone(),
|
|
status: "conflict",
|
|
conflict_regions: regions.len(),
|
|
regions: regions.iter().map(|r| r.description.clone()).collect(),
|
|
notes: Vec::new(),
|
|
},
|
|
MergeStatus::NotApplicable => JsonReportData {
|
|
handler: r.handler.clone(),
|
|
status: "not_applicable",
|
|
conflict_regions: 0,
|
|
regions: Vec::new(),
|
|
notes: Vec::new(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct JsonFileReport<'a> {
|
|
path: &'a str,
|
|
#[serde(flatten)]
|
|
data: &'a JsonReportData,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct JsonMergeReport<'a> {
|
|
/// Schema tag — bumped when the output format changes incompatibly.
|
|
schema_version: u32,
|
|
base: &'a str,
|
|
ours: &'a str,
|
|
theirs: &'a str,
|
|
auto_resolved: usize,
|
|
conflicts: usize,
|
|
files: Vec<JsonFileReport<'a>>,
|
|
}
|
|
|
|
fn file_record_from(path: &str, result: &MergeResult) -> FileRecord {
|
|
let (status, notes) = match &result.status {
|
|
MergeStatus::Merged { notes, .. } => (
|
|
FileStatus::Auto,
|
|
notes
|
|
.iter()
|
|
.map(|n| n.message.clone())
|
|
.collect::<Vec<_>>()
|
|
.join("; "),
|
|
),
|
|
MergeStatus::Conflict { regions, .. } => {
|
|
let n = regions.len();
|
|
(
|
|
FileStatus::Manual,
|
|
format!("{n} conflict region{}", if n == 1 { "" } else { "s" }),
|
|
)
|
|
}
|
|
MergeStatus::NotApplicable => (FileStatus::Manual, "no applicable handler".into()),
|
|
};
|
|
FileRecord {
|
|
path: path.to_string(),
|
|
handler: result.handler.clone(),
|
|
handler_hash: String::new(),
|
|
status,
|
|
notes,
|
|
}
|
|
}
|
|
|
|
fn make_no_auto_conflict(_path: String, ours: &[u8], theirs: &[u8]) -> MergeResult {
|
|
let mut partial = Vec::new();
|
|
partial.extend_from_slice(b"<<<<<<< ours\n");
|
|
partial.extend_from_slice(ours);
|
|
if !ours.is_empty() && !ours.ends_with(b"\n") {
|
|
partial.push(b'\n');
|
|
}
|
|
partial.extend_from_slice(b"=======\n");
|
|
partial.extend_from_slice(theirs);
|
|
if !theirs.is_empty() && !theirs.ends_with(b"\n") {
|
|
partial.push(b'\n');
|
|
}
|
|
partial.extend_from_slice(b">>>>>>> theirs\n");
|
|
MergeResult {
|
|
handler: "no-auto".into(),
|
|
status: MergeStatus::Conflict {
|
|
regions: vec![levcs_merge::ConflictRegion {
|
|
description: "no-auto: forced conflict".into(),
|
|
base: 0..0,
|
|
ours: 0..ours.len(),
|
|
theirs: 0..theirs.len(),
|
|
}],
|
|
partial,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn partial_from(status: MergeStatus, fallback: &[u8]) -> Vec<u8> {
|
|
match status {
|
|
MergeStatus::Conflict { partial, .. } => partial,
|
|
MergeStatus::Merged { content, .. } => content,
|
|
MergeStatus::NotApplicable => fallback.to_vec(),
|
|
}
|
|
}
|
|
|
|
fn rebuild_index_from_tree(
|
|
repo: &Repository,
|
|
tree_id: ObjectId,
|
|
prefix: &str,
|
|
idx: &mut Index,
|
|
) -> Result<()> {
|
|
if tree_id.is_zero() {
|
|
return Ok(());
|
|
}
|
|
let raw = repo.objects.read_typed(tree_id, ObjectType::Tree)?;
|
|
let tree = Tree::parse_body(&raw.body)?;
|
|
for e in tree.entries {
|
|
let path = if prefix.is_empty() {
|
|
e.name.clone()
|
|
} else {
|
|
format!("{prefix}/{}", e.name)
|
|
};
|
|
if path.starts_with(".levcs/") || path == ".levcs" {
|
|
continue;
|
|
}
|
|
match e.entry_type {
|
|
levcs_core::EntryType::Tree => {
|
|
rebuild_index_from_tree(repo, e.hash, &path, idx)?;
|
|
}
|
|
levcs_core::EntryType::Blob => {
|
|
let blob = repo.objects.read_typed(e.hash, ObjectType::Blob)?;
|
|
idx.upsert(IndexEntry {
|
|
path,
|
|
blob_hash: e.hash,
|
|
mode: if e.mode.is_executable() { 0o111 } else { 0 },
|
|
flags: IndexEntryFlags::TRACKED,
|
|
mtime_micros: 0,
|
|
size: blob.body.len() as u64,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn merge_abort() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let merge_head_path = repo.levcs_dir.join("MERGE_HEAD");
|
|
if !merge_head_path.exists() {
|
|
bail!("no merge in progress");
|
|
}
|
|
let head_id = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?;
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head_id)?)?;
|
|
repo.checkout_tree(head_commit.tree, &repo.workdir)?;
|
|
let mut idx = Index::new();
|
|
rebuild_index_from_tree(&repo, head_commit.tree, "", &mut idx)?;
|
|
repo.write_index(&idx)?;
|
|
let _ = fs::remove_file(&merge_head_path);
|
|
let _ = fs::remove_file(repo.levcs_dir.join("MERGE_BASE"));
|
|
let _ = fs::remove_file(repo.levcs_dir.join("merge-record"));
|
|
eprintln!("merge aborted; restored to {head_id}");
|
|
Ok(())
|
|
}
|
|
|
|
fn merge_explain() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let in_progress_path = repo.levcs_dir.join("merge-record");
|
|
|
|
// Two cases (per §6.7): a merge is in progress, or we're inspecting
|
|
// the most recent commit's recorded merge. The TUI source-of-truth
|
|
// is the merge-record TOML; if it isn't there, we fall back to
|
|
// dumping any text we can find (legacy script callers may rely on
|
|
// text output, so keep that path alive).
|
|
let (record_text, ours_tree, theirs_tree, base_tree) = if in_progress_path.exists() {
|
|
let head_id = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?;
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head_id)?)?;
|
|
let merge_head_id =
|
|
ObjectId::from_hex(fs::read_to_string(repo.levcs_dir.join("MERGE_HEAD"))?.trim())?;
|
|
let merge_head_commit = Commit::from_signed(&repo.read_signed(merge_head_id)?)?;
|
|
let base_tree = if repo.levcs_dir.join("MERGE_BASE").exists() {
|
|
let id =
|
|
ObjectId::from_hex(fs::read_to_string(repo.levcs_dir.join("MERGE_BASE"))?.trim())?;
|
|
Some(Commit::from_signed(&repo.read_signed(id)?)?.tree)
|
|
} else {
|
|
None
|
|
};
|
|
(
|
|
fs::read_to_string(&in_progress_path)?,
|
|
head_commit.tree,
|
|
merge_head_commit.tree,
|
|
base_tree,
|
|
)
|
|
} else {
|
|
// No in-progress merge — pull from the most recent commit.
|
|
let head = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?;
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head)?)?;
|
|
let entry = repo
|
|
.lookup_path(head_commit.tree, ".levcs/merge-record")?
|
|
.ok_or_else(|| anyhow!("no merge-record on HEAD or in progress"))?;
|
|
let blob = repo.objects.read_typed(entry.1, ObjectType::Blob)?;
|
|
let text =
|
|
String::from_utf8(blob.body).map_err(|_| anyhow!("merge-record is not UTF-8"))?;
|
|
// For a committed merge, "ours" is HEAD's first parent and
|
|
// "theirs" is HEAD's second parent. If the commit isn't a
|
|
// merge, we have nothing to step through — fall back to text.
|
|
if head_commit.parents.len() < 2 {
|
|
print!("{text}");
|
|
return Ok(());
|
|
}
|
|
let ours_commit = Commit::from_signed(&repo.read_signed(head_commit.parents[0])?)?;
|
|
let theirs_commit = Commit::from_signed(&repo.read_signed(head_commit.parents[1])?)?;
|
|
let base_id = find_common_ancestor(&repo, head_commit.parents[0], head_commit.parents[1])?;
|
|
let base_tree = match base_id {
|
|
Some(id) => Some(Commit::from_signed(&repo.read_signed(id)?)?.tree),
|
|
None => None,
|
|
};
|
|
(text, ours_commit.tree, theirs_commit.tree, base_tree)
|
|
};
|
|
|
|
let record = match MergeRecord::from_toml(&record_text) {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
// Unparseable record — keep the legacy text-dump escape hatch
|
|
// so users with broken state can still see what's there.
|
|
print!("{record_text}");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Build a FileEntry per file in the record. Same loader logic as
|
|
// merge_review but seeded from the *committed* trees.
|
|
let read_path = |tree: ObjectId, p: &str| -> Vec<u8> {
|
|
match repo.lookup_path(tree, p) {
|
|
Ok(Some((_, blob_id))) => match repo.objects.read_typed(blob_id, ObjectType::Blob) {
|
|
Ok(blob) => blob.body,
|
|
Err(_) => Vec::new(),
|
|
},
|
|
_ => Vec::new(),
|
|
}
|
|
};
|
|
let files: Vec<levcs_tui::FileEntry> = record
|
|
.files
|
|
.iter()
|
|
.map(|fr| {
|
|
let ours = read_path(ours_tree, &fr.path);
|
|
let theirs = read_path(theirs_tree, &fr.path);
|
|
let base = base_tree
|
|
.map(|t| read_path(t, &fr.path))
|
|
.unwrap_or_default();
|
|
// For explain, the "current" pane shows the *result* of the
|
|
// merge — i.e., what the engine produced for this file.
|
|
// For an in-progress merge that's the working tree; for a
|
|
// committed merge it's the file at HEAD.
|
|
let current = if in_progress_path.exists() {
|
|
fs::read(repo.workdir.join(&fr.path)).unwrap_or_default()
|
|
} else {
|
|
read_path(ours_tree, &fr.path)
|
|
};
|
|
// Use the structured status to drive the TUI's regions: an
|
|
// auto-resolved file shows as "merged" with the engine's
|
|
// notes; a manual file shows as a single conflict region
|
|
// covering the full file (the record doesn't preserve
|
|
// per-region byte ranges, just the count).
|
|
let status = match fr.status {
|
|
FileStatus::Auto | FileStatus::Ours | FileStatus::Theirs => MergeStatus::Merged {
|
|
content: current.clone(),
|
|
notes: if fr.notes.is_empty() {
|
|
vec![]
|
|
} else {
|
|
vec![levcs_merge::MergeNote {
|
|
message: fr.notes.clone(),
|
|
}]
|
|
},
|
|
},
|
|
FileStatus::Manual => MergeStatus::Conflict {
|
|
regions: vec![levcs_merge::ConflictRegion {
|
|
description: if fr.notes.is_empty() {
|
|
"manual resolution".into()
|
|
} else {
|
|
fr.notes.clone()
|
|
},
|
|
base: 0..base.len(),
|
|
ours: 0..ours.len(),
|
|
theirs: 0..theirs.len(),
|
|
}],
|
|
partial: current.clone(),
|
|
},
|
|
};
|
|
levcs_tui::FileEntry {
|
|
path: fr.path.clone(),
|
|
status,
|
|
current,
|
|
ours,
|
|
theirs,
|
|
base,
|
|
handler: fr.handler.clone(),
|
|
notes: fr.notes.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if files.is_empty() {
|
|
// Nothing structural to show — fall back to dumping the record.
|
|
print!("{record_text}");
|
|
return Ok(());
|
|
}
|
|
|
|
// Non-interactive contexts (scripts, CI, piped output) can't drive
|
|
// the TUI — fall back to the text dump so callers still get
|
|
// something useful. We probe stdin since the alt-screen reads from
|
|
// there; if it isn't a terminal, raw-mode setup fails noisily.
|
|
use std::io::IsTerminal;
|
|
if !std::io::stdin().is_terminal() {
|
|
print!("{record_text}");
|
|
return Ok(());
|
|
}
|
|
|
|
levcs_tui::review_read_only(files).map_err(|e| anyhow!("explain session: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn merge_review() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let merge_record_path = repo.levcs_dir.join("merge-record");
|
|
if !merge_record_path.exists() {
|
|
bail!("no merge in progress");
|
|
}
|
|
let s = fs::read_to_string(&merge_record_path)?;
|
|
let record = MergeRecord::from_toml(&s).map_err(|e| anyhow!("parse merge-record: {e}"))?;
|
|
|
|
// Resolve the three side trees the review needs.
|
|
// * ours — current HEAD's tree (what we had before the merge).
|
|
// * theirs — MERGE_HEAD's tree (what's being merged in).
|
|
// * base — MERGE_BASE's tree (their common ancestor).
|
|
let head_id = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?;
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head_id)?)?;
|
|
let ours_tree = head_commit.tree;
|
|
|
|
let merge_head_id =
|
|
ObjectId::from_hex(fs::read_to_string(repo.levcs_dir.join("MERGE_HEAD"))?.trim())?;
|
|
let merge_head_commit = Commit::from_signed(&repo.read_signed(merge_head_id)?)?;
|
|
let theirs_tree = merge_head_commit.tree;
|
|
|
|
let base_path = repo.levcs_dir.join("MERGE_BASE");
|
|
let base_tree = if base_path.exists() {
|
|
let id = ObjectId::from_hex(fs::read_to_string(&base_path)?.trim())?;
|
|
let c = Commit::from_signed(&repo.read_signed(id)?)?;
|
|
Some(c.tree)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Build a FileEntry per file in the merge-record. Read each side's
|
|
// bytes from its tree by path; missing files (modify-vs-delete) get
|
|
// an empty byte vector for that side.
|
|
let read_path = |tree: ObjectId, p: &str| -> Vec<u8> {
|
|
match repo.lookup_path(tree, p) {
|
|
Ok(Some((_, blob_id))) => match repo.objects.read_typed(blob_id, ObjectType::Blob) {
|
|
Ok(blob) => blob.body,
|
|
Err(_) => Vec::new(),
|
|
},
|
|
_ => Vec::new(),
|
|
}
|
|
};
|
|
|
|
let files: Vec<levcs_tui::FileEntry> = record
|
|
.files
|
|
.iter()
|
|
.map(|fr| {
|
|
let current = fs::read(repo.workdir.join(&fr.path)).unwrap_or_default();
|
|
let ours = read_path(ours_tree, &fr.path);
|
|
let theirs = read_path(theirs_tree, &fr.path);
|
|
let base = base_tree
|
|
.map(|t| read_path(t, &fr.path))
|
|
.unwrap_or_default();
|
|
let status = match fr.status {
|
|
FileStatus::Auto | FileStatus::Ours | FileStatus::Theirs => MergeStatus::Merged {
|
|
content: current.clone(),
|
|
notes: if fr.notes.is_empty() {
|
|
vec![]
|
|
} else {
|
|
vec![levcs_merge::MergeNote {
|
|
message: fr.notes.clone(),
|
|
}]
|
|
},
|
|
},
|
|
FileStatus::Manual => MergeStatus::Conflict {
|
|
regions: vec![levcs_merge::ConflictRegion {
|
|
description: if fr.notes.is_empty() {
|
|
"manual resolution required".into()
|
|
} else {
|
|
fr.notes.clone()
|
|
},
|
|
base: 0..base.len(),
|
|
ours: 0..ours.len(),
|
|
theirs: 0..theirs.len(),
|
|
}],
|
|
partial: current.clone(),
|
|
},
|
|
};
|
|
levcs_tui::FileEntry {
|
|
path: fr.path.clone(),
|
|
status,
|
|
current,
|
|
ours,
|
|
theirs,
|
|
base,
|
|
handler: fr.handler.clone(),
|
|
notes: fr.notes.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let total = files.len();
|
|
let final_state = levcs_tui::review(files).map_err(|e| anyhow!("review session: {e}"))?;
|
|
let report = final_state
|
|
.apply(&repo.workdir)
|
|
.map_err(|e| anyhow!("apply resolutions: {e}"))?;
|
|
eprintln!(
|
|
"review complete: {total} file(s) seen, {} written, {} kept",
|
|
report.written, report.skipped
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn find_common_ancestor(repo: &Repository, a: ObjectId, b: ObjectId) -> Result<Option<ObjectId>> {
|
|
let mut a_anc: HashSet<ObjectId> = HashSet::new();
|
|
let mut stack = vec![a];
|
|
while let Some(id) = stack.pop() {
|
|
if !a_anc.insert(id) {
|
|
continue;
|
|
}
|
|
if let Ok(s) = repo.read_signed(id) {
|
|
if let Ok(c) = Commit::from_signed(&s) {
|
|
stack.extend(c.parents);
|
|
}
|
|
}
|
|
}
|
|
let mut stack = vec![b];
|
|
let mut visited: HashSet<ObjectId> = HashSet::new();
|
|
while let Some(id) = stack.pop() {
|
|
if !visited.insert(id) {
|
|
continue;
|
|
}
|
|
if a_anc.contains(&id) {
|
|
return Ok(Some(id));
|
|
}
|
|
if let Ok(s) = repo.read_signed(id) {
|
|
if let Ok(c) = Commit::from_signed(&s) {
|
|
stack.extend(c.parents);
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
pub fn release(args: ReleaseArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let (_label, sk) = load_secret(args.key.as_deref())?;
|
|
let pk = sk.public();
|
|
let authority = repo
|
|
.current_authority()?
|
|
.ok_or_else(|| anyhow!("no current authority"))?;
|
|
let auth_signed = repo.read_signed(authority)?;
|
|
let auth_body = AuthorityBody::parse(&auth_signed.body)?;
|
|
let m = auth_body
|
|
.find_member(&pk)
|
|
.ok_or_else(|| anyhow!("your key is not in the current authority"))?;
|
|
if m.role < Role::Maintainer {
|
|
bail!(
|
|
"releases require maintainer role; your role is '{}'",
|
|
m.role.name()
|
|
);
|
|
}
|
|
let head = repo
|
|
.refs
|
|
.resolve_head()?
|
|
.ok_or_else(|| anyhow!("no HEAD"))?;
|
|
let head_commit = Commit::from_signed(&repo.read_signed(head)?)?;
|
|
let parent_release = repo
|
|
.refs
|
|
.read(&format!("refs/releases/{}", args.label))?
|
|
.unwrap_or(ZERO_ID);
|
|
let release = Release {
|
|
tree: head_commit.tree,
|
|
parent_release,
|
|
predecessor: head,
|
|
authority,
|
|
declarer_key: pk.0,
|
|
timestamp_micros: now_micros(),
|
|
label: args.label.clone(),
|
|
notes: args.message.unwrap_or_default(),
|
|
};
|
|
let signed = sign_release(release, &sk)?;
|
|
let id = repo.write_signed(&signed)?;
|
|
repo.refs
|
|
.write(&format!("refs/releases/{}", args.label), id)?;
|
|
|
|
// §4.4: warm the release cache and run LRU eviction so the
|
|
// cache stays under its configured cap. The cap is 1 GiB by
|
|
// default; future revisions can wire this through `.levcs/config`.
|
|
levcs_core::release_cache::cache_release(&repo, id)?;
|
|
let _ = levcs_core::release_cache::evict_to(
|
|
&repo,
|
|
levcs_core::release_cache::DEFAULT_CACHE_CAP_BYTES,
|
|
)?;
|
|
|
|
println!("released {} ({id})", args.label);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cache(args: CacheArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let dir = repo.levcs_dir.join("cache").join("workdir");
|
|
fs::create_dir_all(&dir)?;
|
|
if args.list {
|
|
for ent in fs::read_dir(&dir)?.flatten() {
|
|
println!("{}", ent.file_name().to_string_lossy());
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(id) = args.drop {
|
|
let path = dir.join(&id);
|
|
if path.exists() {
|
|
fs::remove_dir_all(path)?;
|
|
eprintln!("dropped cache {id}");
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(id) = args.restore {
|
|
let src = dir.join(&id);
|
|
if !src.is_dir() {
|
|
bail!("no such cache: {id}");
|
|
}
|
|
copy_dir_recursive(&src, &repo.workdir)?;
|
|
eprintln!("restored {id}");
|
|
return Ok(());
|
|
}
|
|
if args.save {
|
|
// Save current working tree files into the cache.
|
|
let id = format!("c{}", now_micros());
|
|
let dest = dir.join(&id);
|
|
fs::create_dir_all(&dest)?;
|
|
for path in repo.walk_workdir()? {
|
|
let rel = path.strip_prefix(&repo.workdir)?;
|
|
let target = dest.join(rel);
|
|
if let Some(parent) = target.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::copy(&path, &target)?;
|
|
}
|
|
if let Some(m) = args.message {
|
|
fs::write(dest.join(".message"), m)?;
|
|
}
|
|
println!("saved cache {id}");
|
|
return Ok(());
|
|
}
|
|
eprintln!("usage: levcs cache --save [-m MSG] | --list | --restore ID | --drop ID");
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
|
for ent in fs::read_dir(src)? {
|
|
let ent = ent?;
|
|
let p = ent.path();
|
|
let rel = p.strip_prefix(src)?;
|
|
let t = dest.join(rel);
|
|
if p.is_dir() {
|
|
fs::create_dir_all(&t)?;
|
|
copy_dir_recursive(&p, &t)?;
|
|
} else {
|
|
if let Some(parent) = t.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::copy(&p, &t)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verify, gc
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn verify() -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let head = repo.refs.resolve_head()?;
|
|
if let Some(h) = head {
|
|
verify_commit(&repo.objects, h, repo.current_branch()?.as_deref())?;
|
|
eprintln!("HEAD commit {h}: ok");
|
|
}
|
|
if let Some(auth) = repo.current_authority()? {
|
|
verify_authority_chain(&repo.objects, auth)?;
|
|
eprintln!("authority chain rooted at {auth}: ok");
|
|
}
|
|
eprintln!("verify: ok");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn gc(args: GcArgs) -> Result<()> {
|
|
let repo = open_repo()?;
|
|
let mut reachable: HashSet<ObjectId> = HashSet::new();
|
|
let mut stack: Vec<ObjectId> = Vec::new();
|
|
if let Some(h) = repo.refs.resolve_head()? {
|
|
stack.push(h);
|
|
}
|
|
if let Some(c) = repo.current_authority()? {
|
|
stack.push(c);
|
|
}
|
|
if let Some(g) = repo.genesis_authority()? {
|
|
stack.push(g);
|
|
}
|
|
for (_, id) in repo.refs.list_all()? {
|
|
stack.push(id);
|
|
}
|
|
while let Some(id) = stack.pop() {
|
|
if !reachable.insert(id) {
|
|
continue;
|
|
}
|
|
if let Ok(raw) = repo.objects.read_object(id) {
|
|
match raw.object_type {
|
|
ObjectType::Tree => {
|
|
if let Ok(t) = Tree::parse_body(&raw.body) {
|
|
for e in t.entries {
|
|
stack.push(e.hash);
|
|
}
|
|
}
|
|
}
|
|
ObjectType::Commit => {
|
|
if let Ok(c) = Commit::parse_body(&raw.body) {
|
|
stack.push(c.tree);
|
|
stack.push(c.authority);
|
|
stack.extend(c.parents);
|
|
}
|
|
}
|
|
ObjectType::Release => {
|
|
if let Ok(r) = Release::parse_body(&raw.body) {
|
|
stack.push(r.tree);
|
|
stack.push(r.predecessor);
|
|
stack.push(r.authority);
|
|
if !r.parent_release.is_zero() {
|
|
stack.push(r.parent_release);
|
|
}
|
|
}
|
|
}
|
|
ObjectType::Authority => {
|
|
if let Ok(b) = AuthorityBody::parse(&raw.body) {
|
|
if !b.previous_authority.is_zero() {
|
|
stack.push(b.previous_authority);
|
|
}
|
|
}
|
|
}
|
|
ObjectType::Blob => {}
|
|
}
|
|
}
|
|
}
|
|
// §4.2.2: don't delete an object that's younger than the grace
|
|
// period. An in-progress `commit` or `push` may have written the
|
|
// blob/tree to the object store but not yet linked it into a ref;
|
|
// GCing it underneath would corrupt the operation. The grace
|
|
// window is configurable; spec default is 14 days.
|
|
let grace = std::time::Duration::from_secs(args.grace_days * 24 * 60 * 60);
|
|
let now = std::time::SystemTime::now();
|
|
let mut deleted = 0usize;
|
|
let mut kept_young = 0usize;
|
|
for id in repo.objects.iter_ids()? {
|
|
if reachable.contains(&id) {
|
|
continue;
|
|
}
|
|
let p = repo.objects.path_for(id);
|
|
// Read mtime; skip with a warning if we can't tell. Choosing to
|
|
// err on the side of keeping the object means a clock-skewed
|
|
// file isn't silently lost.
|
|
let mtime = fs::metadata(&p).and_then(|m| m.modified()).ok();
|
|
if let Some(t) = mtime {
|
|
if let Ok(age) = now.duration_since(t) {
|
|
if age < grace {
|
|
kept_young += 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
let _ = fs::remove_file(p);
|
|
deleted += 1;
|
|
}
|
|
eprintln!(
|
|
"gc: removed {deleted} unreachable object(s); kept {kept_young} within grace ({}d)",
|
|
args.grace_days
|
|
);
|
|
let _ = args.aggressive; // honored via the same delete loop today
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn walk_dir(dir: &Path, base: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
|
for ent in fs::read_dir(dir)? {
|
|
let ent = ent?;
|
|
let path = ent.path();
|
|
let rel = path.strip_prefix(base)?;
|
|
if levcs_core::ignore::always_ignored(rel) {
|
|
continue;
|
|
}
|
|
let ft = ent.file_type()?;
|
|
if ft.is_dir() {
|
|
walk_dir(&path, base, out)?;
|
|
} else {
|
|
out.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn file_mtime_micros(meta: &fs::Metadata) -> i64 {
|
|
meta.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_micros() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn file_mode_bits(meta: &fs::Metadata) -> u8 {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let m = meta.permissions().mode();
|
|
if m & 0o111 != 0 {
|
|
0o111
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn file_mode_bits(_meta: &fs::Metadata) -> u8 {
|
|
0
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn _refs_unused(_: Refs) {}
|
|
#[allow(dead_code)]
|
|
fn _ctx_keep(_: &SecretKey) {}
|