Skip to content

Commit 5bc568f

Browse files
authored
perf: use CoW (copy-on-write) cloning for directory copies (#122)
1 parent 57e7167 commit 5bc568f

File tree

3 files changed

+96
-3
lines changed

3 files changed

+96
-3
lines changed

.github/workflows/lint.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ jobs:
2020
run: |
2121
shellcheck bin/gtr bin/git-gtr lib/*.sh lib/commands/*.sh adapters/editor/*.sh adapters/ai/*.sh
2222
23+
completions:
24+
name: Completions
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Verify completion files are up to date
30+
run: ./scripts/generate-completions.sh --check
31+
2332
test:
2433
name: Tests
2534
runs-on: ubuntu-latest

lib/copy.sh

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,41 @@ merge_copy_patterns() {
7575
fi
7676
}
7777

78+
# Copy a directory using CoW (copy-on-write) when available, falling back to standard cp.
79+
# macOS APFS: cp -cRP (clone); Linux Btrfs/XFS: cp --reflink=auto -RP
80+
# Callers must guard the return value with `if` or `|| true` (set -e safe).
81+
# Usage: _fast_copy_dir src dest
82+
# Cached OS value for _fast_copy_dir; set on first call.
83+
_fast_copy_os=""
84+
85+
_fast_copy_dir() {
86+
local src="$1" dest="$2"
87+
if [ -z "$_fast_copy_os" ]; then
88+
_fast_copy_os=$(detect_os)
89+
fi
90+
local os="$_fast_copy_os"
91+
92+
case "$os" in
93+
darwin)
94+
# Try CoW clone first; if unsupported, fall back to regular copy
95+
if cp -cRP "$src" "$dest" 2>/dev/null; then
96+
return 0
97+
fi
98+
# Clean up any partial clone output before fallback
99+
local _clone_target
100+
_clone_target="${dest%/}/$(basename "$src")"
101+
if [ -e "$_clone_target" ]; then rm -rf "$_clone_target"; fi
102+
cp -RP "$src" "$dest"
103+
;;
104+
linux)
105+
cp --reflink=auto -RP "$src" "$dest"
106+
;;
107+
*)
108+
cp -RP "$src" "$dest"
109+
;;
110+
esac
111+
}
112+
78113
# Copy a single file to destination, handling exclusion, path preservation, and dry-run
79114
# Usage: _copy_pattern_file file dst_root excludes preserve_paths dry_run
80115
# Returns: 0 if file was copied (or would be in dry-run), 1 if skipped/failed
@@ -295,6 +330,13 @@ copy_directories() {
295330

296331
# Find directories matching the pattern
297332
# Use -path for patterns with slashes (e.g., vendor/bundle), -name for basenames
333+
# Note: case inside $() inside heredocs breaks Bash 3.2, so compute first
334+
local find_results
335+
case "$pattern" in
336+
*/*) find_results=$(find . -type d -path "./$pattern" 2>/dev/null) ;;
337+
*) find_results=$(find . -type d -name "$pattern" 2>/dev/null) ;;
338+
esac
339+
298340
while IFS= read -r dir_path; do
299341
[ -z "$dir_path" ] && continue
300342
dir_path="${dir_path#./}"
@@ -307,16 +349,16 @@ copy_directories() {
307349
dest_parent=$(dirname "$dest_dir")
308350
mkdir -p "$dest_parent"
309351

310-
# Copy directory (cp -RP preserves symlinks as symlinks)
311-
if cp -RP "$dir_path" "$dest_parent/" 2>/dev/null; then
352+
# Copy directory using CoW when available (preserves symlinks as symlinks)
353+
if _fast_copy_dir "$dir_path" "$dest_parent/"; then
312354
log_info "Copied directory $dir_path"
313355
copied_count=$((copied_count + 1))
314356
_apply_directory_excludes "$dest_parent" "$dir_path" "$excludes"
315357
else
316358
log_warn "Failed to copy directory $dir_path"
317359
fi
318360
done <<EOF
319-
$(case "$pattern" in */*) find . -type d -path "./$pattern" 2>/dev/null ;; *) find . -type d -name "$pattern" 2>/dev/null ;; esac)
361+
$find_results
320362
EOF
321363
done <<EOF
322364
$dir_patterns

tests/copy_safety.bats

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22

33
setup() {
44
load test_helper
5+
_fast_copy_os=""
6+
source "$PROJECT_ROOT/lib/platform.sh"
57
source "$PROJECT_ROOT/lib/copy.sh"
68
}
79

10+
teardown() {
11+
if [ -n "${_test_tmpdir:-}" ]; then
12+
rm -rf "$_test_tmpdir"
13+
fi
14+
}
15+
816
# --- _is_unsafe_path tests ---
917

1018
@test "absolute path is unsafe" {
@@ -82,3 +90,37 @@ setup() {
8290
excludes=$(printf '%s\n' "*.log" "dist/*")
8391
! is_excluded "src/app.js" "$excludes"
8492
}
93+
94+
# --- _fast_copy_dir tests ---
95+
96+
@test "_fast_copy_dir copies directory contents" {
97+
_test_tmpdir=$(mktemp -d)
98+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
99+
mkdir -p "$src" "$dst"
100+
mkdir -p "$src/mydir/sub"
101+
echo "hello" > "$src/mydir/sub/file.txt"
102+
103+
_fast_copy_dir "$src/mydir" "$dst/"
104+
105+
[ -f "$dst/mydir/sub/file.txt" ]
106+
[ "$(cat "$dst/mydir/sub/file.txt")" = "hello" ]
107+
}
108+
109+
@test "_fast_copy_dir preserves symlinks" {
110+
_test_tmpdir=$(mktemp -d)
111+
local src="$_test_tmpdir/src" dst="$_test_tmpdir/dst"
112+
mkdir -p "$src" "$dst"
113+
mkdir -p "$src/mydir"
114+
echo "target" > "$src/mydir/real.txt"
115+
ln -s real.txt "$src/mydir/link.txt"
116+
117+
_fast_copy_dir "$src/mydir" "$dst/"
118+
119+
[ -L "$dst/mydir/link.txt" ]
120+
[ "$(readlink "$dst/mydir/link.txt")" = "real.txt" ]
121+
}
122+
123+
@test "_fast_copy_dir fails on nonexistent source" {
124+
_test_tmpdir=$(mktemp -d)
125+
! _fast_copy_dir "/nonexistent/path" "$_test_tmpdir/"
126+
}

0 commit comments

Comments
 (0)