Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,27 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html):

## [Unreleased]

---

## [1.0.1] — 2026-06-05

### Fixed
- `wrktr_use`: removed the legacy `source "$config"` path; old-format configs with `export ` prefixes are now parsed safely by the KEY=value parser (strips the prefix, never executes the file as shell code)
- `wrktr_remove`: PWD check now resolves both the current directory and the target path with `pwd -P`, preventing bypass via symlinks or systems where `$TMPDIR` itself is a symlink (e.g. `/tmp` → `/private/tmp` on macOS)
- `wrktr_clone`: initial worktree directory now uses the detected main branch name instead of the hardcoded string `"main"`
- `_wrktr_remote_branches`: replaced `sed` with shell parameter expansion to avoid metacharacter injection when remote names contain special characters; `wrktr_checkout` listing uses `grep -F` for the same reason
- `wrktr_unload`: now unsets `WRKTR_VERSION` along with all other `WRKTR_*` variables
- `wrktr_clone`, `wrktr_adopt`: print a message before any `rm -rf` cleanup so the operation is visible
- `install.sh`: validate `SCRIPT_DIR` immediately after assignment and exit with an error if it could not be resolved
- `wrktr_init`, `wrktr_generate`, `wrktr_adopt`: fail early with a clear error when stdin is not a terminal, preventing hangs in non-interactive environments (CI, piped scripts)

### Removed
- Homebrew formula (`Formula/wrktr.rb`) — distribution via a personal tap is not planned

### Added
- README: explicit platform note that wrktr requires macOS or Linux (Windows needs WSL)
- Tests: coverage for old-format config loading, symlink-based worktree removal guard, `wrktr_unload` variable cleanup, and non-interactive stdin guards

---

## [1.0.0] — 2024
Expand Down Expand Up @@ -79,5 +97,6 @@ Initial release.

---

[Unreleased]: https://github.com/your-username/wrktr/compare/v1.0.0...HEAD
[Unreleased]: https://github.com/your-username/wrktr/compare/v1.0.1...HEAD
[1.0.1]: https://github.com/your-username/wrktr/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/your-username/wrktr/releases/tag/v1.0.0
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

## Requirements

- macOS or Linux (Windows requires WSL)
- bash 3.2 or later (the macOS default is sufficient)
- git 2.7 or later (git 2.36+ required for `wrktr_init`)
- rsync (required only by `wrktr_init`)
Expand Down
4 changes: 4 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ INSTALL_FILE="$INSTALL_DIR/worktree-functions.sh"
MAN_DIR="$HOME/.local/share/man/man1"
MAN_FILE="$MAN_DIR/wrktr.1"
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)"
if [ -z "$SCRIPT_DIR" ]; then
printf 'Error: cannot resolve script directory\n' >&2
exit 1
fi
SOURCE_FILE="$SCRIPT_DIR/worktree-functions.sh"
SOURCE_MAN="$SCRIPT_DIR/docs/wrktr.1"

Expand Down
103 changes: 103 additions & 0 deletions tests/wrktr.bats
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ teardown() {
rm -rf "$BATS_TMPDIR/wrktr-trunk-$$-"*
rm -rf "$BATS_TMPDIR/wrktr-parent-$$"
rm -rf "$BATS_TMPDIR/wrktr-outside-$$"
rm -f "$BATS_TMPDIR/wrktr-symlink-$$"
rm -f "$BATS_TMPDIR/wrktr-sentinel-$$"
}

# ===========================================================================
Expand Down Expand Up @@ -285,6 +287,40 @@ EOF
[ "$status" -eq 1 ]
}

@test "wrktr_use: loads old export-format config safely" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-$RANDOM"
_make_bare_repo "$trunk"

cat > "$WRKTR_CONFIG_DIR/testproject.env" <<EOF
export WRKTR_NAME=testproject
export WRKTR_BASE_TRUNK=$trunk
export WRKTR_BASE_DIR=$trunk/.wrktr
export WRKTR_REMOTE=
export WRKTR_MAIN_BRANCH=main
EOF

run wrktr_use "testproject"
[ "$status" -eq 0 ]
}

@test "wrktr_use: old-format config does not execute embedded shell code" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-$RANDOM"
_make_bare_repo "$trunk"
local sentinel="$BATS_TMPDIR/wrktr-sentinel-$$"

cat > "$WRKTR_CONFIG_DIR/testproject.env" <<EOF
export WRKTR_NAME=testproject
export WRKTR_BASE_TRUNK=$trunk
export WRKTR_BASE_DIR=$trunk/.wrktr
export WRKTR_REMOTE=
export WRKTR_MAIN_BRANCH=main
touch $sentinel
EOF

run wrktr_use "testproject"
[ ! -f "$sentinel" ]
}

# ===========================================================================
# wrktr_generate (dry-run output)
# ===========================================================================
Expand Down Expand Up @@ -324,6 +360,73 @@ EOF
[ "$status" -eq 1 ]
}

@test "wrktr_remove: refuses to remove when inside worktree via symlink" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-$RANDOM"
_make_bare_repo "$trunk"

local target="$trunk/feature%2Ftest"
mkdir -p "$target"

local symlink="$BATS_TMPDIR/wrktr-symlink-$$"
ln -s "$target" "$symlink"

cd "$symlink"
run wrktr_remove "feature/test"
[ "$status" -eq 1 ]
[[ "$output" =~ "Cannot remove worktree while inside it" ]]
}

# ===========================================================================
# wrktr_unload
# ===========================================================================

@test "wrktr_unload: removes wrktr functions from the shell" {
run bash -c "source \"$WRKTR_FUNCTIONS\" && wrktr_unload && type wrktr_validate 2>&1"
[[ "$output" =~ "not found" ]]
}

@test "wrktr_unload: unsets WRKTR_VERSION" {
run bash -c "source \"$WRKTR_FUNCTIONS\" && wrktr_unload && echo \"\${WRKTR_VERSION:-unset}\""
[[ "$output" =~ "unset" ]]
}

@test "wrktr_unload: unsets session variables" {
run bash -c "source \"$WRKTR_FUNCTIONS\" && export WRKTR_NAME=test && wrktr_unload && echo \"\${WRKTR_NAME:-unset}\""
[[ "$output" =~ "unset" ]]
}

# ===========================================================================
# Non-interactive guards
# ===========================================================================

@test "wrktr_init: fails when stdin is not a terminal" {
run wrktr_init </dev/null
[ "$status" -eq 1 ]
[[ "$output" =~ "requires an interactive terminal" ]]
}

@test "wrktr_generate: fails when stdin is not a terminal and no session arg" {
run wrktr_generate </dev/null
[ "$status" -eq 1 ]
[[ "$output" =~ "requires an interactive terminal" ]]
}

@test "wrktr_generate: succeeds with session arg even when stdin is not a terminal" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-$RANDOM"
_make_bare_repo "$trunk"
printf 'WRKTR_NAME=testproject\nWRKTR_BASE_TRUNK=%s\nWRKTR_BASE_DIR=%s/.wrktr\nWRKTR_REMOTE=\nWRKTR_MAIN_BRANCH=main\n' \
"$trunk" "$trunk" > "$WRKTR_CONFIG_DIR/testproject.env"
# Passing a session name skips the interactive prompts; should not block on stdin
run bash -c "source \"$WRKTR_FUNCTIONS\" && WRKTR_CONFIG_DIR=\"$WRKTR_CONFIG_DIR\" wrktr_generate --help </dev/null"
[ "$status" -eq 0 ]
}

@test "wrktr_adopt: fails when stdin is not a terminal" {
run wrktr_adopt </dev/null
[ "$status" -eq 1 ]
[[ "$output" =~ "requires an interactive terminal" ]]
}

# ===========================================================================
# Version
# ===========================================================================
Expand Down
70 changes: 48 additions & 22 deletions worktree-functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
#
# =============================================================================

export WRKTR_VERSION="1.0.0"
export WRKTR_VERSION="1.0.1"
export WRKTR_CONFIG_DIR="$HOME/.config/wrktr"
export WRKTR_DRY_RUN=0
export WRKTR_REPO_DIR_NAME="${WRKTR_REPO_DIR_NAME:-.wrktr}"
Expand Down Expand Up @@ -1511,24 +1511,23 @@ function wrktr_use() {
fi

if grep -q '^export WRKTR_' "$config" 2>/dev/null; then
printf 'wrktr: session "%s" uses the old shell-script config format.\n' "$session" >&2
printf 'To migrate: wrktr_config_edit — remove the "export " prefix from each line.\n\n' >&2
# shellcheck disable=SC1090
source "$config"
else
local _line _key _value
while IFS= read -r _line; do
case "$_line" in
''|'#'*) continue ;;
esac
_key="${_line%%=*}"
_value="${_line#*=}"
case "$_key" in
WRKTR_*) export "$_key"="$_value" ;;
esac
done < "$config"
printf 'wrktr: session "%s" uses the old config format (has "export " prefix).\n' "$session" >&2
printf 'To migrate: wrktr_config_edit — remove "export " from each line.\n\n' >&2
fi

local _line _key _value
while IFS= read -r _line; do
case "$_line" in
''|'#'*) continue ;;
esac
_key="${_line%%=*}"
_key="${_key#export }"
_value="${_line#*=}"
case "$_key" in
WRKTR_*) export "$_key"="$_value" ;;
esac
done < "$config"

if ! wrktr_validate; then
_wrktr_breaker red "wrktr session validation failed: $session"
printf '\nPossible causes:\n'
Expand Down Expand Up @@ -1575,6 +1574,11 @@ function wrktr_generate() {
[ "$1" = "--help" ] && { wrktr_help generate; return 0; }
local session="$1"

if [ -z "$session" ] && [ ! -t 0 ]; then
_wrktr_breaker red "wrktr_generate requires an interactive terminal when no session name is given"
return 1
fi

if [ -z "$session" ]; then
# read -r -p "Session name: " session
printf "Session name: "
Expand Down Expand Up @@ -1728,6 +1732,10 @@ function wrktr_generate() {
# -----------------------------------------------------------------------------
function wrktr_init() {
[ "$1" = "--help" ] && { wrktr_help init; return 0; }
if [ ! -t 0 ]; then
_wrktr_breaker red "wrktr_init requires an interactive terminal"
return 1
fi
if ! command -v rsync >/dev/null 2>&1; then
_wrktr_breaker red "rsync is required by wrktr_init but was not found"
return 1
Expand Down Expand Up @@ -1999,6 +2007,7 @@ function wrktr_clone() {

if ! git clone --bare "$url" "$repo_dir"; then
_wrktr_breaker red "Clone failed"
_wrktr_breaker "Cleaning up: $abs_destination"
rm -rf "$abs_destination"
return 1
fi
Expand Down Expand Up @@ -2035,6 +2044,8 @@ function wrktr_clone() {
return 0
fi

worktree_dir="$abs_destination/$(_wrktr_sanitize_branch_name "$main_branch")"

_wrktr_breaker "Detected main branch: $main_branch"
_wrktr_breaker "Creating main worktree: $worktree_dir"

Expand Down Expand Up @@ -2092,6 +2103,10 @@ function wrktr_clone() {
# -----------------------------------------------------------------------------
function wrktr_adopt() {
[ "$1" = "--help" ] && { wrktr_help adopt; return 0; }
if [ ! -t 0 ]; then
_wrktr_breaker red "wrktr_adopt requires an interactive terminal"
return 1
fi
local existing_path="${1:-}"

if [ -z "$existing_path" ]; then
Expand Down Expand Up @@ -2198,6 +2213,7 @@ function wrktr_adopt() {
_wrktr_breaker "Creating bare clone..."
if ! git clone --bare "$abs_existing" "$repo_dir"; then
_wrktr_breaker red "Failed to create bare clone"
_wrktr_breaker "Cleaning up: $abs_trunk"
rm -rf "$abs_trunk"
return 1
fi
Expand All @@ -2215,6 +2231,7 @@ function wrktr_adopt() {
_wrktr_breaker "Creating main worktree: $worktree_dir"
if ! git --git-dir="$repo_dir" worktree add "$worktree_dir" "$main_branch"; then
_wrktr_breaker red "Failed to create main worktree"
_wrktr_breaker "Cleaning up: $abs_trunk"
rm -rf "$abs_trunk"
return 1
fi
Expand Down Expand Up @@ -2749,9 +2766,10 @@ function wrktr_checkout() {
if ! git --git-dir="$WRKTR_BASE_DIR" rev-parse --verify "$remote_ref" >/dev/null 2>&1; then
_wrktr_breaker red "Remote branch not found: $remote_ref"
printf '\nAvailable remote branches:\n'
local _remote="$WRKTR_REMOTE"
git --git-dir="$WRKTR_BASE_DIR" branch -r 2>/dev/null \
| grep "^ $WRKTR_REMOTE/" \
| sed "s| $WRKTR_REMOTE/||" \
| grep -F " $_remote/" \
| while IFS= read -r _ref; do printf '%s\n' "${_ref#" $_remote/"}"; done \
| head -20
printf '\n'
return 1
Expand Down Expand Up @@ -2831,8 +2849,11 @@ function wrktr_remove() {

local target="$WRKTR_BASE_TRUNK/$safe_branch"

case "$PWD" in
"$target"/*|"$target")
local resolved_pwd resolved_target
resolved_pwd="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
resolved_target="$(cd "$target" 2>/dev/null && pwd -P 2>/dev/null || printf '%s' "$target")"
case "$resolved_pwd" in
"$resolved_target"/*|"$resolved_target")
_wrktr_breaker red "Cannot remove worktree while inside it. Please cd out first."
return 1
;;
Expand Down Expand Up @@ -2903,6 +2924,9 @@ function wrktr_remove() {
# Use this when you want to reload a modified version of the file, cleanly
# remove wrktr from a session, or test changes from scratch.
#
# Note: removes all functions matching ^_?wrktr (i.e. wrktr_* and _wrktr_*).
# If you have your own functions with a wrktr_ or _wrktr_ prefix they will
# also be removed.
#
# Exit codes:
# 0 — always
Expand All @@ -2918,6 +2942,7 @@ function wrktr_unload() {
unset -f "$f"
done < <(compgen -A function | grep -E '^_?wrktr')

unset WRKTR_VERSION
unset WRKTR_NAME
unset WRKTR_BASE_TRUNK
unset WRKTR_BASE_DIR
Expand Down Expand Up @@ -2996,8 +3021,9 @@ function _wrktr_local_branches() {

function _wrktr_remote_branches() {
[ -z "$WRKTR_BASE_DIR" ] || [ -z "$WRKTR_REMOTE" ] && return
local _remote="$WRKTR_REMOTE"
git --git-dir="$WRKTR_BASE_DIR" branch -r --format='%(refname:short)' 2>/dev/null \
| sed "s|^${WRKTR_REMOTE}/||"
| while IFS= read -r _ref; do printf '%s\n' "${_ref#"$_remote/"}"; done
}

function _wrktr_worktree_branches() {
Expand Down
Loading