//! A repository on disk: the `.oxy` directory, one workspace, the operation //! log, and the working-copy change (spec §12, §13.1). //! //! Not yet implemented from the spec: packs, checkpoints, multiple //! workspaces, multi-signer policy updates, and the `objects`/`checkpoint` //! endpoints. A repository without a policy is unverified and accepts //! unsigned pushes; with one, everything received is checked (spec §14.5). Changes are signed individually when `core.sign` is //! `change`; the default `seal` leaves them unsigned until push exists. use crate::attest::{Attest, AttestBody}; use crate::diff::{ranges, split_lines, text_edits}; use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature}; use crate::ids::{ChangeId, NodeId, WorkspaceId}; use crate::objects::{ Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp, }; use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX}; use crate::protocol::{ open_remote, Bundle, RefResult, RefUpdate, RefsResponse, RemoteOptions, RemoteRef, SignedAttest, SignedPolicy, }; use crate::state::{FileContent, Snapshot, State}; use crate::store::{Store, StoreError}; #[allow(unused_imports)] use crate::subrepo::Pin; use crate::tree::OpKey; use rand::RngCore; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; pub const OXY_DIR: &str = ".oxy"; pub const FORMAT_LINE: &str = "oxy repository format 2\n"; const FORMAT1_LINE: &str = "oxy repository format 1\n"; const BINARY_SNIFF: usize = 8 * 1024; /// Loose objects tolerated before a commit packs them (spec §13.4). pub const AUTOPACK_THRESHOLD: usize = 4096; #[derive(Debug, thiserror::Error)] pub enum RepoError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error(transparent)] Store(#[from] StoreError), #[error(transparent)] Object(#[from] crate::objects::ObjectError), #[error(transparent)] State(#[from] crate::state::StateError), #[error("{0}")] Msg(String), } pub type Result = std::result::Result; fn fail(s: impl Into) -> Result { Err(RepoError::Msg(s.into())) } mod bookmarks; mod checkout; mod checkpoint; mod config; mod gc; mod git; mod history; mod hooks; mod identity; mod inspect; mod policy; mod prompt; mod snapshot; mod subrepos; mod sync; mod tags; #[cfg(test)] mod tests; mod workspace; pub use bookmarks::{BookmarkInfo, MergeReport}; pub use checkout::CheckoutReport; pub use config::{parse_size, read_config_file, Config}; pub use gc::GcReport; pub use git::{GitExportReport, GitImportReport}; pub use hooks::{json_array, json_str, HookOutcome}; pub use identity::{Rotation, SignatureStatus}; pub use inspect::{BisectStep, BlameLine}; pub use policy::PolicyUpdate; pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT}; pub use snapshot::{ added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes, render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus, Selection, SnapshotResult, }; pub use subrepos::{SubrepoInfo, SubrepoSync}; pub use sync::{FetchReport, PullReport, PushReport, RefUpdated}; pub use tags::TagInfo; pub use workspace::{SnapshotLock, WorkspaceInfo}; fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { let tmp = path.with_extension("tmp"); { let mut f = fs::File::create(&tmp)?; f.write_all(data)?; f.sync_all()?; } fs::rename(&tmp, path)?; Ok(()) } pub struct LogEntry { pub hash: ChangeHash, /// Metadata from the state; the full change may be absent in a shallow repository. pub change: crate::state::ChangeMeta, pub is_working_copy: bool, pub is_head: bool, } /// A file as found on disk or in an imported tree. pub(crate) struct FsEntry { pub kind: NodeKind, pub exec: bool, /// File content, or a symlink's target. Empty when `blob` is set. pub content: Vec, /// The stored blob of an opaque file whose size and mtime are unchanged /// since the last manifest (spec §12.3 stat cache); the content was not /// read. pub blob: Option, /// BLAKE3 of the content, carried over from the manifest when the stat /// is unchanged, so the manifest can be rewritten without rehashing. pub content_hash: Option, } impl FsEntry { pub(crate) fn new(kind: NodeKind, exec: bool, content: Vec) -> FsEntry { FsEntry { kind, exec, content, blob: None, content_hash: None, } } } pub struct Repo { /// Oxygen-pinned subrepos skipped by the running Git export. pub(crate) git_export_skipped: std::cell::Cell, root: PathBuf, oxy: PathBuf, store: Store, workspace: WorkspaceId, /// A hosted repository with no working copy (spec §12.2, hub spec §4.1). bare: bool, keys_dir: PathBuf, user_config: PathBuf, pub config: Config, } /// Marker file of a bare repository, inside `.oxy/`. const BARE_MARKER: &str = "bare"; /// Marker of a hosted repository that has no id yet: the first push binds /// it to the pusher's id (spec §16.1). const UNBOUND_MARKER: &str = "unbound"; /// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else /// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`. pub fn default_home() -> PathBuf { if let Ok(h) = std::env::var("OXY_HOME") { return PathBuf::from(h); } if let Ok(x) = std::env::var("XDG_CONFIG_HOME") { return PathBuf::from(x).join("oxy"); } let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".config").join("oxy") } pub fn default_keys_dir() -> PathBuf { default_home().join("keys") } pub fn default_user_config() -> PathBuf { default_home().join("config.toml") } /// Where `create_identity` records the author. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IdentityScope { /// The user file: every repository of this user signs with it. User, /// This repository only. Repo, } impl Repo { // ------------------------------------------------------------ lifecycle pub fn init(root: &Path) -> Result { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); Self::init_with_id(root, id) } /// Initialize a repository with a given repository ID (clones share the /// ID of their origin, spec §14.4). pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result { fs::create_dir_all(root)?; let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let oxy = root.join(OXY_DIR); if oxy.exists() { return fail(format!("{} is already an oxy repository", root.display())); } fs::create_dir_all(oxy.join("ops"))?; write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; config.save(&oxy.join("config"))?; let workspace = WorkspaceId::random(); write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?; let store = Store::open(oxy.join("objects"))?; let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config, }; repo.register_workspace(&workspace, &repo.root)?; let wc = repo.new_working_copy(vec![], ""); let wc_hash = repo.store.put_change(&wc)?; let mut view = View::default(); view.bookmarks.insert( repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?, ); view.workspaces.insert(workspace, wc_hash); repo.record_op(&[], "oxy init", view)?; Ok(repo) } /// Create a bare repository: an object store, operation log and /// references with no workspace and no working copy, for hosting /// (hub spec §4.1). `root` holds `.oxy/` and nothing else. pub fn init_bare(root: &Path) -> Result { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); Self::init_bare_with_id(root, id) } pub fn init_bare_with_id(root: &Path, id: [u8; 16]) -> Result { fs::create_dir_all(root)?; let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let oxy = root.join(OXY_DIR); if oxy.exists() { return fail(format!("{} is already an oxy repository", root.display())); } fs::create_dir_all(oxy.join("ops"))?; write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; write_atomic(&oxy.join(BARE_MARKER), b"")?; let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; config.save(&oxy.join("config"))?; let store = Store::open(oxy.join("objects"))?; let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace: WorkspaceId::ZERO, bare: true, keys_dir: default_keys_dir(), user_config: default_user_config(), config, }; let mut view = View::default(); view.bookmarks.insert( repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?, ); repo.record_op(&[], "oxy init --bare", view)?; Ok(repo) } /// A bare repository with no id yet. Its `id()` is all zero until the /// first push binds it (`bind_id`), so an existing repository can be /// pushed to a freshly created hosted one and keep its identity. pub fn init_bare_unbound(root: &Path) -> Result { let repo = Self::init_bare_with_id(root, [0; 16])?; fs::remove_file(repo.oxy.join("id"))?; write_atomic(&repo.oxy.join(UNBOUND_MARKER), b"")?; Ok(repo) } pub fn is_unbound(&self) -> bool { self.oxy.join(UNBOUND_MARKER).is_file() } /// Give an unbound repository its id. pub fn bind_id(&self, id: [u8; 16]) -> Result<()> { if !self.is_unbound() { return fail("this repository already has an id"); } if id == [0; 16] { return fail("a repository id cannot be all zero"); } write_atomic(&self.oxy.join("id"), hex::encode(id).as_bytes())?; fs::remove_file(self.oxy.join(UNBOUND_MARKER))?; Ok(()) } /// Whether this repository has no working copy (`init_bare`). pub fn is_bare(&self) -> bool { self.bare } /// Open the repository containing `start` (searching upwards). pub fn open(start: &Path) -> Result { let start = start.canonicalize()?; let mut dir: Option<&Path> = Some(&start); while let Some(d) = dir { let oxy = d.join(OXY_DIR); if oxy.is_file() { // A secondary workspace (spec §12.2): the file points at the repository. let text = fs::read_to_string(&oxy)?; let Some((shared, workspace)) = workspace::parse_workspace_file(&text) else { return fail(format!("{} is not a valid workspace file", oxy.display())); }; let format = fs::read_to_string(shared.join("format"))?; if format != FORMAT_LINE { return fail(format!("unsupported repository format: {}", format.trim())); } let config = Config::load(Some(&default_user_config()), &shared.join("config"))?; let store = Store::open(shared.join("objects"))?; let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config, }; repo.apply_workspace_bookmark(); return Ok(repo); } if oxy.is_dir() { let format = fs::read_to_string(oxy.join("format"))?; let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?; let bare = oxy.join(BARE_MARKER).is_file(); let workspace = if bare { WorkspaceId::ZERO } else { WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()) .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))? }; let store = Store::open(oxy.join("objects"))?; let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, bare, keys_dir: default_keys_dir(), user_config: default_user_config(), config, }; if !bare { repo.apply_workspace_bookmark(); } if format == FORMAT1_LINE { repo.migrate_format1()?; } else if format != FORMAT_LINE { return fail(format!("unsupported repository format: {}", format.trim())); } return Ok(repo); } dir = d.parent(); } fail(format!( "no oxy repository found at or above {}", start.display() )) } /// Rewrite a format-1 operation log, whose bookmarks were head lists, /// into format 2, where each bookmark names a `state` object. fn migrate_format1(&self) -> Result<()> { let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; let head = ObjectRef::from_hex(hex.trim()) .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?; // Collect the chain root-first. let mut chain: Vec = Vec::new(); let mut cur = Some(head); while let Some(r) = cur { let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?; cur = op.parents.first().copied(); chain.push(op); } chain.reverse(); let mut new_parent: Option = None; for old in chain { let mut view = View::default(); for (name, heads) in &old.view.bookmarks { view.bookmarks.insert( name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?, ); } view.workspaces = old.view.workspaces.clone(); let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view, }; new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?); } write_atomic( &self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes(), )?; write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?; Ok(()) } /// Pack automatically once many loose objects have accumulated. pub(crate) fn maybe_autopack(&self) -> Result<()> { if self.store.loose_count()? > AUTOPACK_THRESHOLD { self.store.pack_loose()?; self.store.merge_packs()?; } Ok(()) } /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use. pub fn id(&self) -> Result<[u8; 16]> { if self.is_unbound() { return Ok([0; 16]); } let path = self.oxy.join("id"); match fs::read_to_string(&path) { Ok(h) => hex::decode(h.trim()) .ok() .and_then(|v| v.try_into().ok()) .ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); write_atomic(&path, hex::encode(id).as_bytes())?; Ok(id) } Err(e) => Err(e.into()), } } pub fn root(&self) -> &Path { &self.root } pub fn store(&self) -> &Store { &self.store } pub fn workspace(&self) -> WorkspaceId { self.workspace } /// Use a different user directory for keys and the user config file /// (tests, or `$OXY_HOME`). Reloads the effective configuration. pub fn with_home(mut self, dir: impl Into) -> Repo { let dir = dir.into(); self.keys_dir = dir.join("keys"); self.user_config = dir.join("config.toml"); if let Ok(c) = Config::load(Some(&self.user_config), &self.oxy.join("config")) { self.config = c; } self.apply_workspace_bookmark(); self } /// The workspace's current bookmark (spec §10.1) overrides the /// configured default when `oxy bookmark switch` has recorded one. fn apply_workspace_bookmark(&mut self) { if let Ok(b) = fs::read_to_string( self.oxy .join("workspaces") .join(self.workspace.to_hex()) .join("bookmark"), ) { let b = b.trim(); if !b.is_empty() { self.config.bookmark = b.to_string(); } } } pub fn user_config_path(&self) -> &Path { &self.user_config } /// Set a key in the user file and refresh the effective configuration. pub fn set_user_config(&mut self, key: &str, value: &str) -> Result<()> { Config::set_user(&self.user_config, key, value)?; self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?; Ok(()) } pub fn save_config(&self) -> Result<()> { self.config.save(&self.oxy.join("config")) } fn author(&self) -> KeyId { self.config.author_key.unwrap_or(ObjectRef::ZERO) } } impl Repo { fn new_working_copy(&self, deps: Vec, message: &str) -> Change { let mut c = Change { change_id: ChangeId::random(), deps, author: self.author(), hlc: Hlc::now(), message: message.into(), tree_ops: vec![], edits: vec![], kind: ChangeKind::WorkingCopy, }; c.normalize(); c } // ------------------------------------------------------------ operation log pub fn head_op(&self) -> Result<(ObjectRef, Op)> { let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?; let r = ObjectRef::from_hex(hex.trim()) .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?; let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?; Ok((r, op)) } pub fn view(&self) -> Result { Ok(self.head_op()?.1.view) } fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result { let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view, }; let r = self.store.put(ObjectType::Op, &op.encode())?; write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?; Ok(r) } pub fn op_log(&self) -> Result> { let mut out = Vec::new(); let (mut r, mut op) = self.head_op()?; loop { let next = op.parents.first().copied(); out.push((r, op)); match next { Some(p) => { r = p; op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?; } None => break, } } Ok(out) } /// Undo the last user command (spec §12.1). Operations that only recorded /// a working-copy snapshot are skipped, since the files are still on disk /// and the next snapshot recreates them. pub fn undo(&self) -> Result { let (_, mut op) = self.head_op()?; while op.is_snapshot() { let Some(parent) = op.parents.first() else { break; }; op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; } let Some(parent) = op.parents.first() else { return fail("nothing to undo"); }; let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; write_atomic( &self.oxy.join("ops").join("head"), parent.to_hex().as_bytes(), )?; self.drop_manifest(); Ok(parent_op) } /// The last operation that was not a snapshot. pub fn last_command(&self) -> Result { let (_, mut op) = self.head_op()?; while op.is_snapshot() { let Some(parent) = op.parents.first() else { break; }; op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; } Ok(op) } // ------------------------------------------------------------ states /// Load the dependency closure of `heads`, through a checkpoint when one applies. pub fn load_state(&self, heads: &[ChangeHash]) -> Result { if let Some(st) = self.load_state_checkpointed(heads)? { return Ok(st); } self.load_state_full(heads) } /// Load the dependency closure of `heads` change by change. pub fn load_state_full(&self, heads: &[ChangeHash]) -> Result { let mut state = State::new(); let mut stack: Vec = heads.to_vec(); let mut seen: BTreeSet = BTreeSet::new(); while let Some(h) = stack.pop() { if !seen.insert(h) { continue; } let c = self.store.get_change(&h)?; stack.extend(c.deps.iter().copied()); state.add_unchecked(c); } state.validate()?; Ok(state) } pub fn working_copy(&self) -> Result<(ChangeHash, Change)> { if self.bare { return fail("this is a bare repository: it has no working copy"); } let view = self.view()?; let h = *view .workspaces .get(&self.workspace) .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?; Ok((h, self.store.get_change(&h)?)) } /// Heads of a bookmark's state (spec §10.1). pub fn heads_of(&self, view: &View, bookmark: &str) -> Result> { match view.bookmarks.get(bookmark) { Some(st) => Ok(self.store.get_state(st)?.heads), None => Ok(vec![]), } } pub fn bookmark_heads(&self) -> Result> { self.heads_of(&self.view()?, &self.config.bookmark) } // ------------------------------------------------------------ blobs fn blob_for(content: &[u8]) -> Blob { Blob { length: content.len() as u64, chunks: crate::chunk::chunks(content) .into_iter() .map(|c| hash_object(ObjectType::Chunk, c)) .collect(), } } pub fn blob_ref(content: &[u8]) -> ObjectRef { Self::blob_for(content).hash() } pub fn store_blob(&self, content: &[u8]) -> Result { for c in crate::chunk::chunks(content) { self.store.put_chunk(c)?; } Ok(self .store .put(ObjectType::Blob, &Self::blob_for(content).encode())?) } /// Read a blob's content, fetching it from the remote that withheld it /// when it is lazily absent (spec §13.5). pub fn read_blob(&self, r: &ObjectRef) -> Result> { if !self.store.has(r) && self.lazy_remote(r).is_some() { self.fetch_lazy_blob(r)?; } let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?; let mut out = Vec::with_capacity(blob.length as usize); for c in &blob.chunks { out.extend(self.store.get_typed(c, ObjectType::Chunk)?); } Ok(out) } // ------------------------------------------------------------ working copy /// Attributes from the `.oxyattributes` files in the working directory. pub fn disk_attributes(&self) -> crate::attributes::Attributes { let mut files: Vec<(String, Vec)> = Vec::new(); if let Ok(b) = fs::read(self.root.join(".oxyattributes")) { files.push((".oxyattributes".into(), b)); } let mut wb = ignore::WalkBuilder::new(&self.root); wb.hidden(false) .git_ignore(false) .git_global(false) .git_exclude(false) .require_git(false) .follow_links(false) .add_custom_ignore_filename(".oxyignore"); wb.filter_entry(|e| e.file_name() != OXY_DIR); for entry in wb.build().flatten() { if entry.file_name() == ".oxyattributes" && entry.path() != self.root.join(".oxyattributes") { if let Ok(b) = fs::read(entry.path()) { let rel: String = entry .path() .strip_prefix(&self.root) .unwrap() .components() .map(|c| c.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"); files.push((rel, b)); } } } crate::attributes::Attributes::from_files( files.iter().map(|(p, c)| (p.as_str(), c.as_slice())), ) } /// Attributes from a materialized state's `.oxyattributes` files. pub fn state_attributes(snapshot: &Snapshot) -> crate::attributes::Attributes { crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| { match &f.content { FileContent::Text { bytes, .. } if p == ".oxyattributes" || p.ends_with("/.oxyattributes") => { Some((p.as_str(), bytes.as_slice())) } _ => None, } })) } /// Store a blob, raw when the path is `nocompress`. pub fn store_blob_for(&self, content: &[u8], nocompress: bool) -> Result { if !nocompress { return self.store_blob(content); } for c in crate::chunk::chunks(content) { self.store.put_chunk_nocompress(c)?; } Ok(self .store .put(ObjectType::Blob, &Self::blob_for(content).encode())?) } fn locks_dir(&self) -> PathBuf { self.oxy.join("locks") } /// Whether `path` is locked locally for editing (spec §18, advisory). pub fn is_locked(&self, path: &str) -> bool { self.locks_dir().join(path.replace('/', "%2F")).exists() } /// Take the advisory local lock on a `lockable` file and make it writable. pub fn lock_path(&self, path: &str) -> Result<()> { let attrs = self.disk_attributes(); if !attrs.lookup(path).lockable { return fail(format!("{path} is not marked lockable in .oxyattributes")); } fs::create_dir_all(self.locks_dir())?; write_atomic( &self.locks_dir().join(path.replace('/', "%2F")), std::process::id().to_string().as_bytes(), )?; set_readonly(&self.root.join(path), false)?; Ok(()) } /// Release the local lock and make the file read-only again. pub fn unlock_path(&self, path: &str) -> Result<()> { let _ = fs::remove_file(self.locks_dir().join(path.replace('/', "%2F"))); if self.root.join(path).exists() { set_readonly(&self.root.join(path), true)?; } Ok(()) } /// Whether a repository path is inside the sparse filter (spec §17). pub fn in_scope(&self, path: &str) -> bool { self.config.sparse_paths.is_empty() || self .config .sparse_paths .iter() .any(|p| path == p || path.starts_with(&format!("{p}/"))) } fn walk_working_copy(&self) -> Result> { self.walk_working_copy_with(None, &BTreeMap::new()) } /// A walker over the working copy that never enters a nested repository /// (a directory below the root holding `.oxy` or `.git`); the directories /// it skipped are collected into `nested`. pub(crate) fn walker( &self, nested: &std::sync::Arc>>, ) -> ignore::WalkBuilder { let mut wb = ignore::WalkBuilder::new(&self.root); wb.hidden(false) .git_ignore(false) .git_global(false) .git_exclude(false) .require_git(false) .follow_links(false) .add_custom_ignore_filename(".oxyignore"); let nested = nested.clone(); wb.filter_entry(move |e| { if e.file_name() == OXY_DIR { return false; } if e.depth() > 0 && e.file_type().is_some_and(|t| t.is_dir()) && (e.path().join(OXY_DIR).exists() || e.path().join(".git").exists()) { nested.lock().unwrap().push(e.path().to_path_buf()); return false; } true }); wb } /// Walk the working copy; with a manifest, a file whose size and mtime /// match it is not read: an opaque file yields its known blob, a text /// file is read but keeps its known hash. Files modified within two /// seconds of the manifest are never trusted (a racy stat). /// /// `.oxyignore` applies to untracked paths only: every path in `tracked` /// (the base state's files) that exists on disk is included even when /// an ignore rule matches it, as in Git. fn walk_working_copy_with( &self, cache: Option<&prompt::Manifest>, tracked: &BTreeMap)>, ) -> Result> { let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let wb = self.walker(&nested); let mut out = BTreeMap::new(); for entry in wb.build() { let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?; let path = entry.path(); if path == self.root { continue; } let rel = path.strip_prefix(&self.root).unwrap(); let rel: String = rel .components() .map(|c| c.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"); let Some(ft) = entry.file_type() else { continue; }; if ft.is_dir() || !self.in_scope(&rel) { continue; } if ft.is_symlink() { let target = fs::read_link(path)?; out.insert( rel, FsEntry::new( NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec(), ), ); } else if ft.is_file() { let meta = entry .metadata() .map_err(|e| RepoError::Msg(e.to_string()))?; let e = self.file_entry(&rel, path, &meta, cache)?; out.insert(rel, e); } } // Nested repositories (spec §7.8): registered in `.oxymodules` or // already tracked as subrepo nodes, they contribute their pin; // unregistered ones are left alone. let modules = self.modules_on_disk(); let removed = self.removed_subrepos(); for rel in &removed { if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) { self.clear_removed_subrepo(rel); } } let nested_dirs = std::mem::take(&mut *nested.lock().unwrap()); for dir in nested_dirs { let rel = self.rel_path(&dir); if !self.in_scope(&rel) || removed.contains(&rel) { continue; } let registered = modules.entries.get(&rel); if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) { continue; } let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?; out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode())); } // Tracked paths the ignore rules hid. for (rel, (kind, blob)) in tracked { if out.contains_key(rel) || !self.in_scope(rel) { continue; } let path = self.root.join(rel); if *kind == NodeKind::Subrepo { if removed.contains(rel) { continue; } let present = path.is_dir() && (path.join(OXY_DIR).exists() || path.join(".git").exists()); if present { let pin = subrepos::read_pin( &path, modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()), )?; out.insert( rel.clone(), FsEntry::new(NodeKind::Subrepo, false, pin.encode()), ); } else if let Some(b) = blob { // Not cloned (or emptied): the pin stays as recorded, as a // Git gitlink does when its directory is absent. out.insert( rel.clone(), FsEntry { kind: NodeKind::Subrepo, exec: false, content: vec![], blob: Some(*b), content_hash: None, }, ); } continue; } let Ok(meta) = fs::symlink_metadata(&path) else { continue; }; let ft = meta.file_type(); if ft.is_symlink() { let target = fs::read_link(&path)?; out.insert( rel.clone(), FsEntry::new( NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec(), ), ); } else if ft.is_file() { let e = self.file_entry(rel, &path, &meta, cache)?; out.insert(rel.clone(), e); } } Ok(out) } /// The entry for one regular file, read or served from the stat cache. fn file_entry( &self, rel: &str, path: &Path, meta: &fs::Metadata, cache: Option<&prompt::Manifest>, ) -> Result { let exec = is_executable(meta); let cached = cache.and_then(|m| { let e = m.entries.get(rel)?; let mtime_ns = prompt::mtime_ns(meta); let racy = mtime_ns + 2_000_000_000 >= m.written_ns; (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e) }); Ok(match cached { Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry { kind: NodeKind::File, exec, content: vec![], blob: e.blob, content_hash: Some(e.hash.clone()), }, Some(e) => FsEntry { kind: NodeKind::File, exec, content: fs::read(path)?, blob: None, content_hash: Some(e.hash.clone()), }, None => FsEntry::new(NodeKind::File, exec, fs::read(path)?), }) } /// The non-directory paths of a snapshot with their kinds, for the /// walker's `tracked` set. pub(crate) fn tracked_kinds( snapshot: &Snapshot, ) -> BTreeMap)> { snapshot .files .iter() .filter(|(_, f)| f.kind != NodeKind::Dir) .map(|(p, f)| { let blob = match &f.content { FileContent::Opaque { blobs } => blobs.first().copied(), _ => None, }; (p.clone(), (f.kind, blob)) }) .collect() } /// A path under the root as a `/`-separated relative string. pub(crate) fn rel_path(&self, path: &Path) -> String { path.strip_prefix(&self.root) .unwrap_or(path) .components() .map(|c| c.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/") } } impl Repo { /// Changes reachable from the bookmark and the working copy, newest first. pub fn log(&self) -> Result> { let heads = self.bookmark_heads()?; if self.bare { return self.log_state(&heads); } let (wc_hash, _) = self.working_copy()?; let mut all = heads.clone(); all.push(wc_hash); let state = self.load_state(&all)?; let head_set: BTreeSet = heads.into_iter().collect(); let mut entries: Vec = Vec::new(); for (h, meta) in state.changes() { entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h), }); } entries.sort_by(|a, b| { b.is_working_copy .cmp(&a.is_working_copy) .then(b.change.hlc.cmp(&a.change.hlc)) .then(b.hash.cmp(&a.hash)) }); Ok(entries) } /// The log of the state with these heads, newest first, with no working /// copy involved (for hosted repositories and any explicit state). pub fn log_state(&self, heads: &[ChangeHash]) -> Result> { let state = self.load_state(heads)?; let head_set: BTreeSet = state.heads().into_iter().collect(); let mut entries: Vec = state .changes() .map(|(h, meta)| LogEntry { hash: *h, change: meta.clone(), is_working_copy: false, is_head: head_set.contains(h), }) .collect(); entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); Ok(entries) } /// Resolve a spec to a set of heads: a tag name, the current bookmark's /// name, or a single change (`resolve_change`). pub fn resolve_heads(&self, spec: &str) -> Result> { let view = self.view()?; if let Some(st) = view.bookmarks.get(spec) { return Ok(self.store.get_state(st)?.heads); } if let Some(heads) = self.tag_heads(spec)? { return Ok(heads); } Ok(vec![self.resolve_change(spec)?]) } /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark. pub fn resolve_change(&self, spec: &str) -> Result { if spec == "@" { return Ok(self.working_copy()?.0); } if let Some(st) = self.view()?.bookmarks.get(spec) { let heads = self.store.get_state(st)?.heads; return match heads.len() { 1 => Ok(heads[0]), n => fail(format!("bookmark {spec} has {n} heads; name a change, or use it where a state is expected")), }; } if spec.is_empty() || spec == "#" { return fail("empty change specifier"); } let entries = self.log()?; let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') { entries .iter() .filter(|e| e.hash.starts_with_hex(hp)) .collect() } else { entries .iter() .filter(|e| e.change.change_id.starts_with_hex(spec)) .collect() }; match matches.len() { 1 => Ok(matches[0].hash), 0 => fail(format!("no change matches {spec:?}")), _ => { let ids: BTreeSet = matches.iter().map(|e| e.change.change_id).collect(); if ids.len() == 1 { fail(format!( "change {spec} is divergent: {} variants", matches.len() )) } else { fail(format!("{spec:?} is ambiguous")) } } } } /// Human-readable description of what a change does, with line content. pub fn describe_change(&self, hash: &ChangeHash) -> Result { let change = self.store.get_change(hash)?; let base = self.load_state(&change.deps)?; let base_snapshot = base.materialize(); Ok(render_change(&change, &base, &base_snapshot)) } } fn remove_any(path: &Path) -> std::io::Result<()> { let m = fs::symlink_metadata(path)?; if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } } fn set_readonly(path: &Path, readonly: bool) -> std::io::Result<()> { let mut perms = fs::metadata(path)?.permissions(); perms.set_readonly(readonly); fs::set_permissions(path, perms) } #[cfg(unix)] fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path)?.permissions(); let mode = perms.mode(); let want = if exec { mode | 0o111 } else { mode & !0o111 }; if want != mode { perms.set_mode(want); fs::set_permissions(path, perms)?; } Ok(()) } #[cfg(not(unix))] fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> { Ok(()) } #[cfg(unix)] fn is_executable(m: &fs::Metadata) -> bool { use std::os::unix::fs::PermissionsExt; m.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] fn is_executable(_m: &fs::Metadata) -> bool { false } fn op_ref(k: OpKey) -> OpRef { OpRef { change: k.0, index: k.1, } } fn split_path(path: &str) -> (&str, &str) { match path.rfind('/') { Some(i) => (&path[..i], &path[i + 1..]), None => ("", path), } }