diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd4211..7c6363e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: release # Build wheels and an sdist for `pyvedit`. -# - On push to main / pull requests: build all targets, run Rust tests, -# but do not publish. +# - On push to main / pull requests: build all targets, run Rust/Python +# tests plus Rust formatting and clippy checks, but do not publish. # - On a `v*` tag (e.g. `v0.0.1`): build all targets, then publish to # PyPI using the PYPI_API_TOKEN secret. # @@ -19,16 +19,42 @@ permissions: contents: read jobs: - rust-tests: - name: Rust tests + rust-checks: + name: Rust checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + - name: cargo fmt + run: cargo fmt --check + - name: cargo clippy + run: cargo clippy --workspace --exclude vedit-py -- -D warnings - name: cargo test run: cargo test --workspace --exclude vedit-py + python-tests: + name: Python tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Create test venv + run: python -m venv .venv + - name: Install test tooling + run: .venv/bin/python -m pip install --upgrade pip maturin pytest + - name: Build extension in place + working-directory: crates/vedit-py + run: ../../.venv/bin/maturin develop --release + - name: pytest + run: .venv/bin/python -m pytest crates/vedit-py/tests/ + build-linux: name: Wheels - Linux ${{ matrix.target }} runs-on: ubuntu-latest @@ -113,7 +139,15 @@ jobs: publish-to-pypi: name: Publish to PyPI - needs: [rust-tests, build-linux, build-macos, build-windows, build-sdist] + needs: + [ + rust-checks, + python-tests, + build-linux, + build-macos, + build-windows, + build-sdist, + ] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: diff --git a/Cargo.lock b/Cargo.lock index ccf8a3a..2d49837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,6 +784,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "windows-sys", ] [[package]] diff --git a/crates/vedit-cli/src/cmd/checkout.rs b/crates/vedit-cli/src/cmd/checkout.rs index 2f76b09..57d8fb7 100644 --- a/crates/vedit-cli/src/cmd/checkout.rs +++ b/crates/vedit-cli/src/cmd/checkout.rs @@ -31,9 +31,7 @@ pub fn run(refstr: &str, output: Option<&Path>) -> Result<()> { // Branch-switch mode. The ref must be a branch. if repo.branch_target(refstr)?.is_none() { - anyhow::bail!( - "{refstr} is not a branch. Use `-o ` to write a timeline by hash." - ); + anyhow::bail!("{refstr} is not a branch. Use `-o ` to write a timeline by hash."); } repo.switch_branch(refstr)?; println!("Switched to branch {refstr}"); diff --git a/crates/vedit-cli/src/cmd/log.rs b/crates/vedit-cli/src/cmd/log.rs index 1e31f49..cc0ffed 100644 --- a/crates/vedit-cli/src/cmd/log.rs +++ b/crates/vedit-cli/src/cmd/log.rs @@ -22,9 +22,10 @@ pub fn run(refstr: &str) -> Result<()> { let mut tags: Vec = Vec::new(); if let Some((head_label, branch_name)) = &head_target && let Ok(Some(target)) = repo.branch_target(branch_name) - && &target == hash { - tags.push(format!("{head_label} -> {branch_name}")); - } + && &target == hash + { + tags.push(format!("{head_label} -> {branch_name}")); + } let tag_str = if tags.is_empty() { String::new() } else { diff --git a/crates/vedit-cli/src/cmd/merge.rs b/crates/vedit-cli/src/cmd/merge.rs index ceffa56..3059c47 100644 --- a/crates/vedit-cli/src/cmd/merge.rs +++ b/crates/vedit-cli/src/cmd/merge.rs @@ -10,8 +10,8 @@ //! and exit non-zero — v0.6 has no in-place resolution UX. use crate::author; -use anyhow::{anyhow, bail, Result}; -use vedit_core::merge::{merge as run_merge, MergeOutcome}; +use anyhow::{Result, anyhow, bail}; +use vedit_core::merge::{MergeOutcome, merge as run_merge}; use vedit_core::object; use vedit_core::otio; use vedit_core::repo::{HeadState, Repo}; @@ -57,10 +57,7 @@ pub fn run(target: &str, options: MergeOptions) -> Result<()> { ); return Ok(()); } - std::fs::write( - repo.root.join("refs/heads").join(¤t_branch), - format!("{target_hash}\n"), - )?; + repo.set_branch_target(¤t_branch, &target_hash)?; println!( "Fast-forwarded {current_branch} from {} to {}", short(&head_hash), @@ -118,9 +115,9 @@ pub fn run(target: &str, options: MergeOptions) -> Result<()> { let merged_value = synthesize_otio(&repo, &head_hash, &target_hash, &merged_timeline)?; let timeline_hash = repo.write_timeline(&merged_value)?; - let message = options.message.unwrap_or_else(|| { - format!("Merge branch '{target}' into {current_branch}") - }); + let message = options + .message + .unwrap_or_else(|| format!("Merge branch '{target}' into {current_branch}")); let commit_hash = repo.commit_with_parents( &timeline_hash, @@ -221,16 +218,18 @@ fn synthesize_otio( // Prefer ours's JSON if structurally equal. if let Some(t) = ours_index.get(&key) && *t == merged_track - && let Some(json) = ours_tracks_json.remove(&key) { - new_track_children.push(json); - continue; - } + && let Some(json) = ours_tracks_json.remove(&key) + { + new_track_children.push(json); + continue; + } if let Some(t) = theirs_index.get(&key) && *t == merged_track - && let Some(json) = theirs_tracks_json.remove(&key) { - new_track_children.push(json); - continue; - } + && let Some(json) = theirs_tracks_json.remove(&key) + { + new_track_children.push(json); + continue; + } // Fallback: emit a minimal-but-valid Track JSON from our model. new_track_children.push(synth_track_json(merged_track)); } @@ -300,12 +299,12 @@ fn describe_conflict(kind: &vedit_core::merge::ConflictKind) -> String { match kind { BothModified => "both branches modified this track".to_string(), BothAdded => "both branches added this track with different content".to_string(), - DeleteVsModify { deleter: Side::Ours } => { - "ours deleted this track but theirs modified it".to_string() - } - DeleteVsModify { deleter: Side::Theirs } => { - "theirs deleted this track but ours modified it".to_string() - } + DeleteVsModify { + deleter: Side::Ours, + } => "ours deleted this track but theirs modified it".to_string(), + DeleteVsModify { + deleter: Side::Theirs, + } => "theirs deleted this track but ours modified it".to_string(), } } diff --git a/crates/vedit-cli/src/diff/render.rs b/crates/vedit-cli/src/diff/render.rs index b5aba86..67f78b6 100644 --- a/crates/vedit-cli/src/diff/render.rs +++ b/crates/vedit-cli/src/diff/render.rs @@ -3,7 +3,10 @@ use vedit_core::model::{RationalTime, TimeRange}; pub fn render(changes: &[Change]) -> Vec { let collapsed = collapse_synced_pairs(changes); - collapsed.iter().map(|(c, synced)| render_one(c, *synced)).collect() + collapsed + .iter() + .map(|(c, synced)| render_one(c, *synced)) + .collect() } /// Detect video/audio mirror pairs and collapse them. Two changes are @@ -44,28 +47,70 @@ fn mirrors(a: &Change, b: &Change) -> bool { use Change::*; match (a, b) { ( - Trimmed { clip: ca, before: ba, after: aa, track: ta }, - Trimmed { clip: cb, before: bb, after: ab, track: tb }, + Trimmed { + clip: ca, + before: ba, + after: aa, + track: ta, + }, + Trimmed { + clip: cb, + before: bb, + after: ab, + track: tb, + }, ) => ta != tb && ca.name == cb.name && ba == bb && aa == ab, ( - Moved { clip: ca, after_neighbor: na, before_neighbor: pa, .. }, - Moved { clip: cb, after_neighbor: nb, before_neighbor: pb, .. }, - ) => { - ca.name == cb.name - && neighbor_names_match(na, nb) - && neighbor_names_match(pa, pb) - } + Moved { + clip: ca, + after_neighbor: na, + before_neighbor: pa, + .. + }, + Moved { + clip: cb, + after_neighbor: nb, + before_neighbor: pb, + .. + }, + ) => ca.name == cb.name && neighbor_names_match(na, nb) && neighbor_names_match(pa, pb), ( - Added { clip: ca, track: ta, .. }, - Added { clip: cb, track: tb, .. }, + Added { + clip: ca, + track: ta, + .. + }, + Added { + clip: cb, + track: tb, + .. + }, ) => ta != tb && ca.name == cb.name, ( - Removed { clip: ca, track: ta, .. }, - Removed { clip: cb, track: tb, .. }, + Removed { + clip: ca, + track: ta, + .. + }, + Removed { + clip: cb, + track: tb, + .. + }, ) => ta != tb && ca.name == cb.name, ( - EffectsChanged { clip: ca, before: ba, after: aa, track: ta }, - EffectsChanged { clip: cb, before: bb, after: ab, track: tb }, + EffectsChanged { + clip: ca, + before: ba, + after: aa, + track: ta, + }, + EffectsChanged { + clip: cb, + before: bb, + after: ab, + track: tb, + }, ) => ta != tb && ca.name == cb.name && ba == bb && aa == ab, ( TransitionAdded { @@ -83,10 +128,7 @@ fn mirrors(a: &Change, b: &Change) -> bool { .. }, ) => { - ta != tb - && neighbor_names_match(ba1, bb1) - && neighbor_names_match(ba2, bb2) - && da == db + ta != tb && neighbor_names_match(ba1, bb1) && neighbor_names_match(ba2, bb2) && da == db } ( TransitionRemoved { @@ -101,11 +143,7 @@ fn mirrors(a: &Change, b: &Change) -> bool { track: tb, .. }, - ) => { - ta != tb - && neighbor_names_match(ba1, bb1) - && neighbor_names_match(ba2, bb2) - } + ) => ta != tb && neighbor_names_match(ba1, bb1) && neighbor_names_match(ba2, bb2), _ => false, } } @@ -127,7 +165,12 @@ fn render_one(change: &Change, synced: bool) -> String { Change::TrackRemoved { name, kind } => { format!(" Removed {} track \"{}\"", track_kind_word(kind), name) } - Change::Trimmed { clip, before, after, .. } => { + Change::Trimmed { + clip, + before, + after, + .. + } => { format!("{}{}", render_trim(clip, before, after), suffix) } Change::Moved { @@ -139,7 +182,13 @@ fn render_one(change: &Change, synced: bool) -> String { .. } => format!( "{}{}", - render_move(clip, *from_index, *to_index, after_neighbor, before_neighbor), + render_move( + clip, + *from_index, + *to_index, + after_neighbor, + before_neighbor + ), suffix ), Change::Added { clip, track, index } => format!( @@ -156,14 +205,24 @@ fn render_one(change: &Change, synced: bool) -> String { index, suffix ), - Change::EffectsChanged { clip, before, after, .. } => format!( + Change::EffectsChanged { + clip, + before, + after, + .. + } => format!( " Effects on \"{}\" changed ({} → {}){}", clip_label(clip), before, after, suffix ), - Change::Replaced { clip, before_media, after_media, .. } => format!( + Change::Replaced { + clip, + before_media, + after_media, + .. + } => format!( "{}{}", render_replaced(clip, before_media, after_media), suffix diff --git a/crates/vedit-cli/src/main.rs b/crates/vedit-cli/src/main.rs index dba2956..3774be1 100644 --- a/crates/vedit-cli/src/main.rs +++ b/crates/vedit-cli/src/main.rs @@ -7,7 +7,11 @@ mod cmd; mod diff; #[derive(Parser)] -#[command(name = "vedit", version, about = "Version control for video timelines.")] +#[command( + name = "vedit", + version, + about = "Version control for video timelines." +)] struct Cli { #[command(subcommand)] cmd: Cmd, @@ -116,7 +120,11 @@ enum Cmd { fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Diff { before, after, json } => diff::run(&before, &after, json), + Cmd::Diff { + before, + after, + json, + } => diff::run(&before, &after, json), Cmd::Init => cmd::init::run(), Cmd::Commit { timeline, message } => cmd::commit::run(&timeline, message.as_deref()), Cmd::Log { refstr } => cmd::log::run(&refstr), @@ -124,10 +132,11 @@ fn main() -> Result<()> { Cmd::Checkout { refstr, output } => cmd::checkout::run(&refstr, output.as_deref()), Cmd::Branch { name, delete } => cmd::branch::run(&name, delete), Cmd::Branches => cmd::branches::run(), - Cmd::Merge { target, message, dry_run } => cmd::merge::run( - &target, - cmd::merge::MergeOptions { message, dry_run }, - ), + Cmd::Merge { + target, + message, + dry_run, + } => cmd::merge::run(&target, cmd::merge::MergeOptions { message, dry_run }), Cmd::Watch { timeline, interval, diff --git a/crates/vedit-core/Cargo.toml b/crates/vedit-core/Cargo.toml index 7389088..10ab2a9 100644 --- a/crates/vedit-core/Cargo.toml +++ b/crates/vedit-core/Cargo.toml @@ -16,5 +16,8 @@ hex = { workspace = true } flate2 = { workspace = true } chrono = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] tempfile = "3" diff --git a/crates/vedit-core/src/atomic.rs b/crates/vedit-core/src/atomic.rs new file mode 100644 index 0000000..002e6da --- /dev/null +++ b/crates/vedit-core/src/atomic.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use std::path::Path; + +#[cfg(not(windows))] +pub(crate) fn replace_file(src: &Path, dst: &Path) -> Result<()> { + std::fs::rename(src, dst) + .with_context(|| format!("renaming {} to {}", src.display(), dst.display())) +} + +#[cfg(windows)] +pub(crate) fn replace_file(src: &Path, dst: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let src_w: Vec = src.as_os_str().encode_wide().chain(Some(0)).collect(); + let dst_w: Vec = dst.as_os_str().encode_wide().chain(Some(0)).collect(); + let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH; + + let ok = unsafe { MoveFileExW(src_w.as_ptr(), dst_w.as_ptr(), flags) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("renaming {} to {}", src.display(), dst.display())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn replace_file_overwrites_existing_file() { + let dir = tempdir().unwrap(); + let src = dir.path().join("new"); + let dst = dir.path().join("current"); + std::fs::write(&src, "new contents").unwrap(); + std::fs::write(&dst, "old contents").unwrap(); + + replace_file(&src, &dst).unwrap(); + + assert!(!src.exists()); + assert_eq!(std::fs::read_to_string(&dst).unwrap(), "new contents"); + } +} diff --git a/crates/vedit-core/src/diff.rs b/crates/vedit-core/src/diff.rs index 104b3cd..55d59d7 100644 --- a/crates/vedit-core/src/diff.rs +++ b/crates/vedit-core/src/diff.rs @@ -7,9 +7,7 @@ //! ID. That choice is what makes vedit work on OTIO from any source, //! including editors that strip third-party metadata. -use crate::model::{ - Clip, RationalTime, TimeRange, Timeline, Track, TrackChild, TrackKind, -}; +use crate::model::{Clip, RationalTime, TimeRange, Timeline, Track, TrackChild, TrackKind}; use serde::{Deserialize, Serialize}; /// One unit of change between two timelines. The shape is designed to be @@ -118,8 +116,7 @@ pub fn diff(before: &Timeline, after: &Timeline) -> Vec { let mut paired_tracks: Vec<(usize, usize)> = Vec::new(); for (a_idx, a_track) in after.tracks.iter().enumerate() { - if let Some(b_idx) = find_matching_track(a_track, &before.tracks, &before_tracks_used) - { + if let Some(b_idx) = find_matching_track(a_track, &before.tracks, &before_tracks_used) { before_tracks_used[b_idx] = true; after_tracks_used[a_idx] = true; paired_tracks.push((b_idx, a_idx)); @@ -156,11 +153,7 @@ pub fn diff(before: &Timeline, after: &Timeline) -> Vec { changes } -fn find_matching_track( - needle: &Track, - haystack: &[Track], - used: &[bool], -) -> Option { +fn find_matching_track(needle: &Track, haystack: &[Track], used: &[bool]) -> Option { // Prefer same kind + same name. for (i, t) in haystack.iter().enumerate() { if used[i] { @@ -245,10 +238,9 @@ fn diff_track(before: &Track, after: &Track, out: &mut Vec) { // matches (LCS-style); pairs in that subset stayed in place, the // others moved. matches.sort_by_key(|(b, _)| *b); - let stable_after: std::collections::HashSet = - longest_increasing_after_indices(&matches) - .into_iter() - .collect(); + let stable_after: std::collections::HashSet = longest_increasing_after_indices(&matches) + .into_iter() + .collect(); // Emit changes for matched clips. for (b_pos, a_pos) in &matches { @@ -257,14 +249,15 @@ fn diff_track(before: &Track, after: &Track, out: &mut Vec) { // Trim detection. if let (Some(br), Some(ar)) = (b_clip.source_range, a_clip.source_range) - && !time_ranges_equal(&br, &ar) { - out.push(Change::Trimmed { - clip: a_clip.into(), - track: track_name.clone(), - before: br, - after: ar, - }); - } + && !time_ranges_equal(&br, &ar) + { + out.push(Change::Trimmed { + clip: a_clip.into(), + track: track_name.clone(), + before: br, + after: ar, + }); + } // Move detection: only flag pairs that aren't part of the stable // order. If an inserted/removed clip merely shifted absolute @@ -334,7 +327,15 @@ fn diff_track(before: &Track, after: &Track, out: &mut Vec) { // Transition-level diff: walk the children of both tracks and emit // transition_added / transition_removed when the set of transitions // between matched neighbors differs. - diff_transitions(before, after, &track_name, &matches, &before_clips, &after_clips, out); + diff_transitions( + before, + after, + &track_name, + &matches, + &before_clips, + &after_clips, + out, + ); } fn diff_transitions( @@ -354,12 +355,9 @@ fn diff_transitions( let b_t = &before_transitions[*b_pos]; let a_t = &after_transitions[*a_pos]; - let before_neighbor: Option = before_clips - .get(*b_pos + 1) - .map(|(_, c)| (*c).into()); - let after_neighbor: Option = after_clips - .get(*a_pos + 1) - .map(|(_, c)| (*c).into()); + let before_neighbor: Option = + before_clips.get(*b_pos + 1).map(|(_, c)| (*c).into()); + let after_neighbor: Option = after_clips.get(*a_pos + 1).map(|(_, c)| (*c).into()); let this_clip: ClipRef = before_clips[*b_pos].1.into(); let _ = this_clip; // not used currently; kept for symmetry @@ -541,7 +539,12 @@ fn verb_phrase(change: &Change) -> String { match change { Change::TrackAdded { name, .. } => format!("added track \"{name}\""), Change::TrackRemoved { name, .. } => format!("removed track \"{name}\""), - Change::Trimmed { clip, before, after, .. } => { + Change::Trimmed { + clip, + before, + after, + .. + } => { let in_delta = after.start_time.seconds() - before.start_time.seconds(); let dur_delta = after.duration.seconds() - before.duration.seconds(); let amount = if in_delta.abs() > 1e-6 { @@ -556,7 +559,12 @@ fn verb_phrase(change: &Change) -> String { }; format!("trimmed \"{}\" by {:.2}s ({dir})", clip.name, amount) } - Change::Moved { clip, after_neighbor, before_neighbor, .. } => { + Change::Moved { + clip, + after_neighbor, + before_neighbor, + .. + } => { if let Some(n) = after_neighbor { format!("moved \"{}\" before \"{}\"", clip.name, n.name) } else if let Some(n) = before_neighbor { @@ -567,7 +575,12 @@ fn verb_phrase(change: &Change) -> String { } Change::Added { clip, .. } => format!("added \"{}\"", clip.name), Change::Removed { clip, .. } => format!("removed \"{}\"", clip.name), - Change::EffectsChanged { clip, before, after, .. } => { + Change::EffectsChanged { + clip, + before, + after, + .. + } => { format!("effects on \"{}\" {}→{}", clip.name, before, after) } Change::Replaced { clip, .. } => format!("replaced media on \"{}\"", clip.name), @@ -628,10 +641,25 @@ fn summary_phrase(changes: &[Change]) -> String { push(&mut parts, removes, "removal", "removals"); push(&mut parts, replaces, "replacement", "replacements"); push(&mut parts, effects, "effect change", "effect changes"); - push(&mut parts, transitions_added, "transition added", "transitions added"); - push(&mut parts, transitions_removed, "transition removed", "transitions removed"); + push( + &mut parts, + transitions_added, + "transition added", + "transitions added", + ); + push( + &mut parts, + transitions_removed, + "transition removed", + "transitions removed", + ); push(&mut parts, tracks_added, "track added", "tracks added"); - push(&mut parts, tracks_removed, "track removed", "tracks removed"); + push( + &mut parts, + tracks_removed, + "track removed", + "tracks removed", + ); let total = changes.len(); let edits_word = if total == 1 { "edit" } else { "edits" }; diff --git a/crates/vedit-core/src/lib.rs b/crates/vedit-core/src/lib.rs index 0f49042..5609dd4 100644 --- a/crates/vedit-core/src/lib.rs +++ b/crates/vedit-core/src/lib.rs @@ -1,3 +1,4 @@ +mod atomic; pub mod commit; pub mod diff; pub mod merge; diff --git a/crates/vedit-core/src/merge.rs b/crates/vedit-core/src/merge.rs index e26f78a..e213a4f 100644 --- a/crates/vedit-core/src/merge.rs +++ b/crates/vedit-core/src/merge.rs @@ -79,8 +79,7 @@ pub fn merge(base: &Timeline, ours: &Timeline, theirs: &Timeline) -> MergeOutcom // Walk the union of keys in a stable order: ours's order first // (preserving any reordering ours did), then any new keys theirs // introduced that ours didn't have. - let mut seen: std::collections::HashSet<(String, TrackKind)> = - std::collections::HashSet::new(); + let mut seen: std::collections::HashSet<(String, TrackKind)> = std::collections::HashSet::new(); let mut merge_order: Vec<&(String, TrackKind)> = Vec::new(); for t in &ours.tracks { let key = (t.name.clone(), t.kind); @@ -95,9 +94,10 @@ pub fn merge(base: &Timeline, ours: &Timeline, theirs: &Timeline) -> MergeOutcom for t in &theirs.tracks { let key = (t.name.clone(), t.kind); if seen.insert(key.clone()) - && let Some(stored) = theirs_index.get_key_value(&key) { - merge_order.push(stored.0); - } + && let Some(stored) = theirs_index.get_key_value(&key) + { + merge_order.push(stored.0); + } } // Tracks that exist only in base (deleted by both) are handled below // implicitly: they contribute nothing to merge_order, so they stay @@ -399,11 +399,7 @@ mod tests { TrackKind::Video, vec![clip("a", "media://a.mov")], )]); - let added_track = track( - "A1", - TrackKind::Audio, - vec![clip("vo", "media://vo.wav")], - ); + let added_track = track("A1", TrackKind::Audio, vec![clip("vo", "media://vo.wav")]); let ours = timeline(vec![base.tracks[0].clone(), added_track.clone()]); let theirs = ours.clone(); match merge(&base, &ours, &theirs) { @@ -458,7 +454,9 @@ mod tests { assert_eq!(cs[0].track_name, "A1"); assert!(matches!( cs[0].kind, - ConflictKind::DeleteVsModify { deleter: Side::Ours } + ConflictKind::DeleteVsModify { + deleter: Side::Ours + } )); } other => panic!("expected Conflicts, got {:?}", other), diff --git a/crates/vedit-core/src/object.rs b/crates/vedit-core/src/object.rs index 645c883..b9fd960 100644 --- a/crates/vedit-core/src/object.rs +++ b/crates/vedit-core/src/object.rs @@ -4,14 +4,19 @@ //! lowercase hex, prefixed with `sha256:`. On disk, objects are stored //! gzipped at `objects//`. -use anyhow::{anyhow, Context, Result}; +use crate::atomic; +use anyhow::{Context, Result, anyhow}; +use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; -use flate2::Compression; use serde_json::Value; use sha2::{Digest, Sha256}; +use std::fs::OpenOptions; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// `sha256:` followed by 64 lowercase hex chars. pub const HASH_PREFIX: &str = "sha256:"; @@ -65,7 +70,7 @@ pub fn object_path(root: &Path, hash: &str) -> Result { pub fn write(root: &Path, value: &Value) -> Result { let h = hash(value); let path = object_path(root, &h)?; - if path.exists() { + if path.exists() && read(root, &h).is_ok() { return Ok(h); } if let Some(parent) = path.parent() { @@ -75,27 +80,72 @@ pub fn write(root: &Path, value: &Value) -> Result { let canonical = canonicalize(value); let bytes = serde_json::to_vec(&canonical)?; - let file = std::fs::File::create(&path) - .with_context(|| format!("creating {}", path.display()))?; - let mut encoder = GzEncoder::new(file, Compression::default()); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(&bytes)?; - encoder.finish()?; + let compressed = encoder.finish()?; + atomic_write_bytes(&path, &compressed)?; Ok(h) } /// Read a JSON object out of the store, ungzipping and parsing. pub fn read(root: &Path, hash: &str) -> Result { let path = object_path(root, hash)?; - let file = std::fs::File::open(&path) - .with_context(|| format!("opening {}", path.display()))?; + let file = std::fs::File::open(&path).with_context(|| format!("opening {}", path.display()))?; let mut decoder = GzDecoder::new(file); let mut bytes = Vec::new(); decoder.read_to_end(&mut bytes)?; - let value: Value = serde_json::from_slice(&bytes) - .with_context(|| format!("parsing object {hash}"))?; + let value: Value = + serde_json::from_slice(&bytes).with_context(|| format!("parsing object {hash}"))?; + let actual = self::hash(&value); + if actual != hash { + return Err(anyhow!( + "object hash mismatch: expected {hash}, got {actual}" + )); + } Ok(value) } +fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?; + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + + let tmp_path = create_temp_path(path); + let mut tmp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .with_context(|| format!("creating temp file {}", tmp_path.display()))?; + tmp.write_all(bytes) + .with_context(|| format!("writing {}", tmp_path.display()))?; + tmp.sync_all() + .with_context(|| format!("syncing {}", tmp_path.display()))?; + drop(tmp); + + if let Err(e) = atomic::replace_file(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); + } + sync_parent_dir(parent); + Ok(()) +} + +fn create_temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("object"); + let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + path.with_file_name(format!(".{file_name}.tmp.{}.{}", std::process::id(), n)) +} + +fn sync_parent_dir(path: &Path) { + if let Ok(dir) = std::fs::File::open(path) { + let _ = dir.sync_all(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +187,38 @@ mod tests { let h2 = write(dir.path(), &v).unwrap(); assert_eq!(h1, h2); } + + #[test] + fn read_rejects_object_whose_contents_do_not_match_hash() { + let dir = tempdir().unwrap(); + let expected = json!({ "x": 1 }); + let wrong = json!({ "x": 2 }); + + let expected_hash = write(dir.path(), &expected).unwrap(); + let wrong_hash = write(dir.path(), &wrong).unwrap(); + let expected_path = object_path(dir.path(), &expected_hash).unwrap(); + let wrong_path = object_path(dir.path(), &wrong_hash).unwrap(); + + std::fs::copy(wrong_path, expected_path).unwrap(); + + let err = read(dir.path(), &expected_hash).unwrap_err(); + assert!( + err.to_string().contains("object hash mismatch"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn write_repairs_corrupt_existing_object_path() { + let dir = tempdir().unwrap(); + let v = json!({ "x": 1 }); + let h = hash(&v); + let path = object_path(dir.path(), &h).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"not a gzip object").unwrap(); + + let written = write(dir.path(), &v).unwrap(); + assert_eq!(written, h); + assert_eq!(read(dir.path(), &h).unwrap(), json!({ "x": 1 })); + } } diff --git a/crates/vedit-core/src/otio.rs b/crates/vedit-core/src/otio.rs index a660e89..d25c70d 100644 --- a/crates/vedit-core/src/otio.rs +++ b/crates/vedit-core/src/otio.rs @@ -4,16 +4,13 @@ //! fields are tolerated. The parser fails only when the document is //! structurally not an OTIO timeline. -use crate::model::{ - Clip, Gap, RationalTime, TimeRange, Timeline, Track, TrackChild, TrackKind, -}; -use anyhow::{anyhow, Context, Result}; +use crate::model::{Clip, Gap, RationalTime, TimeRange, Timeline, Track, TrackChild, TrackKind}; +use anyhow::{Context, Result, anyhow}; use serde_json::Value; use std::path::Path; pub fn load(path: &Path) -> Result { - let bytes = - std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; let value: Value = serde_json::from_slice(&bytes) .with_context(|| format!("parsing {} as JSON", path.display()))?; parse_timeline(&value).with_context(|| format!("interpreting {}", path.display())) @@ -177,16 +174,18 @@ fn parse_gap(value: &Value) -> Gap { fn parse_media_reference(value: &Value) -> Option { let map = value.as_object()?; if let Some(url) = map.get("target_url").and_then(|s| s.as_str()) - && !url.is_empty() { - return Some(url.to_string()); - } + && !url.is_empty() + { + return Some(url.to_string()); + } // Some OTIO files use ExternalReference with `target_url`, others use // `MissingReference` with metadata, others use `GeneratorReference`. We // fall back to a reproducible identity string so generators still match. if let Some(name) = map.get("name").and_then(|s| s.as_str()) - && !name.is_empty() { - return Some(format!("ref-by-name:{name}")); - } + && !name.is_empty() + { + return Some(format!("ref-by-name:{name}")); + } None } diff --git a/crates/vedit-core/src/repo.rs b/crates/vedit-core/src/repo.rs index c2292a8..5e64301 100644 --- a/crates/vedit-core/src/repo.rs +++ b/crates/vedit-core/src/repo.rs @@ -1,15 +1,20 @@ //! Repository operations: init, ref resolution, commit, log walking. +use crate::atomic; use crate::commit::{Author, Commit}; use crate::object; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; use serde_json::Value; +use std::fs::OpenOptions; +use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; pub const VEDIT_DIR: &str = ".vedit"; pub const DEFAULT_BRANCH: &str = "main"; const HEAD_FILE: &str = "HEAD"; +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// A vedit repository rooted at some directory. #[derive(Debug, Clone)] @@ -33,7 +38,7 @@ impl Repo { // HEAD points at refs/heads/main symbolically. The branch ref does // not exist yet — it will be written by the first commit. let head_contents = format!("ref: refs/heads/{DEFAULT_BRANCH}\n"); - std::fs::write(root.join(HEAD_FILE), head_contents)?; + write_text_atomic(&root.join(HEAD_FILE), &head_contents)?; Ok(Self { root }) } @@ -117,6 +122,9 @@ impl Repo { continue; } let name = entry.file_name().to_string_lossy().into_owned(); + if validate_branch_name(&name).is_err() { + continue; + } if let Some(target) = self.branch_target(&name)? { out.push((name, target)); } @@ -145,10 +153,21 @@ impl Repo { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, format!("{target}\n"))?; + write_text_atomic(&path, &format!("{target}\n"))?; Ok(target) } + /// Atomically repoint an existing branch at a resolved commit. + pub fn set_branch_target(&self, name: &str, target: &str) -> Result<()> { + validate_branch_name(name)?; + let path = self.branch_path(name); + if !path.exists() { + bail!("branch {name} does not exist"); + } + let target = self.resolve(target)?; + write_text_atomic(&path, &format!("{target}\n")) + } + /// Delete a branch. Refuses to delete the branch HEAD currently points /// at, since there'd be no way to keep working. pub fn delete_branch(&self, name: &str) -> Result<()> { @@ -159,8 +178,7 @@ impl Repo { if !path.exists() { bail!("branch {name} does not exist"); } - std::fs::remove_file(&path) - .with_context(|| format!("removing {}", path.display()))?; + std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; Ok(()) } @@ -171,7 +189,7 @@ impl Repo { if !path.exists() { bail!("branch {name} does not exist"); } - std::fs::write(self.head_path(), format!("ref: refs/heads/{name}\n"))?; + write_text_atomic(&self.head_path(), &format!("ref: refs/heads/{name}\n"))?; Ok(()) } @@ -253,8 +271,8 @@ impl Repo { /// Read a commit by hash. pub fn read_commit(&self, hash: &str) -> Result { let v = object::read(&self.objects_dir(), hash)?; - let commit: Commit = serde_json::from_value(v) - .with_context(|| format!("parsing commit {hash}"))?; + let commit: Commit = + serde_json::from_value(v).with_context(|| format!("parsing commit {hash}"))?; if commit.schema != Commit::SCHEMA { bail!( "commit {hash} has unsupported schema {:?} (expected {})", @@ -268,12 +286,7 @@ impl Repo { /// Create a commit object pointing at `timeline_hash`, parented at /// the current HEAD (if any), and update HEAD's branch to point at /// it. Returns the new commit's hash. - pub fn commit( - &self, - timeline_hash: &str, - author: Author, - message: &str, - ) -> Result { + pub fn commit(&self, timeline_hash: &str, author: Author, message: &str) -> Result { let parent = match self.head()? { HeadState::Branch(name) => self.branch_target(&name)?, HeadState::Detached(_) => { @@ -299,7 +312,7 @@ impl Repo { if let Some(parent_dir) = path.parent() { std::fs::create_dir_all(parent_dir)?; } - std::fs::write(&path, format!("{commit_hash}\n"))?; + write_text_atomic(&path, &format!("{commit_hash}\n"))?; } Ok(commit_hash) @@ -320,9 +333,7 @@ impl Repo { timeline_hash.to_string(), parents, author, - chrono::Utc::now() - .format("%Y-%m-%dT%H:%M:%SZ") - .to_string(), + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), message.to_string(), ); let commit_value = serde_json::to_value(&commit)?; @@ -333,7 +344,7 @@ impl Repo { if let Some(parent_dir) = path.parent() { std::fs::create_dir_all(parent_dir)?; } - std::fs::write(&path, format!("{commit_hash}\n"))?; + write_text_atomic(&path, &format!("{commit_hash}\n"))?; } Ok(commit_hash) @@ -376,10 +387,8 @@ impl Repo { return Ok(Some(a_hash)); } - let mut a_ancestors: std::collections::BTreeSet = - std::collections::BTreeSet::new(); - let mut frontier: std::collections::VecDeque = - std::collections::VecDeque::new(); + let mut a_ancestors: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut frontier: std::collections::VecDeque = std::collections::VecDeque::new(); frontier.push_back(a_hash); while let Some(h) = frontier.pop_front() { if !a_ancestors.insert(h.clone()) { @@ -391,10 +400,8 @@ impl Repo { } } - let mut b_seen: std::collections::BTreeSet = - std::collections::BTreeSet::new(); - let mut frontier: std::collections::VecDeque = - std::collections::VecDeque::new(); + let mut b_seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut frontier: std::collections::VecDeque = std::collections::VecDeque::new(); frontier.push_back(b_hash); while let Some(h) = frontier.pop_front() { if !b_seen.insert(h.clone()) { @@ -444,6 +451,44 @@ fn validate_branch_name(name: &str) -> Result<()> { Ok(()) } +fn write_text_atomic(path: &Path, contents: &str) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?; + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + + let tmp_path = temp_path_for(path); + let mut tmp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .with_context(|| format!("creating temp file {}", tmp_path.display()))?; + tmp.write_all(contents.as_bytes()) + .with_context(|| format!("writing {}", tmp_path.display()))?; + tmp.sync_all() + .with_context(|| format!("syncing {}", tmp_path.display()))?; + drop(tmp); + + if let Err(e) = atomic::replace_file(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); + } + sync_parent_dir(parent); + Ok(()) +} + +fn temp_path_for(path: &Path) -> PathBuf { + let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("ref"); + let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + path.with_file_name(format!(".{file_name}.tmp.{}.{}", std::process::id(), n)) +} + +fn sync_parent_dir(path: &Path) { + if let Ok(dir) = std::fs::File::open(path) { + let _ = dir.sync_all(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -570,7 +615,10 @@ mod tests { let c = repo.commit(&t, fake_author(), "v").unwrap(); repo.create_branch("alt", "HEAD").unwrap(); - assert_eq!(repo.branch_target("alt").unwrap().as_deref(), Some(c.as_str())); + assert_eq!( + repo.branch_target("alt").unwrap().as_deref(), + Some(c.as_str()) + ); // List shows both. let list = repo.list_branches().unwrap(); @@ -631,8 +679,14 @@ mod tests { let alt_c2 = repo.commit(&t2, fake_author(), "v2 on alt").unwrap(); // main still at v1; alt now at v2. - assert_eq!(repo.branch_target("main").unwrap().as_deref(), Some(main_c1.as_str())); - assert_eq!(repo.branch_target("alt").unwrap().as_deref(), Some(alt_c2.as_str())); + assert_eq!( + repo.branch_target("main").unwrap().as_deref(), + Some(main_c1.as_str()) + ); + assert_eq!( + repo.branch_target("alt").unwrap().as_deref(), + Some(alt_c2.as_str()) + ); // Logs differ. let main_log = repo.log(Some("main")).unwrap(); @@ -641,6 +695,48 @@ mod tests { assert_eq!(alt_log.len(), 2); } + #[test] + fn set_branch_target_updates_existing_branch() { + let dir = tempdir().unwrap(); + let repo = Repo::init(dir.path()).unwrap(); + let t = repo + .write_timeline(&json!({ "OTIO_SCHEMA": "Timeline.1", "name": "v" })) + .unwrap(); + let c1 = repo.commit(&t, fake_author(), "c1").unwrap(); + let c2 = repo.commit(&t, fake_author(), "c2").unwrap(); + + repo.set_branch_target("main", &c1).unwrap(); + assert_eq!( + repo.branch_target("main").unwrap().as_deref(), + Some(c1.as_str()) + ); + + repo.set_branch_target("main", &c2).unwrap(); + assert_eq!( + repo.branch_target("main").unwrap().as_deref(), + Some(c2.as_str()) + ); + } + + #[test] + fn list_branches_ignores_stale_temp_ref_files() { + let dir = tempdir().unwrap(); + let repo = Repo::init(dir.path()).unwrap(); + let t = repo + .write_timeline(&json!({ "OTIO_SCHEMA": "Timeline.1", "name": "v" })) + .unwrap(); + let c = repo.commit(&t, fake_author(), "v").unwrap(); + + std::fs::write( + repo.root.join("refs/heads/.main.tmp.123.0"), + format!("{c}\n"), + ) + .unwrap(); + + let branches = repo.list_branches().unwrap(); + assert_eq!(branches, vec![("main".to_string(), c)]); + } + #[test] fn invalid_branch_names_rejected() { let dir = tempdir().unwrap(); @@ -649,7 +745,14 @@ mod tests { .write_timeline(&json!({ "OTIO_SCHEMA": "Timeline.1", "name": "v" })) .unwrap(); repo.commit(&t, fake_author(), "v").unwrap(); - for bad in ["", ".hidden", "with space", "../escape", "trailing/", "//double"] { + for bad in [ + "", + ".hidden", + "with space", + "../escape", + "trailing/", + "//double", + ] { assert!( repo.create_branch(bad, "HEAD").is_err(), "should reject {bad:?}" @@ -669,9 +772,18 @@ mod tests { let c2 = repo.commit(&t, fake_author(), "c2").unwrap(); let c3 = repo.commit(&t, fake_author(), "c3").unwrap(); - assert_eq!(repo.merge_base(&c3, &c1).unwrap().as_deref(), Some(c1.as_str())); - assert_eq!(repo.merge_base(&c1, &c3).unwrap().as_deref(), Some(c1.as_str())); - assert_eq!(repo.merge_base(&c2, &c2).unwrap().as_deref(), Some(c2.as_str())); + assert_eq!( + repo.merge_base(&c3, &c1).unwrap().as_deref(), + Some(c1.as_str()) + ); + assert_eq!( + repo.merge_base(&c1, &c3).unwrap().as_deref(), + Some(c1.as_str()) + ); + assert_eq!( + repo.merge_base(&c2, &c2).unwrap().as_deref(), + Some(c2.as_str()) + ); } #[test] @@ -689,11 +801,7 @@ mod tests { repo.switch_branch("alt").unwrap(); // alt diverges from `base` (the parent of main_tip), not from main_tip. // Reset alt to point at base. - std::fs::write( - repo.root.join("refs/heads/alt"), - format!("{base}\n"), - ) - .unwrap(); + repo.set_branch_target("alt", &base).unwrap(); let alt_tip = repo.commit(&t, fake_author(), "alt extra").unwrap(); let mb = repo.merge_base(&main_tip, &alt_tip).unwrap(); diff --git a/crates/vedit-core/tests/auto_message.rs b/crates/vedit-core/tests/auto_message.rs index e7344f6..fadae8e 100644 --- a/crates/vedit-core/tests/auto_message.rs +++ b/crates/vedit-core/tests/auto_message.rs @@ -44,7 +44,10 @@ fn timeline_with_clip_count(n: usize) -> serde_json::Value { }) } -fn diff_value(before: &serde_json::Value, after: &serde_json::Value) -> Vec { +fn diff_value( + before: &serde_json::Value, + after: &serde_json::Value, +) -> Vec { let b = otio::parse_timeline(before).unwrap(); let a = otio::parse_timeline(after).unwrap(); diff(&b, &a) @@ -74,7 +77,10 @@ fn two_changes_joined_with_comma() { let changes = diff_value(&before, &after); assert_eq!(changes.len(), 2); let m = auto_message(&changes); - assert!(m.contains(", "), "two-change message should be comma-joined: {m}"); + assert!( + m.contains(", "), + "two-change message should be comma-joined: {m}" + ); assert!(m.contains("clip_3")); assert!(m.contains("clip_4")); } @@ -86,6 +92,9 @@ fn many_changes_use_summary_with_counts() { let changes = diff_value(&before, &after); assert_eq!(changes.len(), 5); let m = auto_message(&changes); - assert!(m.starts_with("5 edits"), "summary should lead with the count: {m}"); + assert!( + m.starts_with("5 edits"), + "summary should lead with the count: {m}" + ); assert!(m.contains("addition"), "should mention additions: {m}"); } diff --git a/crates/vedit-core/tests/corpus.rs b/crates/vedit-core/tests/corpus.rs index a04372a..9045da5 100644 --- a/crates/vedit-core/tests/corpus.rs +++ b/crates/vedit-core/tests/corpus.rs @@ -5,8 +5,8 @@ //! regression tests for the engine and the source of truth for what //! "semantic diff" means in vedit. -use serde_json::{json, Value}; -use vedit_core::diff::{diff, Change}; +use serde_json::{Value, json}; +use vedit_core::diff::{Change, diff}; use vedit_core::otio; // --- helpers ----------------------------------------------------------- @@ -46,7 +46,13 @@ fn clip(name: &str, media: &str, src_start: f64, src_dur: f64) -> Value { }) } -fn clip_with_effects(name: &str, media: &str, src_start: f64, src_dur: f64, effect_count: usize) -> Value { +fn clip_with_effects( + name: &str, + media: &str, + src_start: f64, + src_dur: f64, + effect_count: usize, +) -> Value { let effects: Vec = (0..effect_count) .map(|i| json!({"OTIO_SCHEMA": "Effect.1", "name": format!("e{i}"), "metadata": {}})) .collect(); @@ -183,13 +189,19 @@ fn case_03_clip_moved() { .iter() .filter(|c| matches!(c, Change::Moved { .. })) .collect(); - assert!(!moved.is_empty(), "expected at least one Moved: {:#?}", changes); + assert!( + !moved.is_empty(), + "expected at least one Moved: {:#?}", + changes + ); // c moved from index 2 -> 0. - let c_moved = moved.iter().any(|c| matches!( - c, - Change::Moved { clip, from_index, to_index, .. } - if clip.name == "c" && *from_index == 2 && *to_index == 0 - )); + let c_moved = moved.iter().any(|c| { + matches!( + c, + Change::Moved { clip, from_index, to_index, .. } + if clip.name == "c" && *from_index == 2 && *to_index == 0 + ) + }); assert!(c_moved, "expected c moved 2->0: {:#?}", moved); } @@ -279,7 +291,12 @@ fn case_06_clip_replaced() { .collect(); assert_eq!(replaced.len(), 1, "{:#?}", changes); match replaced[0] { - Change::Replaced { clip, before_media, after_media, .. } => { + Change::Replaced { + clip, + before_media, + after_media, + .. + } => { assert_eq!(clip.name, "intro"); assert_eq!(before_media.as_deref(), Some("media://intro_v1.mov")); assert_eq!(after_media.as_deref(), Some("media://intro_v2.mov")); @@ -448,8 +465,14 @@ fn case_12_combined_trim_and_add() { )], ); let changes = run_diff(before, after); - let trimmed = changes.iter().filter(|c| matches!(c, Change::Trimmed { .. })).count(); - let added = changes.iter().filter(|c| matches!(c, Change::Added { .. })).count(); + let trimmed = changes + .iter() + .filter(|c| matches!(c, Change::Trimmed { .. })) + .count(); + let added = changes + .iter() + .filter(|c| matches!(c, Change::Added { .. })) + .count(); assert_eq!(trimmed, 1, "{:#?}", changes); assert_eq!(added, 1, "{:#?}", changes); } diff --git a/crates/vedit-core/tests/merge_workflow.rs b/crates/vedit-core/tests/merge_workflow.rs index f11cfc8..9352891 100644 --- a/crates/vedit-core/tests/merge_workflow.rs +++ b/crates/vedit-core/tests/merge_workflow.rs @@ -3,7 +3,7 @@ use serde_json::json; use tempfile::tempdir; use vedit_core::commit::Author; -use vedit_core::merge::{merge as run_merge, MergeOutcome}; +use vedit_core::merge::{MergeOutcome, merge as run_merge}; use vedit_core::otio; use vedit_core::repo::Repo; @@ -112,11 +112,8 @@ fn three_way_clean_merge_combines_disjoint_changes() { // ours adds A1 track // theirs adds clip b to V1 // merge should produce: V1 with [a, b], A1 added - let base_tl = otio::parse_timeline(&timeline_with_tracks(vec![video_track( - "V1", - vec!["a"], - )])) - .unwrap(); + let base_tl = + otio::parse_timeline(&timeline_with_tracks(vec![video_track("V1", vec!["a"])])).unwrap(); let ours_tl = otio::parse_timeline(&timeline_with_tracks(vec![ video_track("V1", vec!["a"]), audio_track("A1", vec![]), @@ -149,11 +146,8 @@ fn three_way_clean_merge_combines_disjoint_changes() { #[test] fn three_way_conflict_when_both_modify_same_track() { - let base_tl = otio::parse_timeline(&timeline_with_tracks(vec![video_track( - "V1", - vec!["a"], - )])) - .unwrap(); + let base_tl = + otio::parse_timeline(&timeline_with_tracks(vec![video_track("V1", vec!["a"])])).unwrap(); let ours_tl = otio::parse_timeline(&timeline_with_tracks(vec![video_track( "V1", vec!["a", "b"], diff --git a/crates/vedit-core/tests/real_world.rs b/crates/vedit-core/tests/real_world.rs index 4c1f180..a8ef15f 100644 --- a/crates/vedit-core/tests/real_world.rs +++ b/crates/vedit-core/tests/real_world.rs @@ -83,7 +83,10 @@ fn detects_trim_on_real_multitrack() { _ => None, }) .expect("first track has at least one clip"); - let sr = first_clip.source_range.as_mut().expect("clip has source_range"); + let sr = first_clip + .source_range + .as_mut() + .expect("clip has source_range"); sr.duration.value -= 12.0; let changes = diff(&original, &modified); diff --git a/crates/vedit-core/tests/repo_workflow.rs b/crates/vedit-core/tests/repo_workflow.rs index 175be88..31c8766 100644 --- a/crates/vedit-core/tests/repo_workflow.rs +++ b/crates/vedit-core/tests/repo_workflow.rs @@ -97,10 +97,8 @@ fn full_workflow_init_commit_log_show_checkout() { // committed. let checked_out = repo.read_timeline(&v1_hash).unwrap(); // After canonicalization, content must match. - let original_canonical = - serde_json::to_string(&canonical(&v1)).unwrap(); - let recovered_canonical = - serde_json::to_string(&canonical(&checked_out)).unwrap(); + let original_canonical = serde_json::to_string(&canonical(&v1)).unwrap(); + let recovered_canonical = serde_json::to_string(&canonical(&checked_out)).unwrap(); assert_eq!(original_canonical, recovered_canonical); // HEAD points at c2 via main. @@ -137,10 +135,7 @@ fn resolve_head_before_first_commit_errors() { fn canonical(v: &serde_json::Value) -> serde_json::Value { match v { serde_json::Value::Object(map) => { - let mut entries: Vec<_> = map - .iter() - .map(|(k, v)| (k.clone(), canonical(v))) - .collect(); + let mut entries: Vec<_> = map.iter().map(|(k, v)| (k.clone(), canonical(v))).collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = serde_json::Map::new(); for (k, v) in entries { diff --git a/crates/vedit-py/src/lib.rs b/crates/vedit-py/src/lib.rs index 862ea06..a266e86 100644 --- a/crates/vedit-py/src/lib.rs +++ b/crates/vedit-py/src/lib.rs @@ -97,11 +97,7 @@ impl PyRepo { /// Diff between two refs in this repo (branch name, commit hash, HEAD). /// Returns a list of Change objects. - fn diff_refs( - &self, - before_ref: &str, - after_ref: &str, - ) -> PyResult> { + fn diff_refs(&self, before_ref: &str, after_ref: &str) -> PyResult> { let before_hash = self.inner.resolve(before_ref).map_err(map_err)?; let after_hash = self.inner.resolve(after_ref).map_err(map_err)?; let before_commit = self.inner.read_commit(&before_hash).map_err(map_err)?;