Compare commits
3 commits
d03ae3c2c3
...
6f307c139b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f307c139b | |||
| a6b1a027c8 | |||
| adcdaa20c6 |
16 changed files with 686 additions and 49 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -56,6 +56,7 @@ dependencies = [
|
|||
name = "arc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"clap",
|
||||
"colored",
|
||||
"git2",
|
||||
|
|
@ -87,6 +88,15 @@ version = "1.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ edition = "2024"
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
bincode = "1"
|
||||
rmp-serde = "1"
|
||||
zstd = "0.13"
|
||||
sha2 = "0.10"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
A delta-based version control system written in Rust.
|
||||
|
||||
Unlike Git's snapshot-based model, Arc stores incremental deltas using
|
||||
ZSTD-compressed MessagePack files. Changes are automatically tracked
|
||||
ZSTD-compressed bincode files. Changes are automatically tracked
|
||||
without manual staging, and commits are immutable once created.
|
||||
|
||||
Arc uses a **bookmark** system instead of branches, and bridges to Git
|
||||
|
|
@ -14,7 +14,7 @@ remotes for push, pull, clone, and sync operations via `libgit2`.
|
|||
|
||||
## Features
|
||||
|
||||
- Incremental delta storage (ZSTD + MessagePack)
|
||||
- Incremental delta storage (ZSTD + bincode)
|
||||
- Automatic change tracking (no staging step)
|
||||
- Bookmarks and immutable tags
|
||||
- Named stashes
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ An arc repository keeps all state in an `.arc/` directory at the worktree root:
|
|||
|------|--------|---------|
|
||||
| `HEAD` | YAML | Current state — one of three variants: **unborn** (no commits yet; has `bookmark`), **attached** (on a bookmark; has `bookmark` + `commit`), or **detached** (raw commit; has `commit`). |
|
||||
| `config.yml` | YAML | Local repository configuration. |
|
||||
| `commits/<id>.zst` | Zstandard-compressed MessagePack | Commit objects. Each file contains a `CommitObject` that bundles a `Commit` and its `Delta`. |
|
||||
| `commits/<id>.zst` | Zstandard-compressed bincode | Commit objects. Each file contains a `CommitObject` that bundles a `Commit` and its `Delta`. |
|
||||
| `bookmarks/<name>.yml` | YAML | One file per bookmark. Contains a `RefTarget` with an optional `commit` field. |
|
||||
| `tags/<name>.yml` | YAML | Same format as bookmarks. |
|
||||
| `stashes/state.yml` | YAML | Tracks the active stash. |
|
||||
|
|
@ -42,9 +42,9 @@ hex hashes.
|
|||
## Storage (`src/store.rs`)
|
||||
|
||||
`CommitObject` bundles a `Commit` and its `Delta` into a single unit that is
|
||||
serialized as MessagePack, then compressed with Zstandard at level 3. Files are
|
||||
serialized with bincode, then compressed with Zstandard at level 3. Files are
|
||||
written atomically (write to `.tmp`, then rename). IDs are computed by SHA-256
|
||||
hashing the MessagePack-serialized content-addressable data.
|
||||
hashing the bincode-serialized content-addressable data.
|
||||
|
||||
## Tracking (`src/tracking.rs`)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Initialize a new arc repository. Creates the `.arc/` directory structure includi
|
|||
|
||||
### `arc commit <message>`
|
||||
|
||||
Commit all current changes. No staging area is needed — changes are detected automatically by comparing the worktree to the last commit. Creates a ZSTD-compressed MessagePack commit object in `.arc/commits/`. If a signing key is configured (`user.key`), the commit is signed with SSH.
|
||||
Commit all current changes. No staging area is needed — changes are detected automatically by comparing the worktree to the last commit. Creates a ZSTD-compressed bincode commit object in `.arc/commits/`. If a signing key is configured (`user.key`), the commit is signed with SSH.
|
||||
|
||||
### `arc status`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Git Bridge
|
||||
|
||||
Arc uses an internal git bridge to interoperate with git remotes. Since Arc uses its own delta-based storage format (ZSTD-compressed MessagePack), it maintains a shadow bare git repository to translate between formats when communicating with git servers.
|
||||
Arc uses an internal git bridge to interoperate with git remotes. Since Arc uses its own delta-based storage format (ZSTD-compressed bincode), it maintains a shadow bare git repository to translate between formats when communicating with git servers.
|
||||
|
||||
## Shadow Repository
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ This is an overview of the foundational rules that make the software.
|
|||
8a. use `feat: <message>` for new features, `fix: <message>` for bug fixes, `refactor: <message>` for changes.
|
||||
8b. use `docs: <message>` for docs changes, `build: <message>` for build system changes, etc.
|
||||
9. Anything involving remotes should use `libgit` or `git2` libraries for compatibility.
|
||||
10. Deltas should be stored using ZSTD compressed Messagepack files for easy storage.
|
||||
10. Deltas should be stored using ZSTD compressed bincode files for easy storage.
|
||||
11. When pushing, pulling, and fetching from remotes, it should be bridged to git.
|
||||
12. Lastly, it should cover 90% of use cases that git has, for full feature support.
|
||||
13. Arc should support **optional** commit signing via SSH keys.
|
||||
|
|
@ -117,7 +117,7 @@ These are the implementation phases that should be implemented incrementally.
|
|||
|
||||
1. **Project scaffolding** - Nix flake, direnv, Rust project structure, CLI skeleton with clap, help
|
||||
2. **Core repo structure** - init, internal data model (commits, deltas, YAML config), .arcignore
|
||||
3. **Tracking & committing** - commit, status, diff, auto-change detection, ZSTD + MessagePack storage
|
||||
3. **Tracking & committing** - commit, status, diff, auto-change detection, ZSTD + bincode storage
|
||||
4. **History & inspection** - log, show, history, state reconstruction from delta chains
|
||||
5. **Bookmarks & tags** - mark commands, tag commands, and switch command
|
||||
6. **Undo & modification** - revert, reset, graft, three-way merge
|
||||
|
|
|
|||
194
src/check.rs
Normal file
194
src/check.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::{CommitId, RefTarget};
|
||||
use crate::repo::Repository;
|
||||
use crate::store;
|
||||
use crate::tracking;
|
||||
use crate::ui;
|
||||
|
||||
pub struct CheckReport {
|
||||
pub commits_checked: usize,
|
||||
pub refs_checked: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl CheckReport {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.errors.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CheckReport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.is_ok() {
|
||||
writeln!(
|
||||
f,
|
||||
"{}",
|
||||
ui::success(&format!(
|
||||
"repository ok: {} commit(s), {} ref(s) checked",
|
||||
self.commits_checked, self.refs_checked
|
||||
))
|
||||
)
|
||||
} else {
|
||||
for err in &self.errors {
|
||||
writeln!(f, "{}", ui::error(err))?;
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\n{} error(s) found in {} commit(s), {} ref(s)",
|
||||
self.errors.len(),
|
||||
self.commits_checked,
|
||||
self.refs_checked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(repo: &Repository) -> Result<CheckReport> {
|
||||
debug!(1, "running repository integrity check");
|
||||
let mut errors = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut refs_checked = 0usize;
|
||||
|
||||
let bookmark_ids = collect_ref_targets(repo, &repo.bookmarks_dir(), &mut errors);
|
||||
refs_checked += bookmark_ids.len();
|
||||
let tag_ids = collect_ref_targets(repo, &repo.tags_dir(), &mut errors);
|
||||
refs_checked += tag_ids.len();
|
||||
|
||||
let head = match repo.load_head() {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
errors.push(format!("failed to load HEAD: {e}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let head_commit = match &head {
|
||||
Some(crate::model::Head::Attached { commit, .. }) => Some(commit.clone()),
|
||||
Some(crate::model::Head::Detached { commit }) => Some(commit.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut all_roots: Vec<CommitId> = Vec::new();
|
||||
if let Some(id) = head_commit {
|
||||
all_roots.push(id);
|
||||
}
|
||||
all_roots.extend(bookmark_ids);
|
||||
all_roots.extend(tag_ids);
|
||||
|
||||
for root in &all_roots {
|
||||
walk_commits(repo, root, &mut visited, &mut errors);
|
||||
}
|
||||
|
||||
if let Some(tip) = all_roots.first() {
|
||||
debug!(2, "verifying delta chain replay from HEAD");
|
||||
if let Err(e) = tracking::materialize_committed_tree(repo, tip) {
|
||||
errors.push(format!("delta chain replay failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
let commits_checked = visited.len();
|
||||
|
||||
let orphans = find_orphan_files(repo, &visited);
|
||||
for orphan in &orphans {
|
||||
errors.push(format!("orphan commit object: {orphan}"));
|
||||
}
|
||||
|
||||
debug!(
|
||||
1,
|
||||
"check complete: {} commit(s), {} ref(s), {} error(s)",
|
||||
commits_checked,
|
||||
refs_checked,
|
||||
errors.len()
|
||||
);
|
||||
|
||||
Ok(CheckReport {
|
||||
commits_checked,
|
||||
refs_checked,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_ref_targets(
|
||||
repo: &Repository,
|
||||
dir: &std::path::Path,
|
||||
errors: &mut Vec<String>,
|
||||
) -> Vec<CommitId> {
|
||||
let mut ids = Vec::new();
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return ids,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(contents) => match serde_yaml::from_str::<RefTarget>(&contents) {
|
||||
Ok(ref_target) => {
|
||||
if let Some(id) = ref_target.commit {
|
||||
if !store::commit_object_path(repo, &id).exists() {
|
||||
errors.push(format!("ref '{}' points to missing commit {}", name, id));
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Err(e) => errors.push(format!("ref '{}' has invalid format: {}", name, e)),
|
||||
},
|
||||
Err(e) => errors.push(format!("cannot read ref '{}': {}", name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
fn walk_commits(
|
||||
repo: &Repository,
|
||||
start: &CommitId,
|
||||
visited: &mut HashSet<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
let mut queue = vec![start.clone()];
|
||||
|
||||
while let Some(id) = queue.pop() {
|
||||
if !visited.insert(id.0.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match store::read_commit_object(repo, &id) {
|
||||
Ok(obj) => {
|
||||
for parent in &obj.commit.parents {
|
||||
queue.push(parent.clone());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("commit {}: {}", &id.0[..id.0.len().min(12)], e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_orphan_files(repo: &Repository, reachable: &HashSet<String>) -> Vec<String> {
|
||||
let dir = repo.commits_dir();
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut orphans = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(id) = name.strip_suffix(".zst")
|
||||
&& !reachable.contains(id)
|
||||
{
|
||||
orphans.push(id.to_string());
|
||||
}
|
||||
}
|
||||
orphans.sort();
|
||||
orphans
|
||||
}
|
||||
24
src/cli.rs
24
src/cli.rs
|
|
@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU8, Ordering};
|
|||
use clap::{ArgAction, Parser, Subcommand};
|
||||
|
||||
use crate::bridge;
|
||||
use crate::check;
|
||||
use crate::config;
|
||||
use crate::diff;
|
||||
use crate::ignore::IgnoreRules;
|
||||
|
|
@ -134,6 +135,9 @@ pub enum Command {
|
|||
/// Convert a git repo to an arc repo
|
||||
Migrate,
|
||||
|
||||
/// Verify repository integrity
|
||||
Check,
|
||||
|
||||
/// Manage bookmarks
|
||||
Mark {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -580,6 +584,22 @@ pub fn dispatch(cli: Cli) {
|
|||
}
|
||||
}
|
||||
}
|
||||
Command::Check => {
|
||||
debug!(1, "command: check");
|
||||
let repo = open_repo_or_exit();
|
||||
match check::check(&repo) {
|
||||
Ok(report) => {
|
||||
print!("{report}");
|
||||
if !report.errors.is_empty() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", ui::error(&e.to_string()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Mark { command } => {
|
||||
debug!(1, "command: mark");
|
||||
let repo = open_repo_or_exit();
|
||||
|
|
@ -882,11 +902,11 @@ fn run_diff(repo: &Repository, range: Option<&str>) -> crate::error::Result<Stri
|
|||
let resolved = resolve::parse_and_resolve_range(repo, Some(spec))?;
|
||||
let mut old_tree = BTreeMap::new();
|
||||
for obj in &resolved.chain[..=resolved.start_idx] {
|
||||
tracking::apply_delta(&mut old_tree, &obj.delta);
|
||||
tracking::apply_delta(&mut old_tree, &obj.delta)?;
|
||||
}
|
||||
let mut new_tree = old_tree.clone();
|
||||
for obj in &resolved.chain[resolved.start_idx + 1..] {
|
||||
tracking::apply_delta(&mut new_tree, &obj.delta);
|
||||
tracking::apply_delta(&mut new_tree, &obj.delta)?;
|
||||
}
|
||||
let changes = tracking::detect_changes(&old_tree, &new_tree);
|
||||
Ok(diff::render_diff(&old_tree, &changes))
|
||||
|
|
|
|||
22
src/error.rs
22
src/error.rs
|
|
@ -5,8 +5,7 @@ use std::io;
|
|||
pub enum ArcError {
|
||||
Io(io::Error),
|
||||
Yaml(serde_yaml::Error),
|
||||
MsgPack(rmp_serde::encode::Error),
|
||||
MsgPackDecode(rmp_serde::decode::Error),
|
||||
Bincode(Box<bincode::ErrorKind>),
|
||||
RepoNotFound,
|
||||
RepoAlreadyExists,
|
||||
InvalidPath(String),
|
||||
|
|
@ -41,6 +40,8 @@ pub enum ArcError {
|
|||
NotAGitRepo,
|
||||
FastForwardOnly(String),
|
||||
SigningError(String),
|
||||
CorruptObject(String),
|
||||
UnsupportedDelta(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ArcError {
|
||||
|
|
@ -48,8 +49,7 @@ impl fmt::Display for ArcError {
|
|||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::Yaml(e) => write!(f, "yaml error: {e}"),
|
||||
Self::MsgPack(e) => write!(f, "msgpack encode error: {e}"),
|
||||
Self::MsgPackDecode(e) => write!(f, "msgpack decode error: {e}"),
|
||||
Self::Bincode(e) => write!(f, "bincode error: {e}"),
|
||||
Self::RepoNotFound => write!(f, "not an arc repository (or any parent)"),
|
||||
Self::RepoAlreadyExists => {
|
||||
write!(f, "arc repository already exists in this directory")
|
||||
|
|
@ -97,6 +97,8 @@ impl fmt::Display for ArcError {
|
|||
Self::NotAGitRepo => write!(f, "not a git repository"),
|
||||
Self::FastForwardOnly(reason) => write!(f, "cannot fast-forward: {reason}"),
|
||||
Self::SigningError(msg) => write!(f, "signing error: {msg}"),
|
||||
Self::CorruptObject(msg) => write!(f, "corrupt object: {msg}"),
|
||||
Self::UnsupportedDelta(msg) => write!(f, "unsupported delta format: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,15 +117,9 @@ impl From<serde_yaml::Error> for ArcError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<rmp_serde::encode::Error> for ArcError {
|
||||
fn from(e: rmp_serde::encode::Error) -> Self {
|
||||
Self::MsgPack(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rmp_serde::decode::Error> for ArcError {
|
||||
fn from(e: rmp_serde::decode::Error) -> Self {
|
||||
Self::MsgPackDecode(e)
|
||||
impl From<Box<bincode::ErrorKind>> for ArcError {
|
||||
fn from(e: Box<bincode::ErrorKind>) -> Self {
|
||||
Self::Bincode(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
pub mod ui;
|
||||
|
||||
pub mod bridge;
|
||||
pub mod check;
|
||||
mod cli;
|
||||
pub mod config;
|
||||
pub mod diff;
|
||||
|
|
|
|||
17
src/model.rs
17
src/model.rs
|
|
@ -40,7 +40,6 @@ pub struct Commit {
|
|||
pub message: String,
|
||||
pub author: Option<Signature>,
|
||||
pub timestamp: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub ssh_signature: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -94,21 +93,21 @@ pub struct RefTarget {
|
|||
}
|
||||
|
||||
impl Commit {
|
||||
pub fn to_msgpack(&self) -> crate::error::Result<Vec<u8>> {
|
||||
Ok(rmp_serde::to_vec(self)?)
|
||||
pub fn to_bytes(&self) -> crate::error::Result<Vec<u8>> {
|
||||
Ok(bincode::serialize(self)?)
|
||||
}
|
||||
|
||||
pub fn from_msgpack(bytes: &[u8]) -> crate::error::Result<Self> {
|
||||
Ok(rmp_serde::from_slice(bytes)?)
|
||||
pub fn from_bytes(bytes: &[u8]) -> crate::error::Result<Self> {
|
||||
Ok(bincode::deserialize(bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Delta {
|
||||
pub fn to_msgpack(&self) -> crate::error::Result<Vec<u8>> {
|
||||
Ok(rmp_serde::to_vec(self)?)
|
||||
pub fn to_bytes(&self) -> crate::error::Result<Vec<u8>> {
|
||||
Ok(bincode::serialize(self)?)
|
||||
}
|
||||
|
||||
pub fn from_msgpack(bytes: &[u8]) -> crate::error::Result<Self> {
|
||||
Ok(rmp_serde::from_slice(bytes)?)
|
||||
pub fn from_bytes(bytes: &[u8]) -> crate::error::Result<Self> {
|
||||
Ok(bincode::deserialize(bytes)?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ pub fn graft(repo: &Repository, target: &str, onto: &str) -> Result<Vec<CommitId
|
|||
let short_id = &obj.commit.id.0[..obj.commit.id.0.len().min(12)];
|
||||
let message = format!("graft {short_id}: {}", obj.commit.message);
|
||||
|
||||
let new_id = commit_tree_internal(repo, &message, vec![current_tip], &outcome.tree)?;
|
||||
let new_id = write_commit_object_only(repo, &message, vec![current_tip], &outcome.tree)?;
|
||||
|
||||
current_tip = new_id.clone();
|
||||
current_tree = outcome.tree;
|
||||
|
|
@ -317,6 +317,63 @@ fn commit_tree(
|
|||
Ok(id)
|
||||
}
|
||||
|
||||
fn write_commit_object_only(
|
||||
repo: &Repository,
|
||||
message: &str,
|
||||
parents: Vec<CommitId>,
|
||||
new_tree: &FileTree,
|
||||
) -> Result<CommitId> {
|
||||
let parent_tree = if parents.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
tracking::materialize_committed_tree(repo, &parents[0])?
|
||||
};
|
||||
|
||||
let changes = tracking::detect_changes(&parent_tree, new_tree);
|
||||
|
||||
if changes.is_empty() {
|
||||
return Err(ArcError::NothingToCommit);
|
||||
}
|
||||
|
||||
let delta_id = store::compute_delta_id(&parents.first().cloned(), &changes)?;
|
||||
let delta = Delta {
|
||||
id: delta_id.clone(),
|
||||
base: parents.first().cloned(),
|
||||
changes,
|
||||
};
|
||||
|
||||
let config = crate::config::load_effective(repo);
|
||||
let author = match (config.user_name, config.user_email) {
|
||||
(Some(name), Some(email)) => Some(crate::model::Signature { name, email }),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|_| ArcError::ClockError)?
|
||||
.as_secs() as i64;
|
||||
|
||||
let commit_id = store::compute_commit_id(&parents, &delta_id, message, &author, timestamp)?;
|
||||
|
||||
let commit_obj = crate::model::Commit {
|
||||
id: commit_id.clone(),
|
||||
parents: parents.clone(),
|
||||
delta: delta_id,
|
||||
message: message.to_string(),
|
||||
author,
|
||||
timestamp,
|
||||
ssh_signature: None,
|
||||
};
|
||||
|
||||
let obj = CommitObject {
|
||||
commit: commit_obj,
|
||||
delta,
|
||||
};
|
||||
store::write_commit_object(repo, &obj)?;
|
||||
|
||||
Ok(commit_id)
|
||||
}
|
||||
|
||||
fn commit_tree_internal(
|
||||
repo: &Repository,
|
||||
message: &str,
|
||||
|
|
|
|||
210
src/store.rs
210
src/store.rs
|
|
@ -5,7 +5,7 @@ use std::path::PathBuf;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::error::{ArcError, Result};
|
||||
use crate::model::{Commit, CommitId, Delta, DeltaId, FileChange, Signature};
|
||||
use crate::repo::Repository;
|
||||
|
||||
|
|
@ -15,15 +15,73 @@ pub struct CommitObject {
|
|||
pub delta: Delta,
|
||||
}
|
||||
|
||||
mod legacy {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::{CommitId, Delta, DeltaId, FileChange, Signature};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LegacyCommit {
|
||||
pub id: CommitId,
|
||||
pub parents: Vec<CommitId>,
|
||||
pub delta: DeltaId,
|
||||
pub message: String,
|
||||
pub author: Option<Signature>,
|
||||
pub timestamp: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub ssh_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LegacyCommitObject {
|
||||
pub commit: LegacyCommit,
|
||||
pub delta: Delta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeltaForHash<'a> {
|
||||
pub base: &'a Option<CommitId>,
|
||||
pub changes: &'a [FileChange],
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CommitForHash<'a> {
|
||||
pub parents: &'a [CommitId],
|
||||
pub delta: &'a DeltaId,
|
||||
pub message: &'a str,
|
||||
pub author: &'a Option<Signature>,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl LegacyCommitObject {
|
||||
pub fn into_commit_object(self) -> super::CommitObject {
|
||||
super::CommitObject {
|
||||
commit: crate::model::Commit {
|
||||
id: self.commit.id,
|
||||
parents: self.commit.parents,
|
||||
delta: self.commit.delta,
|
||||
message: self.commit.message,
|
||||
author: self.commit.author,
|
||||
timestamp: self.commit.timestamp,
|
||||
ssh_signature: self.commit.ssh_signature,
|
||||
},
|
||||
delta: self.delta,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_object_path(repo: &Repository, id: &CommitId) -> PathBuf {
|
||||
repo.commits_dir().join(format!("{}.zst", id.0))
|
||||
}
|
||||
|
||||
pub fn write_commit_object(repo: &Repository, obj: &CommitObject) -> Result<()> {
|
||||
debug!(3, "writing commit object {}", obj.commit.id.0);
|
||||
let msgpack = rmp_serde::to_vec(obj)?;
|
||||
let compressed =
|
||||
zstd::stream::encode_all(Cursor::new(&msgpack), 3).map_err(std::io::Error::other)?;
|
||||
let encoded = bincode::serialize(obj)?;
|
||||
let mut encoder = zstd::Encoder::new(Vec::new(), 3).map_err(std::io::Error::other)?;
|
||||
encoder.include_checksum(true).map_err(std::io::Error::other)?;
|
||||
encoder.write_all(&encoded)?;
|
||||
let compressed = encoder.finish().map_err(std::io::Error::other)?;
|
||||
|
||||
let path = commit_object_path(repo, &obj.commit.id);
|
||||
let tmp_path = path.with_extension("zst.tmp");
|
||||
|
|
@ -31,6 +89,8 @@ pub fn write_commit_object(repo: &Repository, obj: &CommitObject) -> Result<()>
|
|||
f.write_all(&compressed)?;
|
||||
f.sync_all()?;
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
let parent_dir = std::fs::File::open(repo.commits_dir())?;
|
||||
parent_dir.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -40,12 +100,142 @@ pub fn read_commit_object(repo: &Repository, id: &CommitId) -> Result<CommitObje
|
|||
let compressed = fs::read(&path)?;
|
||||
let mut decoder =
|
||||
zstd::stream::Decoder::new(Cursor::new(&compressed)).map_err(std::io::Error::other)?;
|
||||
let mut msgpack = Vec::new();
|
||||
let mut decoded = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut msgpack)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let obj: CommitObject = rmp_serde::from_slice(&msgpack)?;
|
||||
Ok(obj)
|
||||
|
||||
match bincode::deserialize::<CommitObject>(&decoded) {
|
||||
Ok(obj) => {
|
||||
validate_commit_object(&obj, id)?;
|
||||
Ok(obj)
|
||||
}
|
||||
Err(bincode_err) => {
|
||||
debug!(3, "bincode failed, trying legacy msgpack for {}", id.0);
|
||||
match rmp_serde::from_slice::<legacy::LegacyCommitObject>(&decoded) {
|
||||
Ok(legacy_obj) => {
|
||||
let obj = legacy_obj.into_commit_object();
|
||||
validate_legacy_commit_object(&obj, id)?;
|
||||
Ok(obj)
|
||||
}
|
||||
Err(msgpack_err) => Err(ArcError::CorruptObject(format!(
|
||||
"failed to decode object (bincode: {bincode_err}, msgpack: {msgpack_err})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_commit_object(obj: &CommitObject, id: &CommitId) -> Result<()> {
|
||||
let expected_delta_id = compute_delta_id(&obj.delta.base, &obj.delta.changes)?;
|
||||
if expected_delta_id != obj.delta.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"delta id mismatch: expected {}, found {}",
|
||||
expected_delta_id, obj.delta.id
|
||||
)));
|
||||
}
|
||||
if obj.commit.delta != obj.delta.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit references delta {}, but object contains delta {}",
|
||||
obj.commit.delta, obj.delta.id
|
||||
)));
|
||||
}
|
||||
let expected_commit_id = compute_commit_id(
|
||||
&obj.commit.parents,
|
||||
&obj.delta.id,
|
||||
&obj.commit.message,
|
||||
&obj.commit.author,
|
||||
obj.commit.timestamp,
|
||||
)?;
|
||||
if expected_commit_id != obj.commit.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit id mismatch: expected {}, found {}",
|
||||
expected_commit_id, obj.commit.id
|
||||
)));
|
||||
}
|
||||
if obj.commit.id != *id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit id does not match expected id: expected {}, found {}",
|
||||
id, obj.commit.id
|
||||
)));
|
||||
}
|
||||
if obj.delta.base != obj.commit.parents.first().cloned() {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"delta base {:?} does not match first parent {:?}",
|
||||
obj.delta.base,
|
||||
obj.commit.parents.first()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_legacy_commit_object(obj: &CommitObject, id: &CommitId) -> Result<()> {
|
||||
let expected_delta_id = compute_legacy_delta_id(&obj.delta.base, &obj.delta.changes)?;
|
||||
if expected_delta_id != obj.delta.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"delta id mismatch: expected {}, found {}",
|
||||
expected_delta_id, obj.delta.id
|
||||
)));
|
||||
}
|
||||
if obj.commit.delta != obj.delta.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit references delta {}, but object contains delta {}",
|
||||
obj.commit.delta, obj.delta.id
|
||||
)));
|
||||
}
|
||||
let expected_commit_id = compute_legacy_commit_id(
|
||||
&obj.commit.parents,
|
||||
&obj.delta.id,
|
||||
&obj.commit.message,
|
||||
&obj.commit.author,
|
||||
obj.commit.timestamp,
|
||||
)?;
|
||||
if expected_commit_id != obj.commit.id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit id mismatch: expected {}, found {}",
|
||||
expected_commit_id, obj.commit.id
|
||||
)));
|
||||
}
|
||||
if obj.commit.id != *id {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"commit id does not match expected id: expected {}, found {}",
|
||||
id, obj.commit.id
|
||||
)));
|
||||
}
|
||||
if obj.delta.base != obj.commit.parents.first().cloned() {
|
||||
return Err(ArcError::CorruptObject(format!(
|
||||
"delta base {:?} does not match first parent {:?}",
|
||||
obj.delta.base,
|
||||
obj.commit.parents.first()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_legacy_delta_id(base: &Option<CommitId>, changes: &[FileChange]) -> Result<DeltaId> {
|
||||
let hashable = legacy::DeltaForHash { base, changes };
|
||||
let bytes = rmp_serde::to_vec(&hashable)
|
||||
.map_err(|e| ArcError::HashError(e.to_string()))?;
|
||||
Ok(DeltaId(sha256_hex(&bytes)))
|
||||
}
|
||||
|
||||
fn compute_legacy_commit_id(
|
||||
parents: &[CommitId],
|
||||
delta: &DeltaId,
|
||||
message: &str,
|
||||
author: &Option<Signature>,
|
||||
timestamp: i64,
|
||||
) -> Result<CommitId> {
|
||||
let hashable = legacy::CommitForHash {
|
||||
parents,
|
||||
delta,
|
||||
message,
|
||||
author,
|
||||
timestamp,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec(&hashable)
|
||||
.map_err(|e| ArcError::HashError(e.to_string()))?;
|
||||
Ok(CommitId(sha256_hex(&bytes)))
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
|
|
@ -72,7 +262,7 @@ struct CommitForHash<'a> {
|
|||
pub fn compute_delta_id(base: &Option<CommitId>, changes: &[FileChange]) -> Result<DeltaId> {
|
||||
debug!(3, "computing delta id (base: {:?})", base);
|
||||
let hashable = DeltaForHash { base, changes };
|
||||
let bytes = rmp_serde::to_vec(&hashable)
|
||||
let bytes = bincode::serialize(&hashable)
|
||||
.map_err(|e| crate::error::ArcError::HashError(e.to_string()))?;
|
||||
Ok(DeltaId(sha256_hex(&bytes)))
|
||||
}
|
||||
|
|
@ -92,7 +282,7 @@ pub fn compute_commit_id(
|
|||
author,
|
||||
timestamp,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec(&hashable)
|
||||
let bytes = bincode::serialize(&hashable)
|
||||
.map_err(|e| crate::error::ArcError::HashError(e.to_string()))?;
|
||||
Ok(CommitId(sha256_hex(&bytes)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,16 @@ pub fn materialize_committed_tree(repo: &Repository, head: &CommitId) -> Result<
|
|||
debug!(3, "materializing tree at commit {}", head.0);
|
||||
let history = load_linear_history(repo, head)?;
|
||||
let mut tree = BTreeMap::new();
|
||||
let mut expected_base: Option<crate::model::CommitId> = None;
|
||||
for obj in &history {
|
||||
apply_delta(&mut tree, &obj.delta);
|
||||
if obj.delta.base != expected_base {
|
||||
return Err(crate::error::ArcError::CorruptObject(format!(
|
||||
"delta chain broken at commit {}",
|
||||
obj.commit.id.0
|
||||
)));
|
||||
}
|
||||
apply_delta(&mut tree, &obj.delta)?;
|
||||
expected_base = Some(obj.commit.id.clone());
|
||||
}
|
||||
debug!(3, "materialized tree with {} file(s)", tree.len());
|
||||
Ok(tree)
|
||||
|
|
@ -93,14 +101,20 @@ pub fn load_linear_history(repo: &Repository, head: &CommitId) -> Result<Vec<Com
|
|||
Ok(chain)
|
||||
}
|
||||
|
||||
pub fn apply_delta(tree: &mut FileTree, delta: &Delta) {
|
||||
pub fn apply_delta(tree: &mut FileTree, delta: &Delta) -> crate::error::Result<()> {
|
||||
for change in &delta.changes {
|
||||
match &change.kind {
|
||||
FileChangeKind::Add { content } | FileChangeKind::Modify { content } => {
|
||||
if let FileContentDelta::Full { bytes } = content {
|
||||
FileChangeKind::Add { content } | FileChangeKind::Modify { content } => match content {
|
||||
FileContentDelta::Full { bytes } => {
|
||||
tree.insert(change.path.clone(), bytes.clone());
|
||||
}
|
||||
}
|
||||
FileContentDelta::Patch { format, .. } => {
|
||||
return Err(crate::error::ArcError::UnsupportedDelta(format!(
|
||||
"patch format '{}' on file '{}'",
|
||||
format, change.path
|
||||
)));
|
||||
}
|
||||
},
|
||||
FileChangeKind::Delete => {
|
||||
tree.remove(&change.path);
|
||||
}
|
||||
|
|
@ -111,6 +125,7 @@ pub fn apply_delta(tree: &mut FileTree, delta: &Delta) {
|
|||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn detect_changes(committed: &FileTree, worktree: &FileTree) -> Vec<FileChange> {
|
||||
|
|
|
|||
154
tests/check.rs
Normal file
154
tests/check.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn arc_cmd() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_arc"));
|
||||
cmd.env("NO_COLOR", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn init_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
arc_cmd()
|
||||
.arg("init")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to init");
|
||||
dir
|
||||
}
|
||||
|
||||
fn commit_file(dir: &TempDir, name: &str, content: &str, msg: &str) {
|
||||
std::fs::write(dir.path().join(name), content).unwrap();
|
||||
let output = arc_cmd()
|
||||
.args(["commit", msg])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to commit");
|
||||
assert!(output.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_clean_repo_succeeds() {
|
||||
let dir = init_repo();
|
||||
commit_file(&dir, "a.txt", "hello\n", "initial");
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("repository ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_multi_commit_repo() {
|
||||
let dir = init_repo();
|
||||
commit_file(&dir, "a.txt", "hello\n", "first");
|
||||
commit_file(&dir, "b.txt", "world\n", "second");
|
||||
commit_file(&dir, "a.txt", "updated\n", "third");
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("3 commit(s)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_corrupt_commit_file() {
|
||||
let dir = init_repo();
|
||||
commit_file(&dir, "a.txt", "hello\n", "initial");
|
||||
|
||||
let commits_dir = dir.path().join(".arc").join("commits");
|
||||
let entries: Vec<_> = std::fs::read_dir(&commits_dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
let commit_path = entries[0].path();
|
||||
std::fs::write(&commit_path, b"corrupted data").unwrap();
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_commit_from_ref() {
|
||||
let dir = init_repo();
|
||||
commit_file(&dir, "a.txt", "hello\n", "initial");
|
||||
|
||||
let commits_dir = dir.path().join(".arc").join("commits");
|
||||
let entries: Vec<_> = std::fs::read_dir(&commits_dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
for entry in entries {
|
||||
std::fs::remove_file(entry.path()).unwrap();
|
||||
}
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("missing commit") || stdout.contains("error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_with_bookmarks_and_tags() {
|
||||
let dir = init_repo();
|
||||
commit_file(&dir, "a.txt", "hello\n", "initial");
|
||||
|
||||
arc_cmd()
|
||||
.args(["mark", "add", "feature"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed");
|
||||
|
||||
arc_cmd()
|
||||
.args(["tag", "add", "v1"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed");
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("repository ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_empty_repo() {
|
||||
let dir = init_repo();
|
||||
|
||||
let output = arc_cmd()
|
||||
.arg("check")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("failed to run check");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("repository ok"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue