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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'skip-changelog')
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

Expand All @@ -37,7 +37,7 @@ jobs:
name: Lint (shellcheck)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install shellcheck
run: sudo apt-get install -y shellcheck
Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:
bash: "5.x"

steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install bats-core and zsh (Ubuntu)
if: runner.os == 'Linux'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Extract version from tag
id: version
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html):
## [Unreleased]

### Added
- Tests: the real `wrktr_remove` path and its branch-delete and force-delete prompts, driven through a pty; `wrktr_init` HEAD and branch-name cases; `wrktr_use` session restore
- Tests: command-level suite in `tests/commands.bats` covering `wrktr_clone`, `add`, `go`, `base`, `status`, `prompt_info`, `update`, `checkout`, `remote_add`, `rebase`, `push`, `remove` (dry-run), `git`, `list`, `current`, `config_show` and dry-run toggling; version-consistency tests (script, CHANGELOG, man page)
- Tests: `wrktr_init` dry-run and stale-temp-dir cases (run in a pty via `tests/helpers/run_in_pty.py`), and zsh source/unload/reload cases

### Changed
- CI: `actions/checkout` bumped to v7.0.1 (pinned by SHA), which runs on Node 24; the previous pin ran on the deprecated Node 20
- `install.sh` no longer edits your shell profile without consent: it asks first when run in a terminal, and leaves the profile untouched (printing the line to add) when it is not. New flags: `--yes` adds the line without asking, `--no-profile` never touches the profile. `update.sh` passes flags through.
- `uninstall.sh` asks before removing the source line from each profile, and lists the profiles without changing them when not run in a terminal. `--yes` removes the line without asking.
- Sourcing `worktree-functions.sh` is now silent; set `WRKTR_VERBOSE=1` to print the "Worktree functions loaded" banner. Sourcing also returns 0 instead of the exit status of the last command.
Expand All @@ -29,6 +31,8 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html):
- Release workflow: fails if the tag does not match `WRKTR_VERSION` and a CHANGELOG entry

### Fixed
- `wrktr_use`: when the session being loaded fails validation, the previously loaded session is restored instead of being lost
- `wrktr_init`: the bare repository's `HEAD` now points at the chosen main branch instead of an unborn `master`, so plain git commands in the bare repo work; an invalid branch name is now rejected before anything is created
- `uninstall.sh`: rewrites profiles in place, so a symlinked profile stays a symlink and keeps its permissions, and no longer aborts when the source line was the only line
- `wrktr_add`, `wrktr_checkout`: a failed fetch now falls back to the refs fetched previously instead of aborting, so they work offline when the base ref already exists locally
- `wrktr_status`: detached worktrees are compared from their own HEAD rather than the bare repository's
Expand Down
62 changes: 62 additions & 0 deletions tests/commands.bats
Original file line number Diff line number Diff line change
Expand Up @@ -696,3 +696,65 @@ _make_existing_clone() {
_pty "trap 'true' RETURN; wrktr_init >/dev/null; trap -p RETURN" "$T/x"$'\n\n\ny\n'
[[ "$output" =~ "trap -- 'true' RETURN" ]]
}

# ---------------------------------------------------------------------------
# wrktr_remove: the real path and its prompts (pty)
# ---------------------------------------------------------------------------

# Create a worktree (and optionally an unmerged commit on it) without leaving
# the test shell's directory.
_make_worktree() {
( wrktr_add "$1" >/dev/null 2>&1 )
[ -d "$TRUNK/$(_wrktr_sanitize_branch_name "$1")" ]
}

@test "wrktr_remove: removes the worktree and keeps the branch on 'n'" {
_start_session
_make_worktree feature/r1
_pty "cd \"$TRUNK\" && wrktr_remove feature/r1" $'n\n'
[ "$status" -eq 0 ]
[ ! -d "$TRUNK/feature%2Fr1" ]
[[ "$output" =~ "Branch kept" ]]
git --git-dir="$TRUNK/.wrktr" show-ref --verify --quiet refs/heads/feature/r1
}

@test "wrktr_remove: deletes a merged branch on 'y'" {
_start_session
_make_worktree feature/r2
_pty "cd \"$TRUNK\" && wrktr_remove feature/r2" $'y\n'
[ "$status" -eq 0 ]
[ ! -d "$TRUNK/feature%2Fr2" ]
[[ "$output" =~ "Branch feature/r2 deleted" ]]
! git --git-dir="$TRUNK/.wrktr" show-ref --verify --quiet refs/heads/feature/r2
}

@test "wrktr_remove: keeps an unmerged branch when the force-delete is declined" {
_start_session
_make_worktree feature/r3
git -C "$TRUNK/feature%2Fr3" commit --allow-empty -qm "unmerged work"
_pty "cd \"$TRUNK\" && wrktr_remove feature/r3" $'y\nn\n'
[ "$status" -eq 0 ]
[ ! -d "$TRUNK/feature%2Fr3" ]
[[ "$output" =~ "unmerged changes" ]]
git --git-dir="$TRUNK/.wrktr" show-ref --verify --quiet refs/heads/feature/r3
}

