diff --git a/src/branch.rs b/src/branch.rs index aaaf867..47379e8 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -11,6 +11,7 @@ use parking_lot::{Mutex, RwLock}; use crate::error::{BranchError, Result}; use crate::inode::ROOT_INO; +use crate::storage; pub struct Branch { pub name: String, @@ -104,7 +105,7 @@ impl Branch { } pub fn has_delta(&self, rel_path: &str) -> bool { - self.delta_path(rel_path).exists() + self.delta_path(rel_path).symlink_metadata().is_ok() } } @@ -419,7 +420,7 @@ impl BranchManager { } let base = self.base_path.join(rel_path.trim_start_matches('/')); - if base.exists() { + if base.symlink_metadata().is_ok() { Ok(Some(base)) } else { Ok(None) @@ -476,8 +477,12 @@ impl BranchManager { // 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() { + if full_path.symlink_metadata().is_ok() { + if full_path + .symlink_metadata() + .map(|m| m.file_type().is_dir()) + .unwrap_or(false) + { fs::remove_dir_all(&full_path)?; } else { fs::remove_file(&full_path)?; @@ -493,10 +498,10 @@ impl BranchManager { if let Some(parent_dir) = dest.parent() { let _ = fs::create_dir_all(parent_dir); } - if let Ok(meta) = src_path.metadata() { + if let Ok(meta) = src_path.symlink_metadata() { total_bytes += meta.len(); } - let _ = fs::copy(src_path, &dest); + let _ = storage::copy_entry(src_path, &dest); num_files += 1; })?; @@ -557,7 +562,7 @@ impl BranchManager { if let Some(parent_dir) = dest.parent() { let _ = fs::create_dir_all(parent_dir); } - let _ = fs::copy(src_path, &dest); + let _ = storage::copy_entry(src_path, &dest); copied_paths.push(rel_path.to_string()); })?; @@ -661,7 +666,11 @@ impl BranchManager { format!("{}/{}", prefix, name) }; - if path.is_dir() { + let is_dir = path + .symlink_metadata() + .map(|m| m.file_type().is_dir()) + .unwrap_or(false); + if is_dir { self.walk_files(&path, &rel_path, f)?; } else { f(&rel_path, &path); diff --git a/src/fs.rs b/src/fs.rs index ee9fa15..e50d483 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -377,7 +377,10 @@ impl Filesystem for BranchFs { return; } }; - let is_dir = resolved.is_dir(); + let is_dir = resolved + .symlink_metadata() + .map(|m| m.is_dir()) + .unwrap_or(false); let ino = self.inodes.get_or_create(&path, is_dir); match self.make_attr(ino, &resolved) { Some(attr) => reply.entry(&TTL, &attr, 0), @@ -428,7 +431,10 @@ impl Filesystem for BranchFs { }; let inode_path = format!("/@{}{}", branch, child_rel); - let is_dir = resolved.is_dir(); + let is_dir = resolved + .symlink_metadata() + .map(|m| m.is_dir()) + .unwrap_or(false); let ino = self.inodes.get_or_create(&inode_path, is_dir); match self.make_attr(ino, &resolved) { Some(attr) => reply.entry(&TTL, &attr, 0), @@ -449,7 +455,10 @@ impl Filesystem for BranchFs { return; } }; - let is_dir = resolved.is_dir(); + let is_dir = resolved + .symlink_metadata() + .map(|m| m.is_dir()) + .unwrap_or(false); let ino = self.inodes.get_or_create(&path, is_dir); match self.make_attr(ino, &resolved) { Some(attr) => reply.entry(&TTL, &attr, 0), @@ -1679,4 +1688,133 @@ impl Filesystem for BranchFs { } } } + + fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) { + let resolved = match self.classify_ino(ino) { + Some(PathContext::BranchPath(branch, rel_path)) => { + if !self.manager.is_branch_valid(&branch) { + reply.error(libc::ENOENT); + return; + } + match self.resolve_for_branch(&branch, &rel_path) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + } + } + Some(PathContext::RootPath(ref rp)) => { + if self.is_stale() { + reply.error(libc::ESTALE); + return; + } + match self.resolve(rp) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + } + } + _ => { + reply.error(libc::EINVAL); + return; + } + }; + + match std::fs::read_link(&resolved) { + Ok(target) => reply.data(target.as_os_str().as_encoded_bytes()), + Err(_) => reply.error(libc::EINVAL), + } + } + + fn symlink( + &mut self, + _req: &Request, + parent: u64, + link_name: &OsStr, + target: &Path, + reply: ReplyEntry, + ) { + let parent_path = match self.inodes.get_path(parent) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let name_str = link_name.to_string_lossy(); + + let branch_ctx = match classify_path(&parent_path) { + PathContext::BranchDir(b) => Some((b, "/".to_string())), + PathContext::BranchPath(b, rel) => Some((b, rel)), + _ => None, + }; + + if let Some((branch, parent_rel)) = branch_ctx { + if !self.manager.is_branch_valid(&branch) { + reply.error(libc::ENOENT); + return; + } + let rel_path = if parent_rel == "/" { + format!("/{}", name_str) + } else { + format!("{}/{}", parent_rel, name_str) + }; + let delta = self.get_delta_path_for_branch(&branch, &rel_path); + if storage::ensure_parent_dirs(&delta).is_err() { + reply.error(libc::EIO); + return; + } + match std::os::unix::fs::symlink(target, &delta) { + Ok(()) => { + let inode_path = format!("/@{}{}", branch, rel_path); + let ino = self.inodes.get_or_create(&inode_path, false); + match self.make_attr(ino, &delta) { + Some(attr) => reply.entry(&TTL, &attr, 0), + None => reply.error(libc::EIO), + } + } + Err(_) => reply.error(libc::EIO), + } + } else { + match classify_path(&parent_path) { + PathContext::BranchCtl(_) | PathContext::RootCtl => { + reply.error(libc::EPERM); + } + PathContext::RootPath(rp) => { + let path = if rp == "/" { + format!("/{}", name_str) + } else { + format!("{}/{}", rp, name_str) + }; + let delta = self.get_delta_path(&path); + if storage::ensure_parent_dirs(&delta).is_err() { + reply.error(libc::EIO); + return; + } + match std::os::unix::fs::symlink(target, &delta) { + Ok(()) => { + if self.is_stale() { + let _ = std::fs::remove_file(&delta); + reply.error(libc::ESTALE); + return; + } + let ino = self.inodes.get_or_create(&path, false); + match self.make_attr(ino, &delta) { + Some(attr) => reply.entry(&TTL, &attr, 0), + None => reply.error(libc::EIO), + } + } + Err(_) => reply.error(libc::EIO), + } + } + _ => { + reply.error(libc::ENOENT); + } + } + } + } } diff --git a/src/fs_helpers.rs b/src/fs_helpers.rs index 8f313aa..9ecf73e 100644 --- a/src/fs_helpers.rs +++ b/src/fs_helpers.rs @@ -50,11 +50,13 @@ impl BranchFs { ) -> std::io::Result { let delta = self.get_delta_path_for_branch(branch, rel_path); - if !delta.exists() { + if delta.symlink_metadata().is_err() { if let Some(src) = self.resolve_for_branch(branch, rel_path) { - if src.exists() && src.is_file() { - storage::copy_file(&src, &delta) - .map_err(|e| std::io::Error::other(e.to_string()))?; + if let Ok(meta) = src.symlink_metadata() { + if meta.file_type().is_symlink() || meta.file_type().is_file() { + storage::copy_entry(&src, &delta) + .map_err(|e| std::io::Error::other(e.to_string()))?; + } } } } @@ -65,7 +67,7 @@ impl BranchFs { } pub(crate) fn make_attr(&self, ino: u64, path: &Path) -> Option { - let meta = std::fs::metadata(path).ok()?; + let meta = std::fs::symlink_metadata(path).ok()?; let kind = if meta.is_dir() { FileType::Directory } else if meta.is_symlink() { @@ -172,9 +174,13 @@ impl BranchFs { format!("{}/{}", rel_path, name) }; let inode_path = format!("{}{}", inode_prefix, child_rel); - let is_dir = entry.path().is_dir(); + let ft = entry.file_type(); + let is_symlink = ft.as_ref().map(|t| t.is_symlink()).unwrap_or(false); + let is_dir = !is_symlink && ft.as_ref().map(|t| t.is_dir()).unwrap_or(false); let child_ino = self.inodes.get_or_create(&inode_path, is_dir); - let kind = if is_dir { + let kind = if is_symlink { + FileType::Symlink + } else if is_dir { FileType::Directory } else { FileType::RegularFile @@ -197,9 +203,14 @@ impl BranchFs { format!("{}/{}", rel_path, name) }; let inode_path = format!("{}{}", inode_prefix, child_rel); - let is_dir = entry.path().is_dir(); + let ft = entry.file_type(); + let is_symlink = ft.as_ref().map(|t| t.is_symlink()).unwrap_or(false); + let is_dir = + !is_symlink && ft.as_ref().map(|t| t.is_dir()).unwrap_or(false); let child_ino = self.inodes.get_or_create(&inode_path, is_dir); - let kind = if is_dir { + let kind = if is_symlink { + FileType::Symlink + } else if is_dir { FileType::Directory } else { FileType::RegularFile diff --git a/src/storage.rs b/src/storage.rs index 71c27f6..938b80c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -17,6 +17,22 @@ pub fn copy_file(src: &Path, dst: &Path) -> Result<()> { Ok(()) } +/// Symlink-aware copy: if `src` is a symlink, recreate the symlink at `dst`; +/// otherwise fall back to a regular file copy. +pub fn copy_entry(src: &Path, dst: &Path) -> Result<()> { + ensure_parent_dirs(dst)?; + let meta = src.symlink_metadata()?; + if meta.file_type().is_symlink() { + let target = fs::read_link(src)?; + // Remove any pre-existing entry at dst so symlink() won't fail + let _ = fs::remove_file(dst); + std::os::unix::fs::symlink(&target, dst)?; + } else { + fs::copy(src, dst)?; + } + Ok(()) +} + pub fn read_file(path: &Path) -> Result> { let mut file = File::open(path)?; let mut buf = Vec::new(); diff --git a/tests/test_integration.rs b/tests/test_integration.rs index fad8db8..fb6b604 100644 --- a/tests/test_integration.rs +++ b/tests/test_integration.rs @@ -4,6 +4,7 @@ //! Run with: cargo test --test test_integration -- --ignored use std::fs; +use std::os::unix::fs as unix_fs; use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -413,3 +414,243 @@ fn test_mkdir_and_nested_files() { // None of this should be in base assert!(!fix.base.join("a").exists()); } + +// ── Symlink tests ─────────────────────────────────────────────────── + +#[test] +#[ignore] +fn test_symlink_base_visible() { + let fix = TestFixture::new("sym_base"); + + // Add symlinks to base before mounting + unix_fs::symlink("file1.txt", fix.base.join("link1")).unwrap(); + unix_fs::symlink("nonexistent", fix.base.join("dangling")).unwrap(); + + fix.mount(); + + // Symlinks should be visible as symlinks (not followed) + assert!( + fix.mnt + .join("link1") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "base symlink should be a symlink through the mount" + ); + assert_eq!( + fs::read_link(fix.mnt.join("link1")) + .unwrap() + .to_str() + .unwrap(), + "file1.txt" + ); + + // Dangling symlink should be visible too + assert!( + fix.mnt + .join("dangling") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "dangling symlink should be visible" + ); + assert_eq!( + fs::read_link(fix.mnt.join("dangling")) + .unwrap() + .to_str() + .unwrap(), + "nonexistent" + ); + + // Following the valid symlink should work + assert_eq!( + fs::read_to_string(fix.mnt.join("link1")).unwrap(), + "base content\n" + ); +} + +#[test] +#[ignore] +fn test_symlink_create_in_branch() { + let fix = TestFixture::new("sym_create"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Create a symlink in the branch + unix_fs::symlink("file1.txt", bdir.join("new_link")).unwrap(); + + assert!( + bdir.join("new_link") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "created symlink should be a symlink" + ); + assert_eq!( + fs::read_link(bdir.join("new_link")) + .unwrap() + .to_str() + .unwrap(), + "file1.txt" + ); + assert_eq!( + fs::read_to_string(bdir.join("new_link")).unwrap(), + "base content\n" + ); + + // Should NOT be in base + assert!(!fix.base.join("new_link").exists()); +} + +#[test] +#[ignore] +fn test_symlink_commit() { + let fix = TestFixture::new("sym_commit"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Create symlinks in the branch + unix_fs::symlink("file1.txt", bdir.join("link_a")).unwrap(); + unix_fs::symlink("no_target", bdir.join("link_dangling")).unwrap(); + + // Commit + let bctl = fix.open_branch_ctl(&branch); + let ret = unsafe { ioctl_commit(bctl.as_raw_fd()) }; + assert_eq!(ret, 0, "commit should succeed"); + + // Symlinks should now exist in base + assert!( + fix.base + .join("link_a") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "committed symlink should be a symlink in base" + ); + assert_eq!( + fs::read_link(fix.base.join("link_a")) + .unwrap() + .to_str() + .unwrap(), + "file1.txt" + ); + + // Dangling symlink should also be committed + assert!( + fix.base + .join("link_dangling") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "committed dangling symlink should be a symlink in base" + ); + assert_eq!( + fs::read_link(fix.base.join("link_dangling")) + .unwrap() + .to_str() + .unwrap(), + "no_target" + ); +} + +#[test] +#[ignore] +fn test_symlink_abort() { + let fix = TestFixture::new("sym_abort"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + unix_fs::symlink("file1.txt", bdir.join("aborted_link")).unwrap(); + assert!(bdir.join("aborted_link").symlink_metadata().is_ok()); + + // Abort + let bctl = fix.open_branch_ctl(&branch); + let ret = unsafe { ioctl_abort(bctl.as_raw_fd()) }; + assert_eq!(ret, 0, "abort should succeed"); + + // Should not be in base + assert!( + fix.base.join("aborted_link").symlink_metadata().is_err(), + "aborted symlink should not be in base" + ); +} + +#[test] +#[ignore] +fn test_symlink_delete_in_branch() { + let fix = TestFixture::new("sym_delete"); + + // Add a symlink to base + unix_fs::symlink("file1.txt", fix.base.join("link_del")).unwrap(); + + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Symlink should be visible + assert!(bdir + .join("link_del") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + + // Delete it in the branch + fs::remove_file(bdir.join("link_del")).unwrap(); + assert!( + bdir.join("link_del").symlink_metadata().is_err(), + "symlink should be gone in branch" + ); + + // Base should still have it + assert!( + fix.base + .join("link_del") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "base symlink should be unaffected" + ); +} + +#[test] +#[ignore] +fn test_symlink_isolation_between_branches() { + let fix = TestFixture::new("sym_isolation"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch_a = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE A"); + let branch_b = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE B"); + + let dir_a = fix.branch_dir(&branch_a); + let dir_b = fix.branch_dir(&branch_b); + + // Create different symlinks in each branch + unix_fs::symlink("file1.txt", dir_a.join("link_a")).unwrap(); + unix_fs::symlink("file2.txt", dir_b.join("link_b")).unwrap(); + + // Each branch should only see its own symlink + assert!(dir_a.join("link_a").symlink_metadata().is_ok()); + assert!(dir_a.join("link_b").symlink_metadata().is_err()); + + assert!(dir_b.join("link_b").symlink_metadata().is_ok()); + assert!(dir_b.join("link_a").symlink_metadata().is_err()); +} diff --git a/tests/test_symlink.sh b/tests/test_symlink.sh new file mode 100755 index 0000000..43b2008 --- /dev/null +++ b/tests/test_symlink.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# Test symlink operations (create, readlink, COW, commit, abort) + +source "$(dirname "$0")/test_helper.sh" + +test_base_symlink_visible() { + setup + + # Add a symlink to the base directory + ln -s file1.txt "$TEST_BASE/link_to_file1" + ln -s subdir "$TEST_BASE/link_to_subdir" + + do_mount + + # Symlink should be visible and identified as a symlink + assert "[[ -L '$TEST_MNT/link_to_file1' ]]" "Base symlink is visible as symlink" + assert "[[ -L '$TEST_MNT/link_to_subdir' ]]" "Base dir symlink is visible as symlink" + + # readlink should return the target + local target + target=$(readlink "$TEST_MNT/link_to_file1") + assert_eq "$target" "file1.txt" "readlink returns correct target" + + target=$(readlink "$TEST_MNT/link_to_subdir") + assert_eq "$target" "subdir" "readlink on dir symlink returns correct target" + + # Following the symlink should work + local content + content=$(cat "$TEST_MNT/link_to_file1") + assert_eq "$content" "base content" "Reading through symlink works" + + do_unmount +} + +test_base_dangling_symlink_visible() { + setup + + # Create a dangling symlink in base + ln -s nonexistent_target "$TEST_BASE/dangling_link" + + do_mount + + # Dangling symlink should be visible as a symlink + assert "[[ -L '$TEST_MNT/dangling_link' ]]" "Dangling symlink is visible" + + # readlink should return the target + local target + target=$(readlink "$TEST_MNT/dangling_link") + assert_eq "$target" "nonexistent_target" "readlink on dangling symlink works" + + # But following it should fail + assert "[[ ! -e '$TEST_MNT/dangling_link' ]]" "Dangling symlink target does not exist" + + do_unmount +} + +test_create_symlink_in_branch() { + setup + do_mount + do_create "symlink_create" "main" + + # Create a symlink in the branch + ln -s file1.txt "$TEST_MNT/new_link" + + assert "[[ -L '$TEST_MNT/new_link' ]]" "New symlink exists" + + local target + target=$(readlink "$TEST_MNT/new_link") + assert_eq "$target" "file1.txt" "New symlink has correct target" + + # Following the symlink should work + local content + content=$(cat "$TEST_MNT/new_link") + assert_eq "$content" "base content" "Reading through new symlink works" + + # Symlink should NOT exist in base + assert "[[ ! -L '$TEST_BASE/new_link' ]]" "Symlink not in base yet" + + do_unmount +} + +test_symlink_cow_preserves_link() { + setup + + # Add a symlink to base + ln -s file1.txt "$TEST_BASE/base_link" + + do_mount + do_create "symlink_cow" "main" + + # The symlink should still be a symlink (not a regular file) + assert "[[ -L '$TEST_MNT/base_link' ]]" "Base symlink still visible in branch" + + local target + target=$(readlink "$TEST_MNT/base_link") + assert_eq "$target" "file1.txt" "Symlink target preserved in branch" + + # Base should be unchanged + assert "[[ -L '$TEST_BASE/base_link' ]]" "Base symlink unchanged" + + do_unmount +} + +test_symlink_readdir_type() { + setup + + # Add symlinks to base + ln -s file1.txt "$TEST_BASE/link1" + ln -s nonexistent "$TEST_BASE/dangling" + + do_mount + + # ls -la should show 'l' for symlinks + local ls_output + ls_output=$(ls -la "$TEST_MNT/" | grep "link1") + assert "[[ '$ls_output' == l* ]]" "ls shows symlink type for link1" + + ls_output=$(ls -la "$TEST_MNT/" | grep "dangling") + assert "[[ '$ls_output' == l* ]]" "ls shows symlink type for dangling link" + + do_unmount +} + +test_symlink_commit_to_base() { + setup + do_mount + do_create "symlink_commit" "main" + + # Create a symlink in the branch + ln -s file2.txt "$TEST_MNT/committed_link" + assert "[[ -L '$TEST_MNT/committed_link' ]]" "Symlink exists before commit" + + # Commit + do_commit + + # Symlink should now exist in base as a symlink + assert "[[ -L '$TEST_BASE/committed_link' ]]" "Symlink in base after commit" + + local target + target=$(readlink "$TEST_BASE/committed_link") + assert_eq "$target" "file2.txt" "Committed symlink has correct target" + + do_unmount +} + +test_symlink_abort_discards() { + setup + do_mount + do_create "symlink_abort" "main" + + # Create a symlink in the branch + ln -s file1.txt "$TEST_MNT/aborted_link" + assert "[[ -L '$TEST_MNT/aborted_link' ]]" "Symlink exists before abort" + + # Abort + do_abort + + # Symlink should NOT exist in base + assert "[[ ! -L '$TEST_BASE/aborted_link' ]]" "Symlink not in base after abort" + assert "[[ ! -e '$TEST_BASE/aborted_link' ]]" "No entry in base after abort" + + do_unmount +} + +test_symlink_delete_in_branch() { + setup + + ln -s file1.txt "$TEST_BASE/link_to_delete" + + do_mount + do_create "symlink_delete" "main" + + assert "[[ -L '$TEST_MNT/link_to_delete' ]]" "Symlink exists before delete" + + # Delete the symlink + rm "$TEST_MNT/link_to_delete" + assert "[[ ! -L '$TEST_MNT/link_to_delete' ]]" "Symlink deleted in branch" + + # Base should still have it + assert "[[ -L '$TEST_BASE/link_to_delete' ]]" "Base symlink still exists" + + do_unmount +} + +test_symlink_in_branch_dir() { + setup + + # Add a symlink to base (may already exist from prior test reusing dirs) + ln -sf file1.txt "$TEST_BASE/base_link" + + do_mount + do_create "symlink_branch_dir" "main" + + # Symlinks should be visible through @branch dir + assert "[[ -L '$TEST_MNT/@symlink_branch_dir/base_link' ]]" "Symlink visible in @branch dir" + + local target + target=$(readlink "$TEST_MNT/@symlink_branch_dir/base_link") + assert_eq "$target" "file1.txt" "readlink works in @branch dir" + + # Create a new symlink through @branch dir + ln -s file2.txt "$TEST_MNT/@symlink_branch_dir/branch_link" + assert "[[ -L '$TEST_MNT/@symlink_branch_dir/branch_link' ]]" "New symlink in @branch dir" + + local target2 + target2=$(readlink "$TEST_MNT/@symlink_branch_dir/branch_link") + assert_eq "$target2" "file2.txt" "readlink on new symlink in @branch dir" + + do_unmount +} + +# Run tests +run_test "Base Symlink Visible" test_base_symlink_visible +run_test "Dangling Symlink Visible" test_base_dangling_symlink_visible +run_test "Create Symlink in Branch" test_create_symlink_in_branch +run_test "Symlink COW Preserves Link" test_symlink_cow_preserves_link +run_test "Symlink Readdir Type" test_symlink_readdir_type +run_test "Symlink Commit to Base" test_symlink_commit_to_base +run_test "Symlink Abort Discards" test_symlink_abort_discards +run_test "Symlink Delete in Branch" test_symlink_delete_in_branch +run_test "Symlink in Branch Dir" test_symlink_in_branch_dir + +print_summary