diff --git a/.claude/tasks/2025-07-07-bash-migration.md b/.claude/tasks/2025-07-07-bash-migration.md index 02128cca..edd7327c 100644 --- a/.claude/tasks/2025-07-07-bash-migration.md +++ b/.claude/tasks/2025-07-07-bash-migration.md @@ -36,11 +36,13 @@ 2. **Setup orchestration (bash)**: `setup.bash` - Calls utilities directly, professional tooling 3. **Interactive tools (zsh)**: `bin/update/*.zsh` - Thin wrappers, rich user experience -### Phase 3: Framework Cleanup (Pending) -**Goal**: Remove zsh test framework and update CI +### Phase 3: Framework Cleanup and CI Enhancement (Pending) +**Goal**: Remove zsh test framework, update CI, and add shellcheck validation **Tasks**: - Update `.github/workflows/test-dotfiles.yml` to use bats instead of custom runner +- Add shellcheck step to CI pipeline for all bash scripts +- Configure shellcheck with appropriate exclusions (e.g., SC1091 for sourced files) - Remove `test/run-tests.zsh` and custom zsh test utilities - Update documentation to reflect bash-first approach - Archive zsh test framework with migration notes @@ -79,6 +81,25 @@ test/install/test-{component}-utils.bats # Unit tests test/install/test-{component}-installation.bats # Integration tests ``` +### Shellcheck CI Configuration (Planned) +```yaml +# .github/workflows/shellcheck.yml +- name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: '.' + check_together: 'yes' + ignore_paths: 'test/install/lib' # Mock scripts + severity: 'error' + format: 'gcc' +``` + +**Shellcheck Standards**: +- All bash scripts must pass with zero warnings +- Use inline directives sparingly (prefer fixing the issue) +- Document any necessary exclusions (e.g., SC1091 for sourced files) +- Configure `.shellcheckrc` for project-wide settings if needed + ### Bash Script Standards - `#!/usr/bin/env bash` shebang - `set -euo pipefail` strict mode @@ -98,7 +119,11 @@ test/install/test-{component}-installation.bats # Integration tests ### CI/CD Pipeline - GitHub Actions currently uses custom zsh test runner - **Future**: Migrate to bats with better reporting and parallelization -- **Benefit**: Standard test output format, better integration with GitHub UI +- **Future**: Add shellcheck validation step for all bash scripts +- **Benefits**: + - Standard test output format, better integration with GitHub UI + - Automated code quality enforcement via shellcheck + - Catch shell scripting issues before merge ### Code Sharing Strategy - **Extract to utilities**: Shared logic between setup and update scripts goes to `lib/*-utils.bash` @@ -157,9 +182,10 @@ test/install/test-{component}-installation.bats # Integration tests ### Quality Gates - All bats tests must pass -- Zero shellcheck warnings allowed +- Zero shellcheck warnings allowed (enforced in CI) - Feature parity verification required - Complete behavior bundle (no dead code) +- CI shellcheck validation must pass ## Current Work diff --git a/bin/install/symlinks.bash b/bin/install/symlinks.bash new file mode 100644 index 00000000..c0b5a710 --- /dev/null +++ b/bin/install/symlinks.bash @@ -0,0 +1,200 @@ +#!/usr/bin/env bash + +# Symlink Installation Script +# Creates symbolic links for all dotfiles configurations + +set -euo pipefail + +# Source utility functions +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dotfiles_root="$(cd "$script_dir/../.." && pwd)" + +# shellcheck source=../../lib/symlink-utils.bash +source "$dotfiles_root/lib/symlink-utils.bash" + +# Configuration paths +DOTFILES="$dotfiles_root" +DOTCONFIG="$DOTFILES/config" +HOMECONFIG="$HOME/.config" + +# Main symlink creation function +create_dotfiles_symlinks() { + echo "๐Ÿ”— Creating dotfiles symlinks..." + + # Remove broken symlinks first + echo "๐Ÿงน Cleaning up broken symlinks..." + remove_broken_symlinks "$HOME" + remove_broken_symlinks "$HOMECONFIG" + + echo "๐Ÿ  Creating home directory symlinks..." + + # Create symlinks for home directory files + local home_files=( + "$DOTFILES/home/.claude" + "$DOTFILES/home/.hushlogin" + "$DOTFILES/home/.zshenv" + ) + + for file in "${home_files[@]}"; do + if [[ -e "$file" ]]; then + maybe_symlink "$file" "$HOME" + else + echo "โš ๏ธ Skipping missing file: $file" + fi + done + + echo "โš™๏ธ Creating config directory symlinks..." + + # Create symlinks for all config files + create_config_symlinks + + echo "๐Ÿ“š Creating Library symlinks..." + + # Create VS Code symlinks + create_vscode_symlinks + + # Create Yazi symlinks if available + create_yazi_symlinks + + echo "โœ… All dotfiles symlinks created successfully" +} + +# Create symlinks for config directory files +create_config_symlinks() { + # Ensure config directory exists + mkdir -p "$HOMECONFIG" + + # Find all files in config directory and create symlinks + if command -v fd >/dev/null 2>&1; then + # Use fd if available (faster and more reliable) + while IFS= read -r -d '' file; do + local relpath="${file#"$DOTCONFIG"/}" + local dirpath + dirpath="$(dirname "$relpath")" + local targetdir="$HOMECONFIG/$dirpath" + + maybe_symlink "$file" "$targetdir" + done < <(fd --type file --hidden . "$DOTCONFIG" --print0 2>/dev/null) + else + # Fallback to find command + while IFS= read -r -d '' file; do + local relpath="${file#"$DOTCONFIG"/}" + local dirpath + dirpath="$(dirname "$relpath")" + local targetdir="$HOMECONFIG/$dirpath" + + maybe_symlink "$file" "$targetdir" + done < <(find "$DOTCONFIG" -type f -print0 2>/dev/null) + fi +} + +# Create VS Code symlinks +create_vscode_symlinks() { + local vscode_user="$HOME/Library/Application Support/Code/User" + + # Check if VS Code directory exists + if [[ ! -d "$vscode_user" ]]; then + echo "๐Ÿ“ VS Code not found, skipping VS Code symlinks" + return 0 + fi + + local vscode_files=( + "$DOTFILES/library/vscode/settings.json" + "$DOTFILES/library/vscode/keybindings.json" + "$DOTFILES/library/vscode/snippets" + ) + + for file in "${vscode_files[@]}"; do + if [[ -e "$file" ]]; then + maybe_symlink "$file" "$vscode_user" + else + echo "โš ๏ธ Skipping missing VS Code file: $file" + fi + done +} + +# Create Yazi symlinks if available +create_yazi_symlinks() { + local yazi_flavors="$HOME/Repos/yazi-rs/flavors" + + if [[ ! -d "$yazi_flavors" ]]; then + echo "๐Ÿ—‚๏ธ Yazi flavors not found, skipping Yazi symlinks" + return 0 + fi + + # Create symlink for Catppuccin Mocha theme + local catppuccin_theme="$yazi_flavors/catppuccin-mocha.yazi" + if [[ -d "$catppuccin_theme" ]]; then + maybe_symlink "$catppuccin_theme" "$HOMECONFIG/yazi/flavors" + else + echo "โš ๏ธ Catppuccin Mocha theme not found at $catppuccin_theme" + fi +} + +# Verify symlinks were created correctly +verify_symlinks() { + echo "๐Ÿ” Verifying symlinks..." + + local verification_failed=false + + # Check critical symlinks + local critical_symlinks=( + "$HOME/.zshenv:$DOTFILES/home/.zshenv" + "$HOMECONFIG/nvim/init.lua:$DOTFILES/config/nvim/init.lua" + "$HOMECONFIG/tmux/tmux.conf:$DOTFILES/config/tmux/tmux.conf" + ) + + for symlink_check in "${critical_symlinks[@]}"; do + local symlink_path="${symlink_check%:*}" + local expected_target="${symlink_check#*:}" + + if [[ -e "$expected_target" ]]; then + if is_symlink_correct "$symlink_path" "$expected_target"; then + echo "โœ… $symlink_path โ†’ $expected_target" + else + echo "โŒ $symlink_path is not correctly linked to $expected_target" + verification_failed=true + fi + fi + done + + if [[ "$verification_failed" == "true" ]]; then + echo "โš ๏ธ Some symlinks verification failed" + return 1 + else + echo "โœ… All critical symlinks verified successfully" + return 0 + fi +} + +# Main execution +main() { + local action="${1:-create}" + + case "$action" in + "create") + create_dotfiles_symlinks + ;; + "verify") + verify_symlinks + ;; + "clean") + echo "๐Ÿงน Cleaning broken symlinks..." + remove_broken_symlinks "$HOME" + remove_broken_symlinks "$HOMECONFIG" + echo "โœ… Cleanup completed" + ;; + *) + echo "Usage: $0 [create|verify|clean]" >&2 + echo " create - Create all dotfiles symlinks (default)" >&2 + echo " verify - Verify critical symlinks are correct" >&2 + echo " clean - Remove broken symlinks only" >&2 + exit 1 + ;; + esac +} + +# Only run main if script is executed directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/lib/symlink-utils.bash b/lib/symlink-utils.bash new file mode 100644 index 00000000..0027e489 --- /dev/null +++ b/lib/symlink-utils.bash @@ -0,0 +1,165 @@ +#!/usr/bin/env bash + +# Symlink utilities for dotfiles setup +# Provides reusable functionality for managing symbolic links + +set -euo pipefail + +# Create symlink if it doesn't exist or points to wrong target +# Args: source_file - absolute path to source file +# target_dir - absolute path to target directory +# Returns: 0 if symlink created/already correct, 1 on error +maybe_symlink() { + local source_file="${1:-}" + local target_dir="${2:-}" + + if [[ -z "$source_file" ]]; then + echo "Error: Source file path is required" >&2 + return 1 + fi + + if [[ -z "$target_dir" ]]; then + echo "Error: Target directory path is required" >&2 + return 1 + fi + + # Check if source file exists + if [[ ! -e "$source_file" ]]; then + echo "Error: Source file does not exist: $source_file" >&2 + return 1 + fi + + local file_name + file_name="$(basename "$source_file")" + local target_path="$target_dir/$file_name" + + # Check if the target file exists and is a symlink pointing to the correct source file + if [[ -L "$target_path" ]] && [[ "$(readlink "$target_path")" == "$source_file" ]]; then + return 0 + fi + + # Create target directory if it doesn't exist + mkdir -p "$target_dir" + + # Create or update symlink + printf "๐Ÿ”— " # inline prefix for the output of the next line + ln -sfv "$source_file" "$target_dir" +} + +# Remove broken symlinks from a directory +# Args: directory - path to directory to clean +# Returns: 0 on success, 1 on error +remove_broken_symlinks() { + local directory="${1:-}" + + if [[ -z "$directory" ]]; then + echo "Error: Directory path is required" >&2 + return 1 + fi + + if [[ ! -d "$directory" ]]; then + echo "Directory does not exist: $directory" >&2 + return 0 + fi + + # Find all symlinks in directory and check if they're broken + local symlink + while IFS= read -r -d '' symlink; do + # Check if symlink target exists + if [[ ! -e "$symlink" ]]; then + echo "Removing broken symlink: $symlink" + rm "$symlink" + fi + done < <(find "$directory" -maxdepth 1 -type l -print0 2>/dev/null) + + return 0 +} + +# Check if symlink points to correct target +# Args: symlink_path - path to symlink to check +# expected_target - expected target path +# Returns: 0 if symlink is correct, 1 otherwise +is_symlink_correct() { + local symlink_path="${1:-}" + local expected_target="${2:-}" + + if [[ -z "$symlink_path" ]] || [[ -z "$expected_target" ]]; then + return 1 + fi + + # Check if it's a symlink and points to correct target + if [[ -L "$symlink_path" ]] && [[ "$(readlink "$symlink_path")" == "$expected_target" ]]; then + return 0 + else + return 1 + fi +} + +# Get relative path from one absolute path to another +# Args: from_path - absolute path to start from +# to_path - absolute path to target +# Returns: relative path on stdout +get_relative_path() { + local from_path="${1:-}" + local to_path="${2:-}" + + if [[ -z "$from_path" ]] || [[ -z "$to_path" ]]; then + echo "Error: Both from_path and to_path are required" >&2 + return 1 + fi + + # Use Python to calculate relative path (more reliable than shell implementation) + python3 -c " +import os +import sys +try: + rel_path = os.path.relpath('$to_path', '$from_path') + print(rel_path) +except ValueError as e: + print('Error calculating relative path', file=sys.stderr) + sys.exit(1) +" 2>/dev/null || { + # Fallback: return absolute path if relative calculation fails + echo "$to_path" + } +} + +# Create symlink with relative path +# Args: source_file - absolute path to source file +# target_dir - absolute path to target directory +# Returns: 0 if symlink created/already correct, 1 on error +maybe_symlink_relative() { + local source_file="${1:-}" + local target_dir="${2:-}" + + if [[ -z "$source_file" ]] || [[ -z "$target_dir" ]]; then + echo "Error: Both source file and target directory are required" >&2 + return 1 + fi + + # Check if source file exists + if [[ ! -e "$source_file" ]]; then + echo "Error: Source file does not exist: $source_file" >&2 + return 1 + fi + + local file_name + file_name="$(basename "$source_file")" + local target_path="$target_dir/$file_name" + + # Calculate relative path from target directory to source file + local relative_source + relative_source=$(get_relative_path "$target_dir" "$source_file") + + # Check if the target file exists and is a symlink pointing to the correct source file + if [[ -L "$target_path" ]] && [[ "$(readlink "$target_path")" == "$relative_source" ]]; then + return 0 + fi + + # Create target directory if it doesn't exist + mkdir -p "$target_dir" + + # Create or update symlink with relative path + printf "๐Ÿ”— " # inline prefix for the output of the next line + ln -sfv "$relative_source" "$target_path" +} \ No newline at end of file diff --git a/test/setup/test-symlink-utils-bash.bats b/test/setup/test-symlink-utils-bash.bats new file mode 100755 index 00000000..14a942fc --- /dev/null +++ b/test/setup/test-symlink-utils-bash.bats @@ -0,0 +1,264 @@ +#!/usr/bin/env bats + +# Symlink utilities tests (bash version) +# Tests the bash version of symlink management functionality + +# Load the symlink utilities +load "../../lib/symlink-utils.bash" + +setup() { + # Create temporary directory for each test + export TEST_TEMP_DIR + TEST_TEMP_DIR="$(mktemp -d)" + + # Create test source and target directories + export TEST_SOURCE_DIR="$TEST_TEMP_DIR/source" + export TEST_TARGET_DIR="$TEST_TEMP_DIR/target" + mkdir -p "$TEST_SOURCE_DIR" "$TEST_TARGET_DIR" + + # Save original HOME for restoration + export ORIGINAL_HOME="$HOME" +} + +teardown() { + # Restore original environment + export HOME="$ORIGINAL_HOME" + + # Clean up temporary directory + if [[ -n "${TEST_TEMP_DIR:-}" && -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Unit Tests for maybe_symlink function + +@test "maybe_symlink creates symlink when target does not exist" { + # Create source file + echo "test content" > "$TEST_SOURCE_DIR/test.txt" + + run maybe_symlink "$TEST_SOURCE_DIR/test.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify symlink was created + [ -L "$TEST_TARGET_DIR/test.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/test.txt")" = "$TEST_SOURCE_DIR/test.txt" ] +} + +@test "maybe_symlink does nothing when correct symlink already exists" { + # Create source file and existing correct symlink + echo "test content" > "$TEST_SOURCE_DIR/existing.txt" + ln -s "$TEST_SOURCE_DIR/existing.txt" "$TEST_TARGET_DIR/existing.txt" + + run maybe_symlink "$TEST_SOURCE_DIR/existing.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify symlink still exists and is correct + [ -L "$TEST_TARGET_DIR/existing.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/existing.txt")" = "$TEST_SOURCE_DIR/existing.txt" ] +} + +@test "maybe_symlink replaces incorrect symlink" { + # Create source files + echo "old content" > "$TEST_SOURCE_DIR/old.txt" + echo "new content" > "$TEST_SOURCE_DIR/new.txt" + + # Create symlink pointing to wrong target + ln -s "$TEST_SOURCE_DIR/old.txt" "$TEST_TARGET_DIR/new.txt" + + run maybe_symlink "$TEST_SOURCE_DIR/new.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify symlink now points to correct target + [ -L "$TEST_TARGET_DIR/new.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/new.txt")" = "$TEST_SOURCE_DIR/new.txt" ] +} + +@test "maybe_symlink replaces regular file with symlink" { + # Create source file and regular file at target + echo "source content" > "$TEST_SOURCE_DIR/replace.txt" + echo "target content" > "$TEST_TARGET_DIR/replace.txt" + + run maybe_symlink "$TEST_SOURCE_DIR/replace.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify regular file was replaced with symlink + [ -L "$TEST_TARGET_DIR/replace.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/replace.txt")" = "$TEST_SOURCE_DIR/replace.txt" ] +} + +@test "maybe_symlink creates target directory when it does not exist" { + # Create source file + echo "test content" > "$TEST_SOURCE_DIR/test.txt" + + # Use non-existent target directory + local new_target="$TEST_TEMP_DIR/new_target" + + run maybe_symlink "$TEST_SOURCE_DIR/test.txt" "$new_target" + [ "$status" -eq 0 ] + + # Verify directory was created and symlink exists + [ -d "$new_target" ] + [ -L "$new_target/test.txt" ] + [ "$(readlink "$new_target/test.txt")" = "$TEST_SOURCE_DIR/test.txt" ] +} + +@test "maybe_symlink fails when source file does not exist" { + run maybe_symlink "$TEST_SOURCE_DIR/nonexistent.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 1 ] + [[ "$output" == *"Source file does not exist"* ]] +} + +@test "maybe_symlink fails when no arguments provided" { + run maybe_symlink + [ "$status" -eq 1 ] + [[ "$output" == *"Source file path is required"* ]] +} + +@test "maybe_symlink fails when only source provided" { + echo "test" > "$TEST_SOURCE_DIR/test.txt" + + run maybe_symlink "$TEST_SOURCE_DIR/test.txt" + [ "$status" -eq 1 ] + [[ "$output" == *"Target directory path is required"* ]] +} + +# Unit Tests for remove_broken_symlinks function + +@test "remove_broken_symlinks removes symlinks pointing to non-existent files" { + # Create symlink pointing to non-existent file + ln -s "$TEST_SOURCE_DIR/nonexistent.txt" "$TEST_TARGET_DIR/broken.txt" + + run remove_broken_symlinks "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify broken symlink was removed + [ ! -e "$TEST_TARGET_DIR/broken.txt" ] +} + +@test "remove_broken_symlinks keeps valid symlinks" { + # Create source file and valid symlink + echo "content" > "$TEST_SOURCE_DIR/valid.txt" + ln -s "$TEST_SOURCE_DIR/valid.txt" "$TEST_TARGET_DIR/valid.txt" + + run remove_broken_symlinks "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify valid symlink still exists + [ -L "$TEST_TARGET_DIR/valid.txt" ] + [ -e "$TEST_TARGET_DIR/valid.txt" ] +} + +@test "remove_broken_symlinks keeps regular files" { + # Create regular file + echo "content" > "$TEST_TARGET_DIR/regular.txt" + + run remove_broken_symlinks "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify regular file still exists + [ -f "$TEST_TARGET_DIR/regular.txt" ] + [ ! -L "$TEST_TARGET_DIR/regular.txt" ] +} + +@test "remove_broken_symlinks handles non-existent directory gracefully" { + run remove_broken_symlinks "$TEST_TEMP_DIR/nonexistent" + [ "$status" -eq 0 ] + [[ "$output" == *"Directory does not exist"* ]] +} + +@test "remove_broken_symlinks requires directory argument" { + run remove_broken_symlinks + [ "$status" -eq 1 ] + [[ "$output" == *"Directory path is required"* ]] +} + +# Unit Tests for is_symlink_correct function + +@test "is_symlink_correct returns 0 for correct symlink" { + # Create source file and correct symlink + echo "content" > "$TEST_SOURCE_DIR/correct.txt" + ln -s "$TEST_SOURCE_DIR/correct.txt" "$TEST_TARGET_DIR/correct.txt" + + run is_symlink_correct "$TEST_TARGET_DIR/correct.txt" "$TEST_SOURCE_DIR/correct.txt" + [ "$status" -eq 0 ] +} + +@test "is_symlink_correct returns 1 for incorrect symlink" { + # Create source files and incorrect symlink + echo "content1" > "$TEST_SOURCE_DIR/file1.txt" + echo "content2" > "$TEST_SOURCE_DIR/file2.txt" + ln -s "$TEST_SOURCE_DIR/file1.txt" "$TEST_TARGET_DIR/link.txt" + + run is_symlink_correct "$TEST_TARGET_DIR/link.txt" "$TEST_SOURCE_DIR/file2.txt" + [ "$status" -eq 1 ] +} + +@test "is_symlink_correct returns 1 for non-existent symlink" { + run is_symlink_correct "$TEST_TARGET_DIR/nonexistent.txt" "$TEST_SOURCE_DIR/any.txt" + [ "$status" -eq 1 ] +} + +@test "is_symlink_correct returns 1 for regular file" { + # Create regular file + echo "content" > "$TEST_TARGET_DIR/regular.txt" + + run is_symlink_correct "$TEST_TARGET_DIR/regular.txt" "$TEST_SOURCE_DIR/any.txt" + [ "$status" -eq 1 ] +} + +# Integration Tests + +@test "symlink workflow: create multiple symlinks" { + # Create multiple source files + echo "file1" > "$TEST_SOURCE_DIR/file1.txt" + echo "file2" > "$TEST_SOURCE_DIR/file2.txt" + echo "file3" > "$TEST_SOURCE_DIR/file3.txt" + + # Create symlinks + run maybe_symlink "$TEST_SOURCE_DIR/file1.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + run maybe_symlink "$TEST_SOURCE_DIR/file2.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + run maybe_symlink "$TEST_SOURCE_DIR/file3.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify all symlinks created correctly + [ -L "$TEST_TARGET_DIR/file1.txt" ] + [ -L "$TEST_TARGET_DIR/file2.txt" ] + [ -L "$TEST_TARGET_DIR/file3.txt" ] + + # Verify symlinks point to correct targets + [ "$(readlink "$TEST_TARGET_DIR/file1.txt")" = "$TEST_SOURCE_DIR/file1.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/file2.txt")" = "$TEST_SOURCE_DIR/file2.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/file3.txt")" = "$TEST_SOURCE_DIR/file3.txt" ] +} + +@test "symlink workflow: cleanup broken symlinks then create new ones" { + # Create broken symlinks + ln -s "$TEST_SOURCE_DIR/missing1.txt" "$TEST_TARGET_DIR/broken1.txt" + ln -s "$TEST_SOURCE_DIR/missing2.txt" "$TEST_TARGET_DIR/broken2.txt" + + # Create valid symlink + echo "valid" > "$TEST_SOURCE_DIR/valid.txt" + ln -s "$TEST_SOURCE_DIR/valid.txt" "$TEST_TARGET_DIR/valid.txt" + + # Cleanup broken symlinks + run remove_broken_symlinks "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify broken symlinks removed, valid one kept + [ ! -e "$TEST_TARGET_DIR/broken1.txt" ] + [ ! -e "$TEST_TARGET_DIR/broken2.txt" ] + [ -L "$TEST_TARGET_DIR/valid.txt" ] + + # Create new symlinks + echo "new content" > "$TEST_SOURCE_DIR/new.txt" + run maybe_symlink "$TEST_SOURCE_DIR/new.txt" "$TEST_TARGET_DIR" + [ "$status" -eq 0 ] + + # Verify new symlink created + [ -L "$TEST_TARGET_DIR/new.txt" ] + [ "$(readlink "$TEST_TARGET_DIR/new.txt")" = "$TEST_SOURCE_DIR/new.txt" ] +} \ No newline at end of file