@test "wrktr_remove: force-deletes an unmerged branch only after a second 'y'" {
_start_session
_make_worktree feature/r4
git -C "$TRUNK/feature%2Fr4" commit --allow-empty -qm "unmerged work"
_pty "cd \"$TRUNK\" && wrktr_remove feature/r4" $'y\ny\n'
[ "$status" -eq 0 ]
[[ "$output" =~ "force-deleted" ]]
! git --git-dir="$TRUNK/.wrktr" show-ref --verify --quiet refs/heads/feature/r4
}

@test "wrktr_remove: refuses a worktree with uncommitted files and leaves it in place" {
_start_session
_make_worktree feature/r5
echo dirty > "$TRUNK/feature%2Fr5/untracked"
_pty "cd \"$TRUNK\" && wrktr_remove feature/r5" $'n\n'
[ "$status" -eq 1 ]
[[ "$output" =~ "Worktree remove failed" ]]
[ -f "$TRUNK/feature%2Fr5/untracked" ]
}
56 changes: 56 additions & 0 deletions tests/wrktr.bats
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ setup() {

# Clear any session state left from a previous test
unset WRKTR_NAME WRKTR_BASE_TRUNK WRKTR_BASE_DIR WRKTR_REMOTE WRKTR_MAIN_BRANCH

# Tests that make commits must not depend on the machine's git identity
# (CI runners and fresh containers have none).
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com
export GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com
}

teardown() {
Expand Down Expand Up @@ -537,6 +542,46 @@ _run_init_in_pty() {
[ ! -e "$root/.wrktr" ]
}

@test "wrktr_init: points the bare repo HEAD at the default main branch" {
command -v python3 >/dev/null 2>&1 || skip "python3 not available"
local root="$BATS_TMPDIR/wrktr-trunk-$$-init-head/x"
mkdir -p "$root/main"
echo f > "$root/main/f"
run python3 "$BATS_TEST_DIRNAME/helpers/run_in_pty.py" \
"source \"$WRKTR_FUNCTIONS\" >/dev/null; wrktr_init >/dev/null" \
"$root"$'\n\n\ny\nn\nn\n'
[ "$status" -eq 0 ]
[ "$(git --git-dir="$root/.wrktr" symbolic-ref HEAD)" = "refs/heads/main" ]
git --git-dir="$root/.wrktr" rev-parse --verify --quiet HEAD >/dev/null
}

@test "wrktr_init: points the bare repo HEAD at a custom main branch" {
command -v python3 >/dev/null 2>&1 || skip "python3 not available"
local root="$BATS_TMPDIR/wrktr-trunk-$$-init-head-custom/x"
mkdir -p "$root/main"
echo f > "$root/main/f"
run python3 "$BATS_TEST_DIRNAME/helpers/run_in_pty.py" \
"source \"$WRKTR_FUNCTIONS\" >/dev/null; wrktr_init >/dev/null" \
"$root"$'\n\ntrunk\ny\nn\nn\n'
[ "$status" -eq 0 ]
[ "$(git --git-dir="$root/.wrktr" symbolic-ref HEAD)" = "refs/heads/trunk" ]
git --git-dir="$root/.wrktr" rev-parse --verify --quiet HEAD >/dev/null
}

@test "wrktr_init: rejects an invalid branch name before creating anything" {
command -v python3 >/dev/null 2>&1 || skip "python3 not available"
local root="$BATS_TMPDIR/wrktr-trunk-$$-init-badbranch/x"
mkdir -p "$root/main"
echo f > "$root/main/f"
run python3 "$BATS_TEST_DIRNAME/helpers/run_in_pty.py" \
"source \"$WRKTR_FUNCTIONS\" >/dev/null; wrktr_init" \
"$root"$'\n\nbad..name\nn\nn\nn\n'
[ "$status" -eq 1 ]
[[ "$output" =~ "Invalid branch name" ]]
[ ! -e "$root/.wrktr" ]
[ -f "$root/main/f" ]
}

# ===========================================================================
# Sourcing under zsh
# ===========================================================================
Expand Down Expand Up @@ -651,6 +696,17 @@ _make_session_config() {
[[ "$output" == *"rc=0 remote=[]" ]]
}

@test "wrktr_use: a failing load keeps the previously loaded session" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-use-keep"
mkdir -p "$trunk"
_make_bare_repo "$trunk"
# A config that exists but fails validation (its trunk directory is missing)
printf 'WRKTR_NAME=broken\nWRKTR_BASE_TRUNK=%s/gone\nWRKTR_BASE_DIR=%s/gone/.wrktr\nWRKTR_REMOTE=\nWRKTR_MAIN_BRANCH=main\n' \
"$trunk" "$trunk" > "$WRKTR_CONFIG_DIR/broken.env"
run bash -c "source \"$WRKTR_FUNCTIONS\" >/dev/null; export WRKTR_CONFIG_DIR=\"$WRKTR_CONFIG_DIR\"; wrktr_use broken >/dev/null 2>&1; echo \"rc=\$? name=[\$WRKTR_NAME] trunk=[\$WRKTR_BASE_TRUNK]\""
[[ "$output" == *"rc=1 name=[testproject] trunk=[$trunk]" ]]
}

