Skip to content

Commit 06c2301

Browse files
committed
feat: add CI workflow for linting and testing
Introduce a GitHub Actions workflow to automate linting with ShellCheck and testing with BATS. The workflow triggers on pushes and pull requests to the main branch, ensuring code quality and functionality through automated checks. Additionally, refactor the copy function in lib/copy.sh to improve maintainability by extracting the file copying logic into a dedicated helper function.
1 parent 9cd81a4 commit 06c2301

10 files changed

Lines changed: 310 additions & 70 deletions

File tree

.github/workflows/lint.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
shellcheck:
11+
name: ShellCheck
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Install ShellCheck
17+
run: sudo apt-get install -y shellcheck
18+
19+
- name: Run ShellCheck
20+
run: |
21+
shellcheck bin/gtr bin/git-gtr lib/*.sh adapters/editor/*.sh adapters/ai/*.sh
22+
23+
test:
24+
name: Tests
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Install BATS
30+
run: sudo apt-get install -y bats
31+
32+
- name: Run tests
33+
run: bats tests/

bin/gtr

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# gtr - Git worktree runner
33
# Portable, cross-platform git worktree management
44

5+
# shellcheck disable=SC2329 # Functions defined inside adapter builders are invoked indirectly
6+
57
set -e
68

79
# Version
@@ -1048,7 +1050,9 @@ cmd_list() {
10481050
# Output: path<tab>branch<tab>status
10491051
local branch status
10501052
branch=$(get_current_branch "$repo_root")
1051-
[ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)"
1053+
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
1054+
branch="(detached)"
1055+
fi
10521056
status=$(worktree_status "$repo_root")
10531057
printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status"
10541058

@@ -1077,7 +1081,9 @@ cmd_list() {
10771081
# Always show repo root first
10781082
local branch
10791083
branch=$(get_current_branch "$repo_root")
1080-
[ -z "$branch" ] || [ "$branch" = "HEAD" ] && branch="(detached)"
1084+
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
1085+
branch="(detached)"
1086+
fi
10811087
printf "%-30s %s\n" "$branch [main repo]" "$repo_root"
10821088

10831089
# Show worktrees sorted by branch name
@@ -1329,6 +1335,7 @@ cmd_doctor() {
13291335
# Check if adapter exists
13301336
local editor_adapter="$GTR_DIR/adapters/editor/${editor}.sh"
13311337
if [ -f "$editor_adapter" ]; then
1338+
# shellcheck disable=SC1090
13321339
. "$editor_adapter"
13331340
if editor_can_open 2>/dev/null; then
13341341
echo "[OK] Editor: $editor (found)"
@@ -1349,6 +1356,7 @@ cmd_doctor() {
13491356
# Check if adapter exists
13501357
local adapter_file="$GTR_DIR/adapters/ai/${ai_tool}.sh"
13511358
if [ -f "$adapter_file" ]; then
1359+
# shellcheck disable=SC1090
13521360
. "$adapter_file"
13531361
if ai_can_start 2>/dev/null; then
13541362
echo "[OK] AI tool: $ai_tool (found)"
@@ -1419,6 +1427,7 @@ cmd_adapter() {
14191427
if [ -f "$adapter_file" ]; then
14201428
local adapter_name
14211429
adapter_name=$(basename "$adapter_file" .sh)
1430+
# shellcheck disable=SC1090
14221431
. "$adapter_file"
14231432

14241433
if editor_can_open 2>/dev/null; then
@@ -1440,6 +1449,7 @@ cmd_adapter() {
14401449
if [ -f "$adapter_file" ]; then
14411450
local adapter_name
14421451
adapter_name=$(basename "$adapter_file" .sh)
1452+
# shellcheck disable=SC1090
14431453
. "$adapter_file"
14441454

14451455
if ai_can_start 2>/dev/null; then
@@ -1896,6 +1906,7 @@ _load_adapter() {
18961906

18971907
# Try loading explicit adapter first (allows special handling)
18981908
if [ -f "$adapter_file" ]; then
1909+
# shellcheck disable=SC1090
18991910
. "$adapter_file"
19001911
return 0
19011912
fi

lib/copy.sh

Lines changed: 44 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,48 @@ parse_pattern_file() {
4848
grep -v '^#' "$file_path" 2>/dev/null | grep -v '^[[:space:]]*$' || true
4949
}
5050

51+
# Copy a single file to destination, handling exclusion, path preservation, and dry-run
52+
# Usage: _copy_pattern_file file dst_root excludes preserve_paths dry_run
53+
# Returns: 0 if file was copied (or would be in dry-run), 1 if skipped/failed
54+
_copy_pattern_file() {
55+
local file="$1"
56+
local dst_root="$2"
57+
local excludes="$3"
58+
local preserve_paths="$4"
59+
local dry_run="$5"
60+
61+
# Remove leading ./
62+
file="${file#./}"
63+
64+
# Skip if excluded
65+
is_excluded "$file" "$excludes" && return 1
66+
67+
# Determine destination path
68+
local dest_file
69+
if [ "$preserve_paths" = "true" ]; then
70+
dest_file="$dst_root/$file"
71+
else
72+
dest_file="$dst_root/$(basename "$file")"
73+
fi
74+
75+
# Copy the file (or show what would be copied in dry-run mode)
76+
if [ "$dry_run" = "true" ]; then
77+
log_info "[dry-run] Would copy: $file"
78+
return 0
79+
fi
80+
81+
local dest_dir
82+
dest_dir=$(dirname "$dest_file")
83+
mkdir -p "$dest_dir"
84+
if cp "$file" "$dest_file" 2>/dev/null; then
85+
log_info "Copied $file"
86+
return 0
87+
else
88+
log_warn "Failed to copy $file"
89+
return 1
90+
fi
91+
}
92+
5193
# Copy files matching patterns from source to destination
5294
# Usage: copy_patterns src_root dst_root includes excludes [preserve_paths] [dry_run]
5395
# includes: newline-separated glob patterns to include
@@ -102,36 +144,8 @@ copy_patterns() {
102144
if [ "$have_globstar" -eq 0 ] && echo "$pattern" | grep -q '\*\*'; then
103145
# Fallback to find for ** patterns on Bash 3.2
104146
while IFS= read -r file; do
105-
# Remove leading ./
106-
file="${file#./}"
107-
108-
# Skip if excluded
109-
is_excluded "$file" "$excludes" && continue
110-
111-
# Determine destination path
112-
local dest_file
113-
if [ "$preserve_paths" = "true" ]; then
114-
dest_file="$dst_root/$file"
115-
else
116-
dest_file="$dst_root/$(basename "$file")"
117-
fi
118-
119-
# Create destination directory (skip in dry-run mode)
120-
local dest_dir
121-
dest_dir=$(dirname "$dest_file")
122-
123-
# Copy the file (or show what would be copied in dry-run mode)
124-
if [ "$dry_run" = "true" ]; then
125-
log_info "[dry-run] Would copy: $file"
147+
if _copy_pattern_file "$file" "$dst_root" "$excludes" "$preserve_paths" "$dry_run"; then
126148
copied_count=$((copied_count + 1))
127-
else
128-
mkdir -p "$dest_dir"
129-
if cp "$file" "$dest_file" 2>/dev/null; then
130-
log_info "Copied $file"
131-
copied_count=$((copied_count + 1))
132-
else
133-
log_warn "Failed to copy $file"
134-
fi
135149
fi
136150
done <<EOF
137151
$(find . -path "./$pattern" -type f 2>/dev/null)
@@ -142,36 +156,8 @@ EOF
142156
# Skip if not a file
143157
[ -f "$file" ] || continue
144158

145-
# Remove leading ./
146-
file="${file#./}"
147-
148-
# Skip if excluded
149-
is_excluded "$file" "$excludes" && continue
150-
151-
# Determine destination path
152-
local dest_file
153-
if [ "$preserve_paths" = "true" ]; then
154-
dest_file="$dst_root/$file"
155-
else
156-
dest_file="$dst_root/$(basename "$file")"
157-
fi
158-
159-
# Create destination directory (skip in dry-run mode)
160-
local dest_dir
161-
dest_dir=$(dirname "$dest_file")
162-
163-
# Copy the file (or show what would be copied in dry-run mode)
164-
if [ "$dry_run" = "true" ]; then
165-
log_info "[dry-run] Would copy: $file"
159+
if _copy_pattern_file "$file" "$dst_root" "$excludes" "$preserve_paths" "$dry_run"; then
166160
copied_count=$((copied_count + 1))
167-
else
168-
mkdir -p "$dest_dir"
169-
if cp "$file" "$dest_file" 2>/dev/null; then
170-
log_info "Copied $file"
171-
copied_count=$((copied_count + 1))
172-
else
173-
log_warn "Failed to copy $file"
174-
fi
175161
fi
176162
done
177163
fi

lib/core.sh

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ resolve_base_dir() {
5454
# Default: <repo>-worktrees next to the repo
5555
base_dir="$(dirname "$repo_root")/${repo_name}-worktrees"
5656
else
57-
# Expand tilde to home directory
57+
# Expand literal tilde to home directory
58+
# Patterns must quote ~ to prevent bash tilde expansion in case arms
59+
# shellcheck disable=SC2088
5860
case "$base_dir" in
59-
~/*) base_dir="$HOME/${base_dir#~/}" ;;
60-
~) base_dir="$HOME" ;;
61+
"~/"*) base_dir="$HOME/${base_dir#"~/"}" ;;
62+
"~") base_dir="$HOME" ;;
6163
esac
6264

6365
# Check if absolute or relative
@@ -89,7 +91,7 @@ resolve_base_dir() {
8991

9092
# Warn if worktree dir is inside repo (but not a sibling)
9193
if [[ "$base_dir" == "$canonical_repo_root"/* ]]; then
92-
local rel_path="${base_dir#$canonical_repo_root/}"
94+
local rel_path="${base_dir#"$canonical_repo_root"/}"
9395
# Check if .gitignore exists and whether it includes the worktree directory
9496
if [ -f "$canonical_repo_root/.gitignore" ]; then
9597
if ! grep -qE "^/?${rel_path}/?\$|^/?${rel_path}/\*?\$" "$canonical_repo_root/.gitignore" 2>/dev/null; then
@@ -234,7 +236,7 @@ resolve_target() {
234236
local repo_root="$2"
235237
local base_dir="$3"
236238
local prefix="$4"
237-
local id path branch sanitized_name
239+
local path branch sanitized_name
238240

239241
# Special case: ID 1 is always the repo root
240242
if [ "$identifier" = "1" ]; then

lib/hooks.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ run_hooks() {
3636
if (
3737
# Export each KEY=VALUE exactly as passed, safely quoted
3838
for kv in "${envs[@]}"; do
39+
# shellcheck disable=SC2163
3940
export "$kv"
4041
done
4142
# Execute the hook

lib/platform.sh

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ open_in_gui() {
6868
esac
6969
}
7070

71+
# Escape a string for safe interpolation into AppleScript double-quoted strings
72+
# Handles backslashes and double quotes that would break AppleScript syntax
73+
_escape_applescript() {
74+
local s="$1"
75+
s="${s//\\/\\\\}"
76+
s="${s//\"/\\\"}"
77+
printf '%s' "$s"
78+
}
79+
7180
# Spawn a new terminal window/tab in a directory
7281
# Usage: spawn_terminal_in path title [command]
7382
# Note: Best-effort implementation, may not work on all systems
@@ -81,25 +90,31 @@ spawn_terminal_in() {
8190

8291
case "$os" in
8392
darwin)
93+
# Escape variables for AppleScript string interpolation
94+
local safe_path safe_title safe_cmd
95+
safe_path=$(_escape_applescript "$path")
96+
safe_title=$(_escape_applescript "$title")
97+
safe_cmd=$(_escape_applescript "$cmd")
98+
8499
# Try iTerm2 first, then Terminal.app
85100
if osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null | grep -q "iTerm"; then
86101
osascript <<-EOF 2>/dev/null || true
87102
tell application "iTerm"
88103
tell current window
89104
create tab with default profile
90105
tell current session
91-
write text "cd \"$path\""
92-
set name to "$title"
93-
$([ -n "$cmd" ] && echo "write text \"$cmd\"")
106+
write text "cd \"$safe_path\""
107+
set name to "$safe_title"
108+
$([ -n "$safe_cmd" ] && echo "write text \"$safe_cmd\"")
94109
end tell
95110
end tell
96111
end tell
97112
EOF
98113
else
99114
osascript <<-EOF 2>/dev/null || true
100115
tell application "Terminal"
101-
do script "cd \"$path\"; $cmd"
102-
set custom title of front window to "$title"
116+
do script "cd \"$safe_path\"; $safe_cmd"
117+
set custom title of front window to "$safe_title"
103118
end tell
104119
EOF
105120
fi

0 commit comments

Comments
 (0)