Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ jobs:
run: |
shellcheck bin/gtr bin/git-gtr lib/*.sh lib/commands/*.sh adapters/editor/*.sh adapters/ai/*.sh

completions:
name: Completions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Verify completion files are up to date
run: ./scripts/generate-completions.sh --check

test:
name: Tests
runs-on: ubuntu-latest
Expand Down
47 changes: 44 additions & 3 deletions lib/copy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ merge_copy_patterns() {
fi
}

# Copy a directory using CoW (copy-on-write) when available, falling back to standard cp.
# macOS APFS: cp -cRP (clone); Linux Btrfs/XFS: cp --reflink=auto -RP
# Callers must guard the return value with `if` or `|| true` (set -e safe).
# Usage: _fast_copy_dir src dest
# Cached OS value for _fast_copy_dir; set on first call.
_fast_copy_os=""

_fast_copy_dir() {
local src="$1" dest="$2"
if [ -z "$_fast_copy_os" ]; then
_fast_copy_os=$(detect_os)
fi
local os="$_fast_copy_os"

case "$os" in
darwin)
# Try CoW clone first; if unsupported, fall back to regular copy
if cp -cRP "$src" "$dest" 2>/dev/null; then
return 0
fi
# Clean up any partial clone output before fallback
local _clone_target="${dest%/}/$(basename "$src")"
if [ -e "$_clone_target" ]; then rm -rf "$_clone_target"; fi
cp -RP "$src" "$dest"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
;;
linux)
cp --reflink=auto -RP "$src" "$dest"
;;
*)
cp -RP "$src" "$dest"
;;
esac
}

# Copy a single file to destination, handling exclusion, path preservation, and dry-run
# Usage: _copy_pattern_file file dst_root excludes preserve_paths dry_run
# Returns: 0 if file was copied (or would be in dry-run), 1 if skipped/failed
Expand Down Expand Up @@ -295,6 +329,13 @@ copy_directories() {

# Find directories matching the pattern
# Use -path for patterns with slashes (e.g., vendor/bundle), -name for basenames
# Note: case inside $() inside heredocs breaks Bash 3.2, so compute first
local find_results
case "$pattern" in
*/*) find_results=$(find . -type d -path "./$pattern" 2>/dev/null) ;;
*) find_results=$(find . -type d -name "$pattern" 2>/dev/null) ;;
esac

while IFS= read -r dir_path; do
[ -z "$dir_path" ] && continue
dir_path="${dir_path#./}"
Expand All @@ -307,16 +348,16 @@ copy_directories() {
dest_parent=$(dirname "$dest_dir")
mkdir -p "$dest_parent"

# Copy directory (cp -RP preserves symlinks as symlinks)
if cp -RP "$dir_path" "$dest_parent/" 2>/dev/null; then
# Copy directory using CoW when available (preserves symlinks as symlinks)
if _fast_copy_dir "$dir_path" "$dest_parent/"; then
log_info "Copied directory $dir_path"
copied_count=$((copied_count + 1))
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"
else
log_warn "Failed to copy directory $dir_path"
fi
done <<EOF
$(case "$pattern" in */*) find . -type d -path "./$pattern" 2>/dev/null ;; *) find . -type d -name "$pattern" 2>/dev/null ;; esac)
$find_results
EOF
done <<EOF
$dir_patterns
Expand Down
42 changes: 42 additions & 0 deletions tests/copy_safety.bats
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

setup() {
load test_helper
_fast_copy_os=""
source "$PROJECT_ROOT/lib/platform.sh"
source "$PROJECT_ROOT/lib/copy.sh"
}

teardown() {
if [ -n "${_test_tmpdir:-}" ]; then
rm -rf "$_test_tmpdir"
fi
}

# --- _is_unsafe_path tests ---

@test "absolute path is unsafe" {
Expand Down Expand Up @@ -82,3 +90,37 @@ setup() {
excludes=$(printf '%s\n' "*.log" "dist/*")
! is_excluded "src/app.js" "$excludes"
}

# --- _fast_copy_dir tests ---

@test "_fast_copy_dir copies directory contents" {
_test_tmpdir=$(mktemp -d)
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
mkdir -p "$src" "$dst"
mkdir -p "$src/mydir/sub"
echo "hello" > "$src/mydir/sub/file.txt"

_fast_copy_dir "$src/mydir" "$dst/"

[ -f "$dst/mydir/sub/file.txt" ]
[ "$(cat "$dst/mydir/sub/file.txt")" = "hello" ]
}

@test "_fast_copy_dir preserves symlinks" {
_test_tmpdir=$(mktemp -d)
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
mkdir -p "$src" "$dst"
mkdir -p "$src/mydir"
echo "target" > "$src/mydir/real.txt"
ln -s real.txt "$src/mydir/link.txt"

_fast_copy_dir "$src/mydir" "$dst/"

[ -L "$dst/mydir/link.txt" ]
[ "$(readlink "$dst/mydir/link.txt")" = "real.txt" ]
}

@test "_fast_copy_dir fails on nonexistent source" {
_test_tmpdir=$(mktemp -d)
! _fast_copy_dir "/nonexistent/path" "$_test_tmpdir/"
}