@test "wrktr_use: ignores WRKTR_ keys that are not session settings" {
local trunk="$BATS_TMPDIR/wrktr-trunk-$$-use-allow"
mkdir -p "$trunk"
Expand Down
66 changes: 57 additions & 9 deletions worktree-functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,39 @@ function _wrktr_expand_tilde() {
esac
}

# -----------------------------------------------------------------------------
# _wrktr_snapshot_vars
# -----------------------------------------------------------------------------
# What it does:
# Captures the current values of the named variables as a string of
# NAME=value assignments (shell-quoted) that can be restored later with:
#
# eval "export$snapshot"
#
# Only variables that are set are captured, so an empty value is preserved
# and an unset variable stays unset. Works in bash and zsh.
#
# Arguments:
# $@ — variable names
#
# Output:
# Echoes " NAME=value NAME2=value2" (leading space), or nothing.
#
# Examples:
# saved="$(_wrktr_snapshot_vars WRKTR_NAME WRKTR_REMOTE)"
# eval "export$saved"
# -----------------------------------------------------------------------------
function _wrktr_snapshot_vars() {
local _var _val _out=""
for _var in "$@"; do
if [ -n "$(eval "printf %s \"\${$_var+set}\"")" ]; then
_val="$(eval "printf %s \"\$$_var\"")"
_out="$_out $(printf '%s=%q' "$_var" "$_val")"
fi
done
printf '%s' "$_out"
}

# -----------------------------------------------------------------------------
# _wrktr_git_is_readonly
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -1621,7 +1654,11 @@ function wrktr_use() {
fi

# Start from a clean slate so a key missing from this config cannot inherit
# a value from a previously loaded session.
# a value from a previously loaded session. Remember the previous session so
# a failed load can put it back.
local previous_session
previous_session="$(_wrktr_snapshot_vars WRKTR_NAME WRKTR_BASE_TRUNK WRKTR_BASE_DIR \
WRKTR_REMOTE WRKTR_MAIN_BRANCH)"
unset WRKTR_NAME WRKTR_BASE_TRUNK WRKTR_BASE_DIR WRKTR_REMOTE WRKTR_MAIN_BRANCH

local _line _key _value
Expand Down Expand Up @@ -1653,6 +1690,11 @@ function wrktr_use() {
unset WRKTR_REMOTE
unset WRKTR_MAIN_BRANCH

if [ -n "$previous_session" ]; then
eval "export$previous_session"
printf 'The previously loaded session is still active: %s\n\n' "$WRKTR_NAME"
fi

return 1
fi

Expand Down Expand Up @@ -1924,6 +1966,9 @@ function wrktr_init() {
return 1
fi

# Reject a bad branch name before anything is created on disk.
_wrktr_validate_branch_name "$main_branch" || return 1

_wrktr_breaker "Initializing bare repository"

if ! _wrktr_run git init --bare "$repo_dir"; then
Expand Down Expand Up @@ -1959,6 +2004,14 @@ function wrktr_init() {

trap _wrktr_init_cleanup RETURN

# git init --bare leaves HEAD on the default branch name (often master). Point
# it at the branch we are about to create, or HEAD stays unborn and plain
# git commands in the bare repo fail.
if ! _wrktr_run git --git-dir="$repo_dir" symbolic-ref HEAD "refs/heads/$main_branch"; then
_wrktr_breaker red "Failed to point HEAD at $main_branch"
return 1
fi

_wrktr_breaker "Temporarily relocating existing worktree contents"

if ! _wrktr_run mv "$worktree_dir" "$tmp_dir"; then
Expand Down Expand Up @@ -3188,14 +3241,9 @@ function wrktr_reload() {

# Unload discards the session and settings; remember them so a reload is
# not a logout.
local _saved="" _var _val
for _var in WRKTR_NAME WRKTR_BASE_TRUNK WRKTR_BASE_DIR WRKTR_REMOTE WRKTR_MAIN_BRANCH \
WRKTR_DRY_RUN WRKTR_CONFIG_DIR WRKTR_REPO_DIR_NAME; do
if [ -n "$(eval "printf %s \"\${$_var+set}\"")" ]; then
_val="$(eval "printf %s \"\$$_var\"")"
_saved="$_saved $(printf '%s=%q' "$_var" "$_val")"
fi
done
local _saved
_saved="$(_wrktr_snapshot_vars WRKTR_NAME WRKTR_BASE_TRUNK WRKTR_BASE_DIR WRKTR_REMOTE \
WRKTR_MAIN_BRANCH WRKTR_DRY_RUN WRKTR_CONFIG_DIR WRKTR_REPO_DIR_NAME)"

# shellcheck disable=SC2119
wrktr_unload
Expand Down
Loading