diff --git a/README.md b/README.md index b1bce10..aded682 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # BranchFS -BranchFS is a FUSE-based filesystem that enables speculative branching on top of any existing filesystem. It gives AI agents isolated workspaces with instant copy-on-write branching, atomic commit-to-root, and zero-cost abort, no root privileges required. +BranchFS is a FUSE-based filesystem that enables speculative branching on top of any existing filesystem. It gives AI agents isolated workspaces with instant copy-on-write branching, atomic commit-to-parent, and zero-cost abort, no root privileges required. ## Features | Feature | Description | |---------|-------------| | Fast Branch Creation | O(1) branch creation with copy-on-write semantics | -| Commit to Root | Changes apply directly to the base filesystem | -| Atomic Abort | Instantly invalidates branch, sibling branches unaffected | -| Atomic Commit | Applies changes and invalidates all branches atomically | +| Commit to Parent | Changes merge into immediate parent branch (or base if parent is main) | +| Atomic Abort | Instantly discards leaf branch, parent and siblings unaffected | +| Atomic Commit | Merges leaf branch into parent atomically | | mmap Invalidation | Memory-mapped files trigger SIGBUS after commit/abort | | @branch Virtual Paths | Access any branch directly via `/@branch-name/` without switching | | Portable | Works on any underlying filesystem (ext4, xfs, nfs, etc.) | ## Architecture -BranchFS is a FUSE-based filesystem that requires no root privileges. It implements file-level copy-on-write: when a file is modified on a branch, the entire file is lazily copied to the branch's delta storage, while unmodified files are resolved by walking up the branch chain to the base directory. Deletions are tracked via tombstone markers. On commit, all changes from the branch chain are applied atomically to the base directory; on abort, the branch's delta storage is simply discarded. +BranchFS is a FUSE-based filesystem that requires no root privileges. It implements file-level copy-on-write: when a file is modified on a branch, the entire file is lazily copied to the branch's delta storage, while unmodified files are resolved by walking up the branch chain to the base directory. Deletions are tracked via tombstone markers. On commit, changes from a leaf branch are merged into its immediate parent (or applied to the base directory if the parent is main); on abort, the leaf branch's delta storage is simply discarded. ### Why not overlayfs? @@ -106,8 +106,12 @@ branchfs create level2 /mnt/workspace -p level1 # auto-switches to level2 # Now on level2, work in it echo "deep change" > /mnt/workspace/file.txt -# Commit from level2 applies: level2 + level1 → base, switches to main +# Commit from level2 merges into level1, switches to level1 branchfs commit /mnt/workspace + +# Now on level1, commit to base +branchfs commit /mnt/workspace +# Changes from both level2 and level1 are now in base, switches to main ``` ### @branch Virtual Paths @@ -176,22 +180,24 @@ All mounts share a single branch namespace managed by the daemon. Branches creat ### Commit -Committing a branch applies the entire chain of changes to the base filesystem: +Committing merges a **leaf branch** into its immediate parent: -1. Changes are collected from the current branch up through its ancestors -2. Deletions are applied first, then file modifications -3. Epoch increments, invalidating all branches across all mounts -4. **Mount automatically switches to main branch** (stays mounted) -5. Memory-mapped regions trigger `SIGBUS` on next access +1. Only leaf branches can be committed, attempting to commit a branch with children returns an error +2. If the parent is **main**: tombstone deletions are applied to the base filesystem, then delta files are copied to base +3. If the parent is **another branch**: child's delta files are merged into the parent's delta directory, and tombstones are merged (child tombstones shadow parent deltas, child deltas un-tombstone parent tombstones) +4. The committed branch is removed; epoch increments +5. **Mount automatically switches to the parent branch** (stays mounted) +6. Memory-mapped regions trigger `SIGBUS` on next access ### Abort -Aborting discards the entire branch chain without affecting the base: +Aborting discards only the **leaf branch** without affecting the parent: -1. The entire branch chain (current branch up to main) is discarded -2. Other branches continue operating normally -3. **Mount automatically switches to main branch** (stays mounted) -4. Memory-mapped regions in aborted branches trigger `SIGBUS` +1. Only leaf branches can be aborted, attempting to abort a branch with children returns an error +2. The leaf branch's delta storage is discarded +3. Other branches (including the parent) continue operating normally +4. **Mount automatically switches to the parent branch** (stays mounted) +5. Memory-mapped regions in the aborted branch trigger `SIGBUS` ### Unmount diff --git a/src/branch.rs b/src/branch.rs index eea112e..9454e01 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -12,9 +12,6 @@ use parking_lot::{Mutex, RwLock}; use crate::error::{BranchError, Result}; use crate::inode::ROOT_INO; -/// Type alias for collected changes: (deletions, file modifications) -type CollectedChanges = (HashSet, Vec<(String, PathBuf)>); - pub struct Branch { pub name: String, pub parent: Option, @@ -79,6 +76,18 @@ impl Branch { self.tombstones.read().clone() } + /// Replace the in-memory tombstone set and rewrite the tombstones file. + pub fn set_tombstones(&self, new_tombstones: HashSet) -> Result<()> { + let mut tombstones = self.tombstones.write(); + *tombstones = new_tombstones; + // Rewrite the tombstones file + let mut file = File::create(&self.tombstones_file)?; + for t in tombstones.iter() { + writeln!(file, "{}", t)?; + } + Ok(()) + } + pub fn delta_path(&self, rel_path: &str) -> PathBuf { self.files_dir.join(rel_path.trim_start_matches('/')) } @@ -368,7 +377,14 @@ impl BranchManager { } } - pub fn commit(&self, branch_name: &str) -> Result<()> { + /// Returns true if no other branch has `parent == name`. + fn is_leaf(name: &str, branches: &std::collections::HashMap) -> bool { + !branches.values().any(|b| b.parent.as_deref() == Some(name)) + } + + /// Commit a leaf branch into its immediate parent. + /// Returns the parent branch name on success. + pub fn commit(&self, branch_name: &str) -> Result { let start = Instant::now(); if branch_name == "main" { return Err(BranchError::CannotOperateOnMain); @@ -376,115 +392,164 @@ impl BranchManager { let mut branches = self.branches.write(); - let chain = self.get_branch_chain(branch_name, &branches)?; - let (deletions, files) = self.collect_changes(&chain, &branches)?; - let num_deletions = deletions.len(); - let num_files = files.len(); - let total_bytes: u64 = files - .iter() - .filter_map(|(_, p)| p.metadata().ok()) - .map(|m| m.len()) - .sum(); - - for path in &deletions { - let full_path = self.base_path.join(path.trim_start_matches('/')); - if full_path.exists() { - if full_path.is_dir() { - fs::remove_dir_all(&full_path)?; - } else { - fs::remove_file(&full_path)?; + let branch = branches + .get(branch_name) + .ok_or_else(|| BranchError::NotFound(branch_name.to_string()))?; + + if !Self::is_leaf(branch_name, &branches) { + return Err(BranchError::NotALeaf(branch_name.to_string())); + } + + let parent_name = branch + .parent + .clone() + .ok_or_else(|| BranchError::NotFound(branch_name.to_string()))?; + + let child_tombstones = branch.get_tombstones(); + let child_files_dir = branch.files_dir.clone(); + + if parent_name == "main" { + // Direct child of main: apply to base filesystem + // Apply tombstones as deletions + for path in &child_tombstones { + let full_path = self.base_path.join(path.trim_start_matches('/')); + if full_path.exists() { + if full_path.is_dir() { + fs::remove_dir_all(&full_path)?; + } else { + fs::remove_file(&full_path)?; + } } } - } - for (rel_path, src_path) in &files { - let dest = self.base_path.join(rel_path.trim_start_matches('/')); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent)?; + // Copy delta files to base + let mut num_files = 0u64; + let mut total_bytes = 0u64; + self.walk_files(&child_files_dir, "", &mut |rel_path, src_path| { + let dest = self.base_path.join(rel_path.trim_start_matches('/')); + if let Some(parent_dir) = dest.parent() { + let _ = fs::create_dir_all(parent_dir); + } + if let Ok(meta) = src_path.metadata() { + total_bytes += meta.len(); + } + let _ = fs::copy(src_path, &dest); + num_files += 1; + })?; + + // Remove branch + branches.remove(branch_name); + let branch_dir = self.storage_path.join("branches").join(branch_name); + if branch_dir.exists() { + fs::remove_dir_all(&branch_dir)?; } - fs::copy(src_path, &dest)?; - } - branches.clear(); - let main_branch = Branch::new("main", None, &self.storage_path)?; - branches.insert("main".to_string(), main_branch); + self.epoch.fetch_add(1, Ordering::SeqCst); - self.epoch.fetch_add(1, Ordering::SeqCst); + drop(branches); + self.invalidate_all_mounts(); - // Invalidate kernel cache for all mounts (epoch changed, everything is stale) - // Must be done after releasing the branches lock to avoid deadlock - drop(branches); - self.invalidate_all_mounts(); + let elapsed = start.elapsed(); + log::debug!( + "[BENCH] commit '{}' to base: {:?} ({} us), {} deletions, {} files, {} bytes", + branch_name, + elapsed, + elapsed.as_micros(), + child_tombstones.len(), + num_files, + total_bytes + ); + } else { + // Nested branch: merge delta into parent's delta + let parent = branches + .get(&parent_name) + .ok_or_else(|| BranchError::NotFound(parent_name.to_string()))?; + + let parent_files_dir = parent.files_dir.clone(); + let mut parent_tombstones = parent.get_tombstones(); + + // Step 1: For each child tombstone, remove matching file from parent delta + // and add tombstone to parent + for tombstone in &child_tombstones { + let parent_delta = parent_files_dir.join(tombstone.trim_start_matches('/')); + if parent_delta.exists() { + if parent_delta.is_dir() { + let _ = fs::remove_dir_all(&parent_delta); + } else { + let _ = fs::remove_file(&parent_delta); + } + } + parent_tombstones.insert(tombstone.clone()); + } - let elapsed = start.elapsed(); - log::debug!( - "[BENCH] commit '{}': {:?} ({} us), {} deletions, {} files, {} bytes", - branch_name, - elapsed, - elapsed.as_micros(), - num_deletions, - num_files, - total_bytes - ); + // Step 2: Copy child's delta files into parent's delta directory + let mut copied_paths = Vec::new(); + self.walk_files(&child_files_dir, "", &mut |rel_path, src_path| { + let dest = parent_files_dir.join(rel_path.trim_start_matches('/')); + if let Some(parent_dir) = dest.parent() { + let _ = fs::create_dir_all(parent_dir); + } + let _ = fs::copy(src_path, &dest); + copied_paths.push(rel_path.to_string()); + })?; - Ok(()) - } + // Step 3: For each copied delta file, remove that path from parent's tombstones + for path in &copied_paths { + parent_tombstones.remove(path); + } - pub fn abort(&self, branch_name: &str) -> Result<()> { - let start = Instant::now(); - if branch_name == "main" { - return Err(BranchError::CannotOperateOnMain); - } + // Write updated tombstones to parent + parent.set_tombstones(parent_tombstones)?; - let mut branches = self.branches.write(); - let chain = self.get_branch_chain(branch_name, &branches)?; - - // Collect branch names before modifying (for cache invalidation) - let aborted_branches: Vec = - chain.iter().filter(|n| *n != "main").cloned().collect(); - - for name in &chain { - if name != "main" { - branches.remove(name); - let branch_dir = self.storage_path.join("branches").join(name); - if branch_dir.exists() { - fs::remove_dir_all(&branch_dir)?; - } + // Remove child branch + branches.remove(branch_name); + let branch_dir = self.storage_path.join("branches").join(branch_name); + if branch_dir.exists() { + fs::remove_dir_all(&branch_dir)?; } - } - // Note: abort does NOT increment epoch - only the aborted branch chain - // becomes invalid, siblings remain valid. + self.epoch.fetch_add(1, Ordering::SeqCst); - // Invalidate kernel cache for aborted branches only - // Must be done after releasing the branches lock - drop(branches); - self.invalidate_branches(&aborted_branches); + let affected = vec![branch_name.to_string(), parent_name.clone()]; + drop(branches); + self.invalidate_branches(&affected); - let elapsed = start.elapsed(); - log::debug!( - "[BENCH] abort '{}': {:?} ({} us)", - branch_name, - elapsed, - elapsed.as_micros() - ); + let elapsed = start.elapsed(); + log::debug!( + "[BENCH] commit '{}' into parent '{}': {:?} ({} us)", + branch_name, + parent_name, + elapsed, + elapsed.as_micros(), + ); + } - Ok(()) + Ok(parent_name) } - pub fn abort_single(&self, branch_name: &str) -> Result<()> { + /// Abort a leaf branch, discarding only that branch. + /// Returns the parent branch name on success. + pub fn abort(&self, branch_name: &str) -> Result { + let start = Instant::now(); if branch_name == "main" { - // Nothing to abort for main - return Ok(()); + return Err(BranchError::CannotOperateOnMain); } let mut branches = self.branches.write(); - if !branches.contains_key(branch_name) { - // Branch doesn't exist, nothing to do - return Ok(()); + let branch = branches + .get(branch_name) + .ok_or_else(|| BranchError::NotFound(branch_name.to_string()))?; + + if !Self::is_leaf(branch_name, &branches) { + return Err(BranchError::NotALeaf(branch_name.to_string())); } + let parent_name = branch + .parent + .clone() + .ok_or_else(|| BranchError::NotFound(branch_name.to_string()))?; + // Remove only this branch branches.remove(branch_name); let branch_dir = self.storage_path.join("branches").join(branch_name); @@ -496,59 +561,15 @@ impl BranchManager { drop(branches); self.invalidate_branches(&[branch_name.to_string()]); - Ok(()) - } - - fn get_branch_chain( - &self, - start: &str, - branches: &std::collections::HashMap, - ) -> Result> { - let mut chain = Vec::new(); - let mut current = start; - - loop { - chain.push(current.to_string()); - let branch = branches - .get(current) - .ok_or_else(|| BranchError::NotFound(current.to_string()))?; - - match &branch.parent { - Some(parent) => current = parent, - None => break, - } - } - - Ok(chain) - } - - fn collect_changes( - &self, - chain: &[String], - branches: &std::collections::HashMap, - ) -> Result { - let mut deletions = HashSet::new(); - let mut files = Vec::new(); - let mut seen_files = HashSet::new(); - - for name in chain { - let branch = branches.get(name).unwrap(); - - for path in branch.get_tombstones() { - deletions.insert(path); - } - - if branch.files_dir.exists() { - self.walk_files(&branch.files_dir, "", &mut |rel_path, full_path| { - if !seen_files.contains(rel_path) && !deletions.contains(rel_path) { - seen_files.insert(rel_path.to_string()); - files.push((rel_path.to_string(), full_path.to_path_buf())); - } - })?; - } - } + let elapsed = start.elapsed(); + log::debug!( + "[BENCH] abort '{}': {:?} ({} us)", + branch_name, + elapsed, + elapsed.as_micros() + ); - Ok((deletions, files)) + Ok(parent_name) } fn walk_files(&self, dir: &Path, prefix: &str, f: &mut F) -> Result<()> diff --git a/src/daemon.rs b/src/daemon.rs index 573b940..c254377 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -23,6 +23,7 @@ pub enum Request { Unmount { mountpoint: String }, Create { name: String, parent: String }, NotifySwitch { mountpoint: String, branch: String }, + GetMountBranch { mountpoint: String }, List, Shutdown, } @@ -328,6 +329,15 @@ impl Daemon { Response::error(&format!("Mount not found: {:?}", path)) } } + Request::GetMountBranch { mountpoint } => { + let path = PathBuf::from(&mountpoint); + let mounts = self.mounts.lock(); + if let Some(info) = mounts.get(&path) { + Response::success_with_data(serde_json::json!(info.current_branch)) + } else { + Response::error(&format!("Mount not found: {:?}", path)) + } + } Request::List => { let branches: Vec<_> = self .list_branches() diff --git a/src/error.rs b/src/error.rs index 4ea69f2..fea9af2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,9 @@ pub enum BranchError { #[error("cannot operate on main branch")] CannotOperateOnMain, + #[error("cannot commit/abort non-leaf branch '{0}'")] + NotALeaf(String), + #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/src/fs.rs b/src/fs.rs index 9ed8bed..2be7068 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1230,9 +1230,9 @@ impl Filesystem for BranchFs { BRANCHFS_IOC_COMMIT => { log::info!("ioctl: COMMIT for branch '{}'", branch_name); match self.manager.commit(&branch_name) { - Ok(()) => { - self.switch_to_branch("main"); - log::info!("Switched to main branch after commit"); + Ok(parent) => { + self.switch_to_branch(&parent); + log::info!("Switched to branch '{}' after commit", parent); reply.ioctl(0, &[]) } Err(e) => { @@ -1244,9 +1244,9 @@ impl Filesystem for BranchFs { BRANCHFS_IOC_ABORT => { log::info!("ioctl: ABORT for branch '{}'", branch_name); match self.manager.abort(&branch_name) { - Ok(()) => { - self.switch_to_branch("main"); - log::info!("Switched to main branch after abort"); + Ok(parent) => { + self.switch_to_branch(&parent); + log::info!("Switched to branch '{}' after abort", parent); reply.ioctl(0, &[]) } Err(e) => { diff --git a/src/fs_ctl.rs b/src/fs_ctl.rs index 0865f6d..1c0024e 100644 --- a/src/fs_ctl.rs +++ b/src/fs_ctl.rs @@ -70,10 +70,9 @@ impl BranchFs { }; match result { - Ok(()) => { - // Switch to main branch after successful commit/abort (like DAXFS remount) - self.switch_to_branch("main"); - log::info!("Switched to main branch after {}", cmd_lower); + Ok(parent) => { + self.switch_to_branch(&parent); + log::info!("Switched to branch '{}' after {}", parent, cmd_lower); reply.written(data.len() as u32) } Err(e) => { @@ -100,20 +99,18 @@ impl BranchFs { }; match result { - Ok(()) => { - if cmd_lower == "commit" { - // Commit clears all branches and increments epoch → clear everything - self.inodes.clear(); - self.current_epoch - .store(self.manager.get_epoch(), Ordering::SeqCst); - *self.branch_name.write() = "main".to_string(); - } else { - // Abort: clear inodes for the aborted branch prefix - self.inodes.clear_prefix(&format!("/@{}", branch)); - // Also clear any child branches that may have been aborted - // (abort removes the whole chain) - } - log::info!("Branch ctl {} succeeded for '{}'", cmd_lower, branch); + Ok(parent) => { + // Clear inodes for the affected branch prefix and update epoch + self.inodes.clear_prefix(&format!("/@{}", branch)); + self.current_epoch + .store(self.manager.get_epoch(), Ordering::SeqCst); + *self.branch_name.write() = parent.clone(); + log::info!( + "Branch ctl {} succeeded for '{}', switched to '{}'", + cmd_lower, + branch, + parent + ); reply.written(data.len() as u32) } Err(e) => { diff --git a/src/main.rs b/src/main.rs index 4a4d2a1..ab7ab68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,6 +96,46 @@ fn send_request(storage: &Path, request: &Request) -> Result { .map_err(|e| anyhow::anyhow!("Failed to communicate with daemon: {}", e)) } +/// Determine the parent branch of the mount's current branch. +/// Returns "main" if the current branch is unknown or has no parent. +fn get_parent_branch(storage: &Path, mountpoint: &Path) -> String { + // Ask the daemon what branch this mount is currently on + let current = match send_request( + storage, + &Request::GetMountBranch { + mountpoint: mountpoint.to_string_lossy().to_string(), + }, + ) { + Ok(resp) if resp.ok => resp + .data + .and_then(|d| d.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "main".to_string()), + _ => return "main".to_string(), + }; + + if current == "main" { + return "main".to_string(); + } + + // Get the branch list to find the parent + let list_resp = match send_request(storage, &Request::List) { + Ok(resp) if resp.ok => resp, + _ => return "main".to_string(), + }; + + if let Some(data) = list_resp.data { + if let Some(branches) = data.as_array() { + for branch in branches { + if branch["name"].as_str() == Some(¤t) { + return branch["parent"].as_str().unwrap_or("main").to_string(); + } + } + } + } + + "main".to_string() +} + fn main() -> Result<()> { env_logger::init(); let cli = Cli::parse(); @@ -199,6 +239,9 @@ fn main() -> Result<()> { let storage = storage.canonicalize()?; let ctl_path = mountpoint.join(".branchfs_ctl"); + // Determine parent branch before commit (FUSE handler will switch to it) + let parent = get_parent_branch(&storage, &mountpoint); + let mut file = std::fs::OpenOptions::new() .write(true) .open(&ctl_path) @@ -207,12 +250,12 @@ fn main() -> Result<()> { file.write_all(b"commit") .map_err(|e| anyhow::anyhow!("Commit failed: {}", e))?; - // Notify daemon that we've switched to main + // Notify daemon that we've switched to the parent branch let _ = send_request( &storage, &Request::NotifySwitch { mountpoint: mountpoint.to_string_lossy().to_string(), - branch: "main".to_string(), + branch: parent, }, ); @@ -227,6 +270,9 @@ fn main() -> Result<()> { let storage = storage.canonicalize()?; let ctl_path = mountpoint.join(".branchfs_ctl"); + // Determine parent branch before abort (FUSE handler will switch to it) + let parent = get_parent_branch(&storage, &mountpoint); + let mut file = std::fs::OpenOptions::new() .write(true) .open(&ctl_path) @@ -235,12 +281,12 @@ fn main() -> Result<()> { file.write_all(b"abort") .map_err(|e| anyhow::anyhow!("Abort failed: {}", e))?; - // Notify daemon that we've switched to main + // Notify daemon that we've switched to the parent branch let _ = send_request( &storage, &Request::NotifySwitch { mountpoint: mountpoint.to_string_lossy().to_string(), - branch: "main".to_string(), + branch: parent, }, ); diff --git a/tests/test_abort.sh b/tests/test_abort.sh index 0939968..82e204a 100755 --- a/tests/test_abort.sh +++ b/tests/test_abort.sh @@ -49,7 +49,7 @@ test_abort_switches_to_main() { do_unmount } -test_abort_nested_discards_chain() { +test_abort_nested_discards_leaf_only() { setup do_mount @@ -60,16 +60,16 @@ test_abort_nested_discards_chain() { do_create "abort_n2" "abort_n1" echo "n2 content" > "$TEST_MNT/n2_file.txt" - # Abort from n2 should discard both n2 and n1 + # Abort from n2 should discard only n2, n1 still exists do_abort - # Both branches should be gone - assert_branch_not_exists "abort_n1" "n1 removed after abort" + # n2 should be gone, n1 should still exist assert_branch_not_exists "abort_n2" "n2 removed after abort" + assert_branch_exists "abort_n1" "n1 still exists after n2 abort" - # Files should not be visible - assert_file_not_exists "$TEST_MNT/n1_file.txt" "n1 file gone" - assert_file_not_exists "$TEST_MNT/n2_file.txt" "n2 file gone" + # n2 file should not be visible, but n1 file should be via @branch + assert_file_not_exists "$TEST_MNT/n2_file.txt" "n2 file gone from current view" + assert_file_exists "$TEST_MNT/@abort_n1/n1_file.txt" "n1 file still visible via @abort_n1" # Base unchanged assert_file_not_exists "$TEST_BASE/n1_file.txt" "No n1 file in base" @@ -120,11 +120,38 @@ test_abort_main_fails() { do_unmount } +test_abort_non_leaf_fails() { + setup + do_mount + + # Create A from main, then B from A + do_create "branch_a" "main" + do_create "branch_b" "branch_a" + + # Try to abort A (not a leaf, should fail) + if do_switch "branch_a" && "$BRANCHFS" abort "$TEST_MNT" --storage "$TEST_STORAGE" 2>/dev/null; then + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e " ${RED}✗${NC} Abort non-leaf should fail" + else + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}✓${NC} Abort non-leaf correctly failed" + fi + + # Both branches should still exist + assert_branch_exists "branch_a" "branch_a still exists after failed abort" + assert_branch_exists "branch_b" "branch_b still exists after failed abort" + + do_unmount +} + # Run tests run_test "Abort Discards Changes" test_abort_discards_changes run_test "Abort Switches to Main" test_abort_switches_to_main -run_test "Abort Nested Discards Chain" test_abort_nested_discards_chain +run_test "Abort Nested Discards Leaf Only" test_abort_nested_discards_leaf_only run_test "Abort Preserves Siblings" test_abort_preserves_siblings run_test "Abort Main Fails" test_abort_main_fails +run_test "Abort Non-Leaf Fails" test_abort_non_leaf_fails print_summary diff --git a/tests/test_commit.sh b/tests/test_commit.sh index f0d01cc..eff5c70 100755 --- a/tests/test_commit.sh +++ b/tests/test_commit.sh @@ -101,21 +101,36 @@ test_commit_nested_branches() { assert_file_exists "$TEST_MNT/nest1_file.txt" "nest1 file visible" assert_file_exists "$TEST_MNT/nest2_file.txt" "nest2 file visible" - # Commit from nest2 + # Commit from nest2 — merges into nest1, NOT to base do_commit - # Both files should be in base - assert_file_exists "$TEST_BASE/nest1_file.txt" "nest1 file in base after commit" - assert_file_exists "$TEST_BASE/nest2_file.txt" "nest2 file in base after commit" - - # Both branches should be gone - assert_branch_not_exists "nest1" "nest1 removed after commit" + # nest2 should be gone, nest1 should still exist assert_branch_not_exists "nest2" "nest2 removed after commit" + assert_branch_exists "nest1" "nest1 still exists after nest2 commit" + + # Files should NOT be in base yet + assert_file_not_exists "$TEST_BASE/nest1_file.txt" "nest1 file NOT in base yet" + assert_file_not_exists "$TEST_BASE/nest2_file.txt" "nest2 file NOT in base yet" + + # But nest2_file should be visible via nest1's @branch path + assert_file_exists "$TEST_MNT/@nest1/nest2_file.txt" "nest2 file visible via @nest1" + assert_file_exists "$TEST_MNT/@nest1/nest1_file.txt" "nest1 file visible via @nest1" + + # Now switch to nest1 and commit to base + do_switch "nest1" + do_commit + + # Both files should now be in base + assert_file_exists "$TEST_BASE/nest1_file.txt" "nest1 file in base after nest1 commit" + assert_file_exists "$TEST_BASE/nest2_file.txt" "nest2 file in base after nest1 commit" + + # nest1 should be gone + assert_branch_not_exists "nest1" "nest1 removed after commit to base" do_unmount } -test_commit_invalidates_all_branches() { +test_commit_preserves_siblings() { setup do_mount @@ -125,13 +140,37 @@ test_commit_invalidates_all_branches() { echo "sibling b content" > "$TEST_MNT/sibling_b_file.txt" - # Commit sibling_b + # Commit sibling_b — only sibling_b is removed, sibling_a preserved do_commit - # sibling_a should also be invalidated (removed due to epoch change) - # Note: In current implementation, commit clears ALL branches except main - assert_branch_not_exists "sibling_a" "sibling_a invalidated after commit" assert_branch_not_exists "sibling_b" "sibling_b removed after commit" + assert_branch_exists "sibling_a" "sibling_a preserved after sibling commit" + + do_unmount +} + +test_commit_non_leaf_fails() { + setup + do_mount + + # Create A from main, then B from A + do_create "branch_a" "main" + do_create "branch_b" "branch_a" + + # Try to commit A (not a leaf, should fail) + if do_switch "branch_a" && "$BRANCHFS" commit "$TEST_MNT" --storage "$TEST_STORAGE" 2>/dev/null; then + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e " ${RED}✗${NC} Commit non-leaf should fail" + else + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}✓${NC} Commit non-leaf correctly failed" + fi + + # Both branches should still exist + assert_branch_exists "branch_a" "branch_a still exists after failed commit" + assert_branch_exists "branch_b" "branch_b still exists after failed commit" do_unmount } @@ -142,6 +181,7 @@ run_test "Commit Modified File" test_commit_modified_file run_test "Commit Deleted File" test_commit_deleted_file run_test "Commit Switches to Main" test_commit_switches_to_main run_test "Commit Nested Branches" test_commit_nested_branches -run_test "Commit Invalidates All Branches" test_commit_invalidates_all_branches +run_test "Commit Preserves Siblings" test_commit_preserves_siblings +run_test "Commit Non-Leaf Fails" test_commit_non_leaf_fails print_summary diff --git a/tests/test_helper.sh b/tests/test_helper.sh index 90c23c9..49e44ec 100755 --- a/tests/test_helper.sh +++ b/tests/test_helper.sh @@ -121,6 +121,12 @@ do_abort() { "$BRANCHFS" abort "$TEST_MNT" --storage "$TEST_STORAGE" } +# Switch to a branch by writing to the ctl file +do_switch() { + local name="$1" + echo -n "switch:${name}" > "$TEST_MNT/.branchfs_ctl" +} + # List branches do_list() { "$BRANCHFS" list --storage "$TEST_STORAGE"