diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfda899..b31cc4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd156d..10a33cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 504c33d..f03415d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/tests/commands.bats b/tests/commands.bats index b76a9d3..9ca57e6 100644 --- a/tests/commands.bats +++ b/tests/commands.bats @@ -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" ] +} diff --git a/tests/wrktr.bats b/tests/wrktr.bats index bb0a035..1062257 100644 --- a/tests/wrktr.bats +++ b/tests/wrktr.bats @@ -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() { @@ -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 # =========================================================================== @@ -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" diff --git a/worktree-functions.sh b/worktree-functions.sh index bde7fbe..69f0164 100644 --- a/worktree-functions.sh +++ b/worktree-functions.sh @@ -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 # ----------------------------------------------------------------------------- @@ -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 @@ -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 @@ -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 @@ -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 @@ -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