diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 0e9b285..b4707b3 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,4 @@ e257f44613ca0004a3156c47b7b2b45137f05869 # style(README.md): use a shorter lang 6bf14baa530feb871219b09ea82dae6844692054 # style: fix indents 1858a6813b38f1c22e7ec0811927bf07d53e47c5 # style: fix indents 6857734c07a78c4a8c0411cb258b9dea40807147 # style(.editorconfig): rearrange a table +c6514fc9d69b1f148054ce944144f240df7dcf4c # style: edit whitespace diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 352e37e..14aa106 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -15,6 +15,16 @@ defaults: shell: bash -el {0} jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup BATS + uses: bats-core/bats-action@3.0.0 + - name: Run BATS tests + run: bats --jobs 4 --timing tests/*.bats + run-ci: name: Run CI runs-on: ubuntu-latest @@ -23,26 +33,22 @@ jobs: - uses: actions/setup-python@v6 with: check-latest: true - cache: "pip" - name: Install pre-commit run: | python3 -m pip install --upgrade pip python3 -m pip install --upgrade pre-commit - - name: Set cache date - run: | - echo "DATE=$(date +'%Y%m%d')" >> "$GITHUB_ENV" - name: Cache pre-commit hooks id: cache-pre-commit-hooks uses: actions/cache@v5 env: - CACHE_NUMBER: 0 + CACHE_NUMBER: 1 with: path: ~/.cache/pre-commit - key: ${{ runner.os }}-precommit-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.DATE }}-${{ env.CACHE_NUMBER }} # yamllint disable-line rule:line-length - - name: Install pre-commit hooks + key: ${{ runner.os }}-precommit-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.CACHE_NUMBER }} # yamllint disable-line rule:line-length + - name: Install pre-commit hook environments if: steps.cache-pre-commit-hooks.outputs.cache-hit != 'true' run: | - pre-commit install + pre-commit install-hooks - name: Run pre-commit hooks run: | pre-commit run --all-files diff --git a/.yamllint b/.yamllint index 319c80a..1981f5c 100644 --- a/.yamllint +++ b/.yamllint @@ -1,7 +1,6 @@ extends: default yaml-files: - - ".yamlfmt" - ".yamllint" - "*.yaml" - "*.yml" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77aa118 --- /dev/null +++ b/Makefile @@ -0,0 +1,123 @@ +.ONESHELL: + +DEBUG ?= false +VERBOSE ?= false + +ifeq ($(DEBUG),true) + MAKEFLAGS += --debug=v +else ifneq ($(VERBOSE),true) + MAKEFLAGS += --silent +endif + +PRECOMMIT ?= pre-commit +ifneq ($(shell command -v prek >/dev/null 2>&1 && echo y),) + PRECOMMIT := prek + ifneq ($(filter true,$(DEBUG) $(VERBOSE)),) + $(info Using prek for pre-commit checks) + ifeq ($(DEBUG),true) + PRECOMMIT := $(PRECOMMIT) -v + endif + endif +endif + +# Terminal formatting (tput with fallbacks to ANSI codes) +_COLOR := $(shell tput sgr0 2>/dev/null || printf '\033[0m') +BOLD := $(shell tput bold 2>/dev/null || printf '\033[1m') +CYAN := $(shell tput setaf 6 2>/dev/null || printf '\033[0;36m') +GREEN := $(shell tput setaf 2 2>/dev/null || printf '\033[0;32m') +RED := $(shell tput setaf 1 2>/dev/null || printf '\033[0;31m') +YELLOW := $(shell tput setaf 3 2>/dev/null || printf '\033[0;33m') + +.DEFAULT_GOAL := help +.PHONY: help +help: ## Show this help message + @echo "$(BOLD)Available targets:$(_COLOR)" + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "; max = 0} \ + {if (length($$1) > max) max = length($$1)} \ + {targets[NR] = $$0} \ + END {for (i = 1; i <= NR; i++) { \ + split(targets[i], arr, FS); \ + printf "$(CYAN)%-*s$(_COLOR) %s\n", max + 2, arr[1], arr[2]}}' + @echo + @echo "$(BOLD)Environment variables:$(_COLOR)" + @echo " $(YELLOW)DEBUG$(_COLOR) = true|false Set to true to enable debug output (default: false)" + @echo " $(YELLOW)VERBOSE$(_COLOR) = true|false Set to true to enable verbose output (default: false)" + +.PHONY: develop +WITH_HOOKS ?= true +develop: ## Set up the project for development (WITH_HOOKS={true|false}, default=true) + @if ! git config --local --get-all include.path | grep -q ".gitconfigs/alias"; then \ + git config --local --add include.path "$(CURDIR)/.gitconfigs/alias"; \ + fi + @git config blame.ignoreRevsFile .git-blame-ignore-revs + @set -e; \ + if command -v git-lfs >/dev/null 2>&1; then \ + git lfs install --local --skip-repo || true; \ + fi; \ + current_branch=$$(git branch --show-current); \ + stash_was_needed=0; \ + cleanup() { \ + exit_code=$$?; \ + if [ "$$current_branch" != "$$(git branch --show-current)" ]; then \ + echo "$(YELLOW)Attempting to return to $$current_branch...$(_COLOR)"; \ + if git switch "$$current_branch" 2>/dev/null; then \ + echo "Successfully returned to $$current_branch"; \ + else \ + echo "$(RED)Error: Could not return to $$current_branch. You are on $$(git branch --show-current).$(_COLOR)" >&2; \ + if [ "$$exit_code" -eq 0 ]; then exit_code=1; fi; \ + fi; \ + fi; \ + if [ $$stash_was_needed -eq 1 ] && git stash list | head -1 | grep -q "Auto stash before switching to main"; then \ + echo "$(YELLOW)Note: Your stashed changes are still available. Run 'git stash pop' to restore them.$(_COLOR)"; \ + fi; \ + exit $$exit_code; \ + }; \ + trap cleanup EXIT; \ + if ! git diff --quiet || ! git diff --cached --quiet; then \ + git stash push -m "Auto stash before switching to main"; \ + stash_was_needed=1; \ + fi; \ + git switch main && git pull; \ + if command -v git-lfs >/dev/null 2>&1; then \ + git lfs pull || true; \ + fi; \ + git switch "$$current_branch"; \ + if [ $$stash_was_needed -eq 1 ]; then \ + if git stash apply; then \ + git stash drop; \ + else \ + echo "$(RED)Error: Stash apply had conflicts. Resolve them, then run: git stash drop$(_COLOR)"; \ + fi; \ + fi; \ + trap - EXIT + @if [ "$(WITH_HOOKS)" = "true" ]; then \ + $(MAKE) enable-pre-commit; \ + fi + +.PHONY: test +PARALLEL ?= true +test: ## Run all tests (PARALLEL={true|false}, default=true) + @if [ "$(PARALLEL)" = "true" ]; then \ + echo "$(CYAN)Running tests in parallel...$(_COLOR)"; \ + bats --jobs 4 --timing tests/*.bats; \ + else \ + echo "$(CYAN)Running tests sequentially...$(_COLOR)"; \ + bats tests/*.bats; \ + fi + +.PHONY: check +check: run-pre-commit test ## Run all code quality checks and tests + +.PHONY: enable-pre-commit +enable-pre-commit: ## Enable pre-commit hooks (along with commit-msg and pre-push hooks) + @if command -v pre-commit >/dev/null 2>&1; then \ + pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push --hook-type prepare-commit-msg ; \ + else \ + echo "$(YELLOW)Warning: pre-commit is not installed. Skipping hook installation.$(_COLOR)"; \ + echo "Install it with: pip install pre-commit (or brew install pre-commit on macOS)"; \ + fi + +.PHONY: run-pre-commit +run-pre-commit: ## Run the pre-commit checks + $(PRECOMMIT) run --all-files diff --git a/README.md b/README.md index b8771df..64af499 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,3 @@ Add the above line to your favorite shell configuration file (e.g. `~/.bashrc`, - [`touchx`](touchx): Create (or update) a file and add `+x` permission to it. - [`update-mine`](update-mine): Update all branches with open pull requests authored by you. - [`venv-now`](venv-now): Create a new Python virtual environment in ./.venv (or the given directory), activating it if sourced. - -## TODO - -- fix indents diff --git a/ach b/ach index 8eff1bb..b7e24cb 100755 --- a/ach +++ b/ach @@ -99,14 +99,6 @@ if git status --short "$FILE" | grep -q "^[ MADRCU?]"; then esac fi -STASHED=false -if [ -n "$(git diff --cached)" ]; then - echo "There are staged changes. Stashing them temporarily..." - git stash push -k -m "Temporary stash for $SCRIPT_NAME" - STASHED=true - echo "Staged changes stashed." -fi - { if [[ $INCLUDE_SUMMARY == true ]]; then SUMMARY=$(git log -1 --format=%s "$HASH_ENTRY" 2>/dev/null || echo "summary unavailable") @@ -127,18 +119,9 @@ fi fi fi - git commit --no-verify -m "$COMMIT_MSG" + git commit --no-verify -m "$COMMIT_MSG" -- "$FILE" echo "Successfully updated and committed $FILE." } || { echo "Failed to update $FILE. Resolve manually." - if [ "$STASHED" = true ]; then git stash pop || echo "Failed to apply stashed changes."; fi exit 1 } - -if [ "$STASHED" = true ]; then - echo "Reapplying stashed changes..." - git stash pop || { - echo "Failed to apply stashed changes. Resolve manually." - exit 1 - } -fi diff --git a/chdirx b/chdirx index e301eda..379f350 100755 --- a/chdirx +++ b/chdirx @@ -67,6 +67,8 @@ process_directory() { for file in "$dir"/.* "$dir"/*; do # Skip if file doesn't exist (handles empty dirs) or if it's `.` or `..` [[ -e $file && $file != "$dir/." && $file != "$dir/.." ]] || continue + # Skip symlinks to prevent loops and operating outside target tree + [[ -L $file ]] && continue if [[ -f $file ]]; then # Check if the first line starts with #! if head -n 1 "$file" | grep -q '^#!'; then diff --git a/gcfixup b/gcfixup index c257149..741d994 100755 --- a/gcfixup +++ b/gcfixup @@ -7,14 +7,14 @@ gcfixup() { $SCRIPT_NAME - Create a fixup commit and automatically rebase with autosquash Usage: - $SCRIPT_NAME [options] + $SCRIPT_NAME [options] Description: This script creates a fixup commit for the specified commit hash and then performs an interactive rebase with autosquash and autostash enabled. Arguments: - The commit hash to fix up. + The commit hash to fix up. [options] Optional arguments to pass to 'git commit'. Example: @@ -33,6 +33,19 @@ EOF local commit_hash="$1" shift # Remove the commit hash from the arguments + # Validate that the commit exists + if ! git rev-parse --verify "${commit_hash}^{commit}" &>/dev/null; then + echo "Error: Invalid commit hash '$commit_hash'." >&2 + return 1 + fi + + # Check if this is the root commit (has no parent) + if ! git rev-parse --verify "$commit_hash"~1 &>/dev/null; then + echo "Error: Cannot fixup root commit (it has no parent to rebase onto)." >&2 + echo "Workaround: git commit --fixup=$commit_hash && git rebase -i --autosquash --root" >&2 + return 1 + fi + if ! git commit --fixup="$commit_hash" "$@"; then return 1 fi diff --git a/git-shed b/git-shed index d957787..bf22f68 100755 --- a/git-shed +++ b/git-shed @@ -61,11 +61,14 @@ fi echo "Fetching latest remote info and pruning..." git fetch --prune -# Identify local branches fully merged into the target branch. -MERGED_BRANCHES=$(git branch --merged "$TARGET_BRANCH" | - grep -v "$TARGET_BRANCH" | +# Identify local branches fully merged into the remote target branch. +# Using origin/$TARGET_BRANCH ensures we compare against the up-to-date remote. +# Wrap in subshell so filtering applies to both the primary and fallback command. +MERGED_BRANCHES=$( (git branch --merged "origin/$TARGET_BRANCH" 2>/dev/null || + git branch --merged "$TARGET_BRANCH") | grep -v "^\*" | - sed 's/^ //') + sed 's/^ //' | + grep -v -F -x "$TARGET_BRANCH") if [ -z "$MERGED_BRANCHES" ]; then echo "No local branches merged into '$TARGET_BRANCH' found. Nothing to clean." @@ -91,7 +94,8 @@ else fi # Identify local branches that no longer have a remote. -STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | awk '{print $1}') +# Use -vv to show tracking info; ": gone]" indicates deleted upstream +STALE_BRANCHES=$(git branch -vv | awk '/: gone]/ {print ($1 == "*" ? $2 : $1)}') if [ -z "$STALE_BRANCHES" ]; then echo "No stale branches (i.e., branches with no remote) found." diff --git a/how-big b/how-big index f150d81..759c039 100755 --- a/how-big +++ b/how-big @@ -1,5 +1,8 @@ #!/bin/bash set -euo pipefail +# Note: we still allow partial results when permission errors occur on some +# entries. We capture non-zero exits from find/du as "partial" rather than +# failing the script, but keep pipefail to catch genuine pipeline issues. SCRIPT_NAME=$(basename "$0") @@ -26,14 +29,14 @@ EOF } # Default behavior (show directories only) -FILES_TOO="" +SHOW_FILES="false" # Parse command-line arguments while [[ $# -gt 0 ]]; do case "$1" in - -a) FILES_TOO="-a" ;; # Enable file size display - -h | --help) usage ;; # Show help and exit - *) TARGET_DIR="$1" ;; # Assume anything else is the directory argument + -a) SHOW_FILES="true" ;; # Enable file size display + -h | --help) usage ;; # Show help and exit + *) TARGET_DIR="$1" ;; # Assume anything else is the directory argument esac shift done @@ -48,4 +51,26 @@ if [[ ! -d $TARGET_DIR ]]; then fi # Run the disk usage command with optional file inclusion -du $FILES_TOO -h -d 1 "$TARGET_DIR" | sort -hr +DU_TMP=$(mktemp) +partial=false +trap 'rm -f "$DU_TMP"' EXIT + +if [[ "$SHOW_FILES" == "true" ]]; then + # Cross-platform: use find + du for files, then du for directories + if ! find -- "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + >>"$DU_TMP" 2>/dev/null; then + partial=true + fi + if ! du -h -d 1 -- "$TARGET_DIR" >>"$DU_TMP" 2>/dev/null; then + partial=true + fi +else + if ! du -h -d 1 -- "$TARGET_DIR" >>"$DU_TMP" 2>/dev/null; then + partial=true + fi +fi + +sort -hr "$DU_TMP" | uniq + +if [[ "$partial" == true ]]; then + echo "Warning: some entries were skipped (e.g., permission denied)." >&2 +fi diff --git a/mergewith b/mergewith index fb3183b..0c7460a 100755 --- a/mergewith +++ b/mergewith @@ -3,6 +3,7 @@ SCRIPT_NAME=$(basename "$0") usage() { + local exit_code=${1:-0} cat < @@ -24,7 +25,7 @@ Examples: $SCRIPT_NAME main $SCRIPT_NAME feature-branch EOF - exit 0 + exit "$exit_code" } reference_branch="" @@ -35,12 +36,12 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "Error: Unknown option '$1'" >&2 - usage + usage 1 ;; *) if [[ -n $reference_branch ]]; then echo "Error: Multiple reference branches provided: '$reference_branch' and '$1'" >&2 - usage + usage 1 fi reference_branch="$1" ;; @@ -51,7 +52,7 @@ done # Ensure exactly one reference branch is provided if [[ -z $reference_branch ]]; then echo "Error: No reference branch specified." >&2 - usage + usage 1 fi # Ensure this is a git repository @@ -66,12 +67,24 @@ if ! current_branch=$(git branch --show-current); then exit 1 fi +# Abort if in detached HEAD state +# Note: git branch --show-current returns empty string only for detached HEAD +if [[ -z "$current_branch" ]]; then + echo "Error: You are in detached HEAD state. Please checkout a branch first." + exit 1 +fi + echo "Current branch is $current_branch" -echo "Pulling latest changes for $current_branch" -if ! git pull; then - echo "Error: Failed to pull the latest changes for branch '$current_branch'." - exit 1 +# Only pull if an upstream is configured +if git rev-parse --abbrev-ref "@{upstream}" &>/dev/null; then + echo "Pulling latest changes for $current_branch" + if ! git pull; then + echo "Error: Failed to pull the latest changes for branch '$current_branch'." + exit 1 + fi +else + echo "No upstream configured for $current_branch, skipping pull." fi if [[ $current_branch == "$reference_branch" ]]; then diff --git a/tests/ach.bats b/tests/ach.bats new file mode 100755 index 0000000..2176817 --- /dev/null +++ b/tests/ach.bats @@ -0,0 +1,132 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +load 'test_helper' + +@test "ach: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/ach" +} + +@test "ach: --help displays usage information" { + run "$SCRIPTS_DIR/ach" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "HASH" +} + +@test "ach: -h displays usage information" { + run "$SCRIPTS_DIR/ach" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "HASH" +} + +@test "ach: adds last commit hash to default file" { + setup_git_repo + + # Capture the hash BEFORE running ach (since ach creates a new commit) + local last_hash + last_hash=$(git rev-parse HEAD) + + run "$SCRIPTS_DIR/ach" + [ "$status" -eq 0 ] + assert_file_exists ".git-blame-ignore-revs" + + # Verify the original commit hash is in the file + grep -q "$last_hash" ".git-blame-ignore-revs" +} + +@test "ach: adds specified hash to custom file" { + setup_git_repo + local hash + hash=$(git rev-parse HEAD) + + run "$SCRIPTS_DIR/ach" "$hash" "custom-ignore.txt" + [ "$status" -eq 0 ] + assert_file_exists "custom-ignore.txt" + grep -q "$hash" "custom-ignore.txt" +} + +@test "ach: --no-summary omits commit summary" { + setup_git_repo + + run "$SCRIPTS_DIR/ach" --no-summary + [ "$status" -eq 0 ] + assert_file_exists ".git-blame-ignore-revs" + + # File should only contain the hash line, not "# Initial commit" + run grep -q "# Initial commit" ".git-blame-ignore-revs" + [ "$status" -ne 0 ] +} + +@test "ach: fails with invalid hash" { + setup_git_repo + + run "$SCRIPTS_DIR/ach" "invalidhash123" + [ "$status" -ne 0 ] +} + +@test "ach: commit is atomic - does not include pre-staged changes" { + setup_git_repo + + # Create and stage a separate file (simulating user's work in progress) + echo "unrelated work" >unrelated.txt + git add unrelated.txt + + # Verify it's staged + run bash -c 'git diff --cached --name-only | grep -q "unrelated.txt"' + [ "$status" -eq 0 ] + + # Capture hash before ach runs + local target_hash + target_hash=$(git rev-parse HEAD) + + # Run ach - this should ONLY commit the blame file + run "$SCRIPTS_DIR/ach" + [ "$status" -eq 0 ] + + # Verify the target hash was added to the ignore file + grep -q "$target_hash" ".git-blame-ignore-revs" + + # The ach commit should contain ONLY the blame file, not unrelated.txt + local ach_commit_files + ach_commit_files=$(git diff-tree --no-commit-id --name-only -r HEAD) + [ "$ach_commit_files" = ".git-blame-ignore-revs" ] + + # unrelated.txt should STILL be staged (git commit preserves unrelated index changes) + run bash -c 'git diff --cached --name-only | grep -q "unrelated.txt"' + [ "$status" -eq 0 ] +} + +@test "ach: succeeds with unstaged changes to other files" { + setup_git_repo + + # Create a tracked file with unstaged modifications + echo "tracked content" >tracked.txt + git add tracked.txt + git commit -m "Add tracked file" + echo "modified content" >tracked.txt + + # Also create an untracked file + echo "untracked content" >untracked.txt + + # Verify we have unstaged changes + run git status --porcelain + [[ "$output" == *" M tracked.txt"* ]] || [[ "$output" == *"M tracked.txt"* ]] || [[ "$output" == *"?? untracked.txt"* ]] + + # Capture hash before ach runs + local target_hash + target_hash=$(git rev-parse HEAD) + + # Run ach - should succeed despite unstaged changes to other files + run "$SCRIPTS_DIR/ach" + [ "$status" -eq 0 ] + + # Verify the commit was created correctly + grep -q "$target_hash" ".git-blame-ignore-revs" + + # Unstaged changes should still be present + run git status --porcelain tracked.txt + [[ "$output" == *"M"* ]] +} diff --git a/tests/chdirx.bats b/tests/chdirx.bats new file mode 100755 index 0000000..3bcaa05 --- /dev/null +++ b/tests/chdirx.bats @@ -0,0 +1,76 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "chdirx: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/chdirx" +} + +@test "chdirx: --help displays usage information" { + run "$SCRIPTS_DIR/chdirx" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "directory" +} + +@test "chdirx: -h displays usage information" { + run "$SCRIPTS_DIR/chdirx" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "directory" +} + +@test "chdirx: fails when no directory specified" { + run "$SCRIPTS_DIR/chdirx" + [ "$status" -ne 0 ] + assert_output_contains "No directory specified" +} + +@test "chdirx: fails when directory does not exist" { + run "$SCRIPTS_DIR/chdirx" "nonexistent_dir" + [ "$status" -ne 0 ] + assert_output_contains "is not a directory" +} + +@test "chdirx: makes shebang files executable" { + mkdir testdir + echo '#!/bin/bash' >testdir/script.sh + echo 'echo hello' >>testdir/script.sh + chmod -x testdir/script.sh + + run "$SCRIPTS_DIR/chdirx" testdir + [ "$status" -eq 0 ] + assert_executable testdir/script.sh +} + +@test "chdirx: ignores files without shebang" { + mkdir testdir + echo 'just text' >testdir/readme.txt + chmod -x testdir/readme.txt + + run "$SCRIPTS_DIR/chdirx" testdir + [ "$status" -eq 0 ] + [ ! -x testdir/readme.txt ] +} + +@test "chdirx: -r processes subdirectories recursively" { + mkdir -p testdir/subdir + echo '#!/bin/bash' >testdir/script1.sh + echo '#!/bin/bash' >testdir/subdir/script2.sh + chmod -x testdir/script1.sh testdir/subdir/script2.sh + + run "$SCRIPTS_DIR/chdirx" -r testdir + [ "$status" -eq 0 ] + assert_executable testdir/script1.sh + assert_executable testdir/subdir/script2.sh +} + +@test "chdirx: without -r does not process subdirectories" { + mkdir -p testdir/subdir + echo '#!/bin/bash' >testdir/subdir/script.sh + chmod -x testdir/subdir/script.sh + + run "$SCRIPTS_DIR/chdirx" testdir + [ "$status" -eq 0 ] + [ ! -x testdir/subdir/script.sh ] +} diff --git a/tests/gcfixup.bats b/tests/gcfixup.bats new file mode 100755 index 0000000..2b9d766 --- /dev/null +++ b/tests/gcfixup.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "gcfixup: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/gcfixup" +} + +@test "gcfixup: --help displays usage information" { + run "$SCRIPTS_DIR/gcfixup" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "target commit hash" +} + +@test "gcfixup: -h displays usage information" { + run "$SCRIPTS_DIR/gcfixup" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" +} + +@test "gcfixup: shows help when no arguments provided" { + run "$SCRIPTS_DIR/gcfixup" + [ "$status" -eq 0 ] + assert_output_contains "Usage:" +} + +@test "gcfixup: fails with invalid commit hash" { + setup_git_repo + + run "$SCRIPTS_DIR/gcfixup" invalidhash123 + [ "$status" -ne 0 ] +} + +@test "gcfixup: fails on root commit with helpful workaround" { + setup_git_repo + + # The initial commit from setup_git_repo is the root commit + local root_hash + root_hash=$(git rev-parse HEAD) + + run "$SCRIPTS_DIR/gcfixup" "$root_hash" + [ "$status" -ne 0 ] + assert_output_contains "root commit" + assert_output_contains "Workaround" + assert_output_contains "--root" +} + +@test "gcfixup: creates fixup commit for valid hash" { + setup_git_repo + + # Create a second commit so we're not fixing up the root commit + echo "second content" >second.txt + git add second.txt + git commit -m "Second commit" + + # Get the second commit hash + local second_hash + second_hash=$(git rev-parse HEAD) + + # Make a new change to fixup + echo "fixup content" >>second.txt + git add second.txt + + # Run gcfixup. + # We use GIT_SEQUENCE_EDITOR=true to auto-proceed with the rebase plan. + # The rebase should squash the fixup commit into the "Second commit". + GIT_SEQUENCE_EDITOR=true GIT_EDITOR=true run "$SCRIPTS_DIR/gcfixup" "$second_hash" + [ "$status" -eq 0 ] + + # Verify the commit count is still 2 (fixup was squashed) + local commit_count + commit_count=$(git rev-list --count HEAD) + [ "$commit_count" -eq 2 ] + + # Verify the content of second.txt contains both original and fixup content + grep -q "second content" second.txt + grep -q "fixup content" second.txt + + # Verify the commit message of HEAD is still "Second commit" + run git log -1 --format=%s + [ "$output" = "Second commit" ] +} diff --git a/tests/git-shed.bats b/tests/git-shed.bats new file mode 100755 index 0000000..84e8897 --- /dev/null +++ b/tests/git-shed.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "git-shed: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/git-shed" +} + +@test "git-shed: --help displays usage information" { + run "$SCRIPTS_DIR/git-shed" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "TARGET_BRANCH" +} + +@test "git-shed: -h displays usage information" { + run "$SCRIPTS_DIR/git-shed" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "TARGET_BRANCH" +} + +@test "git-shed: fails when target branch does not exist" { + setup_git_repo + + run "$SCRIPTS_DIR/git-shed" nonexistent-branch + [ "$status" -ne 0 ] + assert_output_contains "does not exist" +} + +@test "git-shed: --dry-run shows what would be deleted without deleting" { + setup_git_repo + git switch -c feature-branch + echo "feature" >feature.txt + git add feature.txt + git commit -m "Add feature" + git switch main + git merge feature-branch + + run "$SCRIPTS_DIR/git-shed" --dry-run -y main + [ "$status" -eq 0 ] + assert_output_contains "DRY-RUN" + # Branch should still exist after dry-run + git show-ref --verify --quiet refs/heads/feature-branch +} + +@test "git-shed: defaults to main branch" { + setup_git_repo + + run "$SCRIPTS_DIR/git-shed" --dry-run -y + [ "$status" -eq 0 ] + assert_output_contains "Fetching" +} diff --git a/tests/how-big.bats b/tests/how-big.bats new file mode 100755 index 0000000..7ad490a --- /dev/null +++ b/tests/how-big.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "how-big: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/how-big" +} + +@test "how-big: --help displays usage information" { + run "$SCRIPTS_DIR/how-big" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "directory" +} + +@test "how-big: -h displays usage information" { + run "$SCRIPTS_DIR/how-big" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "directory" +} + +@test "how-big: fails when directory does not exist" { + run "$SCRIPTS_DIR/how-big" "nonexistent_dir" + [ "$status" -ne 0 ] + assert_output_contains "not a valid directory" +} + +@test "how-big: shows size of current directory by default" { + mkdir -p subdir + echo "test content" >subdir/file.txt + + run "$SCRIPTS_DIR/how-big" + [ "$status" -eq 0 ] + # Output should contain size information + [[ "$output" =~ [0-9] ]] +} + +@test "how-big: shows size of specified directory" { + mkdir -p testdir/subdir + echo "test content" >testdir/subdir/file.txt + + run "$SCRIPTS_DIR/how-big" testdir + [ "$status" -eq 0 ] + assert_output_contains "testdir" +} + +@test "how-big: -a shows individual file sizes" { + mkdir -p testdir + echo "content" >testdir/file.txt + + run "$SCRIPTS_DIR/how-big" -a testdir + [ "$status" -eq 0 ] + # Verify it shows the file (cross-platform fix uses find + du) + assert_output_contains "file.txt" +} + +@test "how-big: -a shows both files and subdirectories" { + mkdir -p testdir/subdir + echo "file content" >testdir/myfile.txt + echo "subdir content" >testdir/subdir/nested.txt + + run "$SCRIPTS_DIR/how-big" -a testdir + [ "$status" -eq 0 ] + # Should show both the file and the subdirectory + assert_output_contains "myfile.txt" + assert_output_contains "subdir" +} + +@test "how-big: without -a does not show individual files" { + mkdir -p testdir/subdir + echo "file content" >testdir/standalone.txt + echo "subdir content" >testdir/subdir/nested.txt + + run "$SCRIPTS_DIR/how-big" testdir + [ "$status" -eq 0 ] + # Should show subdirectory but NOT the standalone file + assert_output_contains "subdir" + assert_output_not_contains "standalone.txt" +} + +@test "how-big: -a works with current directory" { + echo "root file" >rootfile.txt + mkdir -p subdir + echo "nested" >subdir/nested.txt + + run "$SCRIPTS_DIR/how-big" -a + [ "$status" -eq 0 ] + assert_output_contains "rootfile.txt" +} diff --git a/tests/mergewith.bats b/tests/mergewith.bats new file mode 100755 index 0000000..7672bad --- /dev/null +++ b/tests/mergewith.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "mergewith: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/mergewith" +} + +@test "mergewith: --help displays usage information" { + run "$SCRIPTS_DIR/mergewith" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "reference_branch" +} + +@test "mergewith: -h displays usage information" { + run "$SCRIPTS_DIR/mergewith" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "reference_branch" +} + +@test "mergewith: fails when no reference branch specified" { + setup_git_repo + + run "$SCRIPTS_DIR/mergewith" + [ "$status" -ne 0 ] + assert_output_contains "reference_branch" +} + +@test "mergewith: fails when not in a git repository" { + # We're in TEST_TEMP_DIR which is not a git repo + run "$SCRIPTS_DIR/mergewith" main + [ "$status" -ne 0 ] + assert_output_contains "Not inside a git repository" +} + +@test "mergewith: warns when current and reference branch are the same" { + setup_git_repo + + # Set up proper tracking so git pull works + rm -rf "$TEST_TEMP_DIR/remote.git" + git clone --bare . "$TEST_TEMP_DIR/remote.git" + git remote add origin "$TEST_TEMP_DIR/remote.git" + git fetch origin + git branch --set-upstream-to=origin/main main + + run "$SCRIPTS_DIR/mergewith" main + [ "$status" -eq 0 ] + assert_output_contains "same" +} + +@test "mergewith: fails in detached HEAD state" { + setup_git_repo + + # Enter detached HEAD state + git checkout --detach HEAD + + run "$SCRIPTS_DIR/mergewith" main + [ "$status" -ne 0 ] + assert_output_contains "detached HEAD" +} + +@test "mergewith: fails with unknown option" { + run "$SCRIPTS_DIR/mergewith" --unknown + [ "$status" -ne 0 ] + assert_output_contains "Unknown option" +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100755 index 0000000..819e7f4 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,129 @@ +#!/bin/bash +# Common test helper functions for BATS tests + +# Get the directory containing the scripts (parent of tests directory) +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export SCRIPTS_DIR + +# Ensure a "python" executable is available on PATH for tests that expect it. +# If python3 exists but python does not, create a persistent shim directory +# containing a python -> python3 symlink and prepend it to PATH if needed. +# Tests that modify PATH should either preserve $PYTHON_SHIM_DIR on PATH or +# call this helper again after changing PATH. +ensure_python_symlink() { + # If "python" is already available, nothing to do. + if command -v python &>/dev/null; then + return 0 + fi + + # If python3 is not available either, we cannot provide a shim. + if ! command -v python3 &>/dev/null; then + return 0 + fi + + # Lazily create a persistent shim directory once per test run. + if [[ -z "${PYTHON_SHIM_DIR:-}" ]]; then + PYTHON_SHIM_DIR="$(mktemp -d)" + export PYTHON_SHIM_DIR + fi + + mkdir -p "$PYTHON_SHIM_DIR" + ln -sf "$(command -v python3)" "$PYTHON_SHIM_DIR/python" + + # Prepend the shim directory to PATH if it's not already present. + case ":$PATH:" in + *":$PYTHON_SHIM_DIR:"*) ;; + *) export PATH="$PYTHON_SHIM_DIR:$PATH" ;; + esac +} + +# Setup function - runs before each test +setup() { + # Create a temporary directory for test files + TEST_TEMP_DIR="$(mktemp -d)" + cd "$TEST_TEMP_DIR" || return 1 + + # Ensure a "python" command is available on PATH for tests that expect it. + ensure_python_symlink +} + +# Teardown function - runs after each test +teardown() { + # Clean up temporary directory + if [[ -n "$TEST_TEMP_DIR" && -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Teardown function - runs once after all tests in a file +teardown_file() { + # Clean up python shim directory if it was created + if [[ -n "${PYTHON_SHIM_DIR:-}" && -d "$PYTHON_SHIM_DIR" ]]; then + rm -rf "$PYTHON_SHIM_DIR" + fi +} + +# Helper to create a minimal git repo for git-related tests +setup_git_repo() { + git init --initial-branch=main + git config user.email "test@example.com" + git config user.name "Test User" + echo "initial" >README.md + git add README.md + git commit -m "Initial commit" +} + +# Helper to check if output contains a substring +# Parameters: +# $1 - expected substring +# Note: $output is set by BATS 'run' command +assert_output_contains() { + local expected="$1" + # shellcheck disable=SC2154 # $output is set by BATS + if [[ "$output" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" + echo "Actual output: $output" + return 1 + fi +} + +# Helper to check if output does NOT contain a substring +# Parameters: +# $1 - unexpected substring +# Note: $output is set by BATS 'run' command +assert_output_not_contains() { + local unexpected="$1" + # shellcheck disable=SC2154 # $output is set by BATS + if [[ "$output" == *"$unexpected"* ]]; then + echo "Expected output NOT to contain: $unexpected" + echo "Actual output: $output" + return 1 + fi +} + +# Helper to assert file is executable +assert_executable() { + local file="$1" + if [[ ! -x "$file" ]]; then + echo "Expected '$file' to be executable" + return 1 + fi +} + +# Helper to assert file exists +assert_file_exists() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "Expected file '$file' to exist" + return 1 + fi +} + +# Helper to assert directory exists +assert_dir_exists() { + local dir="$1" + if [[ ! -d "$dir" ]]; then + echo "Expected directory '$dir' to exist" + return 1 + fi +} diff --git a/tests/touchx.bats b/tests/touchx.bats new file mode 100755 index 0000000..5c33725 --- /dev/null +++ b/tests/touchx.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "touchx: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/touchx" +} + +@test "touchx: --help displays usage information" { + run "$SCRIPTS_DIR/touchx" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "file" +} + +@test "touchx: -h displays usage information" { + run "$SCRIPTS_DIR/touchx" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "file" +} + +@test "touchx: displays usage when no arguments provided" { + run "$SCRIPTS_DIR/touchx" + [ "$status" -ne 0 ] + assert_output_contains "Usage:" +} + +@test "touchx: creates new executable file" { + run "$SCRIPTS_DIR/touchx" newscript.sh + [ "$status" -eq 0 ] + assert_file_exists newscript.sh + assert_executable newscript.sh +} + +@test "touchx: makes existing file executable" { + echo "existing content" >existing.sh + chmod -x existing.sh + + run "$SCRIPTS_DIR/touchx" existing.sh + [ "$status" -eq 0 ] + assert_executable existing.sh + # Content should be preserved + grep -q "existing content" existing.sh +} + +@test "touchx: handles multiple files" { + run "$SCRIPTS_DIR/touchx" file1.sh file2.sh file3.sh + [ "$status" -eq 0 ] + assert_file_exists file1.sh + assert_file_exists file2.sh + assert_file_exists file3.sh + assert_executable file1.sh + assert_executable file2.sh + assert_executable file3.sh +} + +@test "touchx: fails with unknown option" { + run "$SCRIPTS_DIR/touchx" --unknown + [ "$status" -ne 0 ] + assert_output_contains "Unknown option" +} diff --git a/tests/update-mine.bats b/tests/update-mine.bats new file mode 100755 index 0000000..7e21aa0 --- /dev/null +++ b/tests/update-mine.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "update-mine: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/update-mine" +} + +@test "update-mine: --help displays usage information" { + run "$SCRIPTS_DIR/update-mine" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "reference_branch" +} + +@test "update-mine: fails when no reference branch specified" { + run "$SCRIPTS_DIR/update-mine" + [ "$status" -ne 0 ] + assert_output_contains "Missing" +} + +@test "update-mine: fails when not in a git repository" { + # We're in TEST_TEMP_DIR which is not a git repo + run "$SCRIPTS_DIR/update-mine" main + [ "$status" -ne 0 ] + assert_output_contains "Not inside a Git repository" +} + +@test "update-mine: accepts --debug flag" { + setup_git_repo + + # This will fail because gh is not configured, but it should accept the flag + run "$SCRIPTS_DIR/update-mine" --debug main + # Should not fail due to unknown option + assert_output_not_contains "Unknown option" +} + +@test "update-mine: accepts --all flag" { + setup_git_repo + + # This will fail because gh is not configured, but it should accept the flag + run "$SCRIPTS_DIR/update-mine" --all main + # Should not fail due to unknown option + assert_output_not_contains "Unknown option" +} + +@test "update-mine: fails with unknown option" { + run "$SCRIPTS_DIR/update-mine" --unknown main + [ "$status" -ne 0 ] + assert_output_contains "Unknown option" +} diff --git a/tests/venv-now.bats b/tests/venv-now.bats new file mode 100755 index 0000000..e1dcfa8 --- /dev/null +++ b/tests/venv-now.bats @@ -0,0 +1,169 @@ +#!/usr/bin/env bats + +load 'test_helper' + +@test "venv-now: script has valid bash syntax" { + bash -n "$SCRIPTS_DIR/venv-now" +} + +@test "venv-now: --help displays usage information" { + run "$SCRIPTS_DIR/venv-now" --help + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "DIRECTORY" +} + +@test "venv-now: -h displays usage information" { + run "$SCRIPTS_DIR/venv-now" -h + [ "$status" -eq 0 ] + assert_output_contains "Usage:" + assert_output_contains "DIRECTORY" +} + +@test "venv-now: creates .venv directory by default" { + # Skip if python3 is not available + if ! command -v python3 &>/dev/null; then + skip "python3 not available" + fi + + run "$SCRIPTS_DIR/venv-now" + [ "$status" -eq 0 ] + assert_dir_exists ".venv" + assert_file_exists ".venv/bin/activate" +} + +@test "venv-now: creates custom-named venv directory" { + # Skip if python3 is not available + if ! command -v python3 &>/dev/null; then + skip "python3 not available" + fi + + run "$SCRIPTS_DIR/venv-now" myenv + [ "$status" -eq 0 ] + assert_dir_exists "myenv" + assert_file_exists "myenv/bin/activate" +} + +@test "venv-now: --no-remove preserves existing venv" { + # Skip if python3 is not available + if ! command -v python3 &>/dev/null; then + skip "python3 not available" + fi + + # Create initial venv with a marker file + python3 -m venv .venv + echo "marker" >.venv/marker.txt + + run "$SCRIPTS_DIR/venv-now" --no-remove + [ "$status" -eq 0 ] + # Marker file should still exist + assert_file_exists ".venv/marker.txt" +} + +@test "venv-now: removes existing venv by default" { + # Skip if python3 is not available + if ! command -v python3 &>/dev/null; then + skip "python3 not available" + fi + + # Create initial venv with a marker file + python3 -m venv .venv + echo "marker" >.venv/marker.txt + + run "$SCRIPTS_DIR/venv-now" + [ "$status" -eq 0 ] + # Marker file should be gone (venv was recreated) + [ ! -f ".venv/marker.txt" ] +} + +@test "venv-now: -n is alias for --no-remove" { + # Skip if python3 is not available + if ! command -v python3 &>/dev/null; then + skip "python3 not available" + fi + + python3 -m venv .venv + echo "marker" >.venv/marker.txt + + run "$SCRIPTS_DIR/venv-now" -n + [ "$status" -eq 0 ] + assert_file_exists ".venv/marker.txt" +} + +@test "venv-now: fails with unknown option" { + run "$SCRIPTS_DIR/venv-now" --unknown + [ "$status" -ne 0 ] # unknown option should exit with non-zero status + assert_output_contains "Unknown option" +} + +# Unit tests for is_dangerous_venv_path() - completely safe, no file operations +# These test the validation logic directly without invoking any dangerous ops + +@test "is_dangerous_venv_path: rejects empty path" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "" "/some/path" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects dot" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "." "/current" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects double-dot" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path ".." "/parent" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects root path" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "/" "/" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects tilde" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "~" "$HOME" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects path resolving to HOME" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "some/path" "$HOME" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects path resolving to root" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "some/path" "/" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: rejects path resolving to current dir" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "foo/.." "/current" "/current" + [ "$status" -eq 0 ] # 0 = dangerous +} + +@test "is_dangerous_venv_path: accepts safe path" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path ".venv" "/project/.venv" "/project" + [ "$status" -eq 1 ] # 1 = safe +} + +@test "is_dangerous_venv_path: accepts custom venv name" { + VENV_NOW_SOURCE_ONLY=1 source "$SCRIPTS_DIR/venv-now" + run is_dangerous_venv_path "myenv" "/project/myenv" "/project" + [ "$status" -eq 1 ] # 1 = safe +} + +# Integration test for dangerous path rejection (uses safe test directory) +@test "venv-now: integration test rejects path resolving to current dir" { + mkdir -p testdir + # testdir/.. resolves to TEST_TEMP_DIR (current dir) - safe to test + run "$SCRIPTS_DIR/venv-now" "testdir/.." + [ "$status" -ne 0 ] + assert_output_contains "dangerous path" +} diff --git a/touchx b/touchx index 9395039..017a267 100755 --- a/touchx +++ b/touchx @@ -3,6 +3,7 @@ SCRIPT_NAME=$(basename "$0") usage() { + local exit_code=${1:-0} cat < [ ...] @@ -22,12 +23,12 @@ Examples: $SCRIPT_NAME script.sh $SCRIPT_NAME file1.sh file2.sh EOF - exit 0 + exit "$exit_code" } # Check if no arguments were provided if [ "$#" -lt 1 ]; then - usage + usage 1 fi # Collect file arguments @@ -41,7 +42,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "Error: Unknown option '$1'" >&2 - usage + usage 1 ;; *) files+=("$1") @@ -53,7 +54,7 @@ done # Ensure at least one valid file argument remains if [ "${#files[@]}" -eq 0 ]; then echo "Error: No files specified." >&2 - usage + usage 1 fi # Process each file argument diff --git a/update-mine b/update-mine index 050e2f8..14cd26f 100755 --- a/update-mine +++ b/update-mine @@ -8,17 +8,19 @@ SCRIPT_NAME=$(basename "$0") # Function to display usage information usage() { + local exit_code=${1:-0} cat < -Updates branches with open pull requests authored by you by: +By default, updates branches with open pull requests authored by you by: 1. Checking out the branch. 2. Merging the specified reference branch into it. 3. Pushing the updated branch to the remote. Options: --debug Enable debugging output. - --all Update all active branches on the remote authored by you. + --all Update ALL non-protected branches on the remote (not just yours). + Use with caution - this may update branches owned by others. --help Show this help message and exit. Arguments: @@ -30,7 +32,7 @@ Examples: $SCRIPT_NAME --all main $SCRIPT_NAME --debug --all main EOF - exit 0 + exit "$exit_code" } # Parse command-line arguments @@ -50,14 +52,14 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "Unknown option: $1" >&2 - usage + usage 1 ;; *) if [ -z "$reference_branch" ]; then reference_branch="$1" else echo "Error: Multiple reference branches specified: '$reference_branch' and '$1'" >&2 - usage + usage 1 fi shift ;; @@ -82,26 +84,51 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 1 fi +# Ensure gh CLI is installed +if ! command -v gh >/dev/null 2>&1; then + echo "Error: 'gh' (GitHub CLI) is required but not installed." >&2 + echo "Install it from: https://cli.github.com/" >&2 + exit 1 +fi + # Fetch latest remote branches to avoid using stale references git remote update >/dev/null 2>&1 # Determine the list of branches to update if [ "$all_branches" = true ]; then - echo "Fetching all non-protected active branches authored by you..." - branches=$(gh api repos/:owner/:repo/branches --paginate --jq '.[] | select(.protected == false) | .name') + echo "Fetching all non-protected branches (use with caution)..." + if ! branches=$(gh api repos/:owner/:repo/branches --paginate --jq '.[] | select(.protected == false) | .name' 2>&1); then + echo "Error: Failed to fetch branches from GitHub API." >&2 + echo "$branches" >&2 + exit 1 + fi else echo "Fetching branches with open PRs authored by you..." - branches=$(gh pr list --author "@me" --state open --json headRefName -q '.[].headRefName') + if ! branches=$(gh pr list --author "@me" --state open --json headRefName -q '.[].headRefName' 2>&1); then + echo "Error: Failed to list PRs. Is 'gh' installed and authenticated?" >&2 + echo "$branches" >&2 + exit 1 + fi fi # Iterate through branches and update them -echo "$branches" | while read -r branch; do +if [ -z "$branches" ]; then + echo "No branches found." + exit 0 +fi + +while read -r branch; do + # Skip empty lines + [ -z "$branch" ] && continue + echo "Processing branch: $branch" - # Checkout the branch - if ! git checkout "$branch"; then - echo "Error: Failed to checkout branch $branch. Skipping..." - continue + # Checkout the branch (create tracking branch if it only exists on remote) + if ! git checkout "$branch" 2>/dev/null; then + if ! git switch --track "origin/$branch" 2>/dev/null; then + echo "Error: Failed to checkout branch $branch. Skipping..." + continue + fi fi # Merge the reference branch into the current branch @@ -117,4 +144,4 @@ echo "$branches" | while read -r branch; do else echo "Error: Failed to push branch $branch. Skipping..." fi -done +done <<<"$branches" diff --git a/venv-now b/venv-now index e1d5d57..e086e15 100755 --- a/venv-now +++ b/venv-now @@ -5,11 +5,35 @@ return-or-exit() { [[ $SOURCED == true ]] && return "$1" || exit "$1" } +# Pure validation function - returns 0 if path is dangerous, 1 if safe +# Can be tested safely without triggering any file operations +# Parameters: +# $1 - venv_dir: the raw directory argument +# $2 - resolved_dir: the resolved/normalized path +# $3 - current_dir: the current working directory +is_dangerous_venv_path() { + local venv_dir="$1" + local resolved_dir="$2" + local current_dir="$3" + + [[ -z "$venv_dir" || "$venv_dir" == "." || "$venv_dir" == ".." || + "$venv_dir" == "/" || "$venv_dir" == "~" || + "$resolved_dir" == "$HOME" || "$resolved_dir" == "/" || + "$resolved_dir" == "$current_dir" ]] +} + +# When sourced with VENV_NOW_SOURCE_ONLY=1, only export functions (for testing) +# This allows tests to call is_dangerous_venv_path() without triggering file ops +if [[ "${VENV_NOW_SOURCE_ONLY:-}" == "1" ]]; then + return 0 +fi + VENV_DIR=".venv" REMOVE_EXISTING=true SCRIPT_NAME=$(basename "$0") usage() { + local exit_code=${1:-0} cat <&2 - usage + usage 1 ;; *) if [ "$positional_arg_set" = false ]; then @@ -55,20 +79,51 @@ while [[ $# -gt 0 ]]; do positional_arg_set=true else echo "Error: Multiple directory arguments specified: '$VENV_DIR' and '$1'" >&2 - usage + usage 1 fi shift ;; esac done +# Find python executable (prefer python3 for systems without python symlink) +PYTHON_CMD="python" +if ! command -v python &>/dev/null && command -v python3 &>/dev/null; then + PYTHON_CMD="python3" +fi + +# Global safety check: refuse obviously dangerous targets +# Resolve and normalize the target virtual environment path safely, +# even if the directory does not exist yet. +RESOLVED_VENV_DIR="$( + "$PYTHON_CMD" - "$VENV_DIR" <<'EOF' +from pathlib import Path +import sys + +# Path.resolve() resolves '..' and symlinks without requiring the +# final path component to exist (strict=False is the default in 3.6+). +print(Path(sys.argv[1]).resolve(strict=False)) +EOF +)" || { + echo "Error: Failed to resolve path '$VENV_DIR'" >&2 + return-or-exit 1 +} + +# Get current directory for comparison +CURRENT_DIR="$(pwd -P)" + +if is_dangerous_venv_path "$VENV_DIR" "$RESOLVED_VENV_DIR" "$CURRENT_DIR"; then + echo "Error: Refusing to use dangerous path: '$VENV_DIR' (resolves to '$RESOLVED_VENV_DIR')" >&2 + return-or-exit 1 +fi + if $REMOVE_EXISTING && [ -d "$VENV_DIR" ]; then echo "Removing existing virtual environment: $VENV_DIR" - rm -rf "$VENV_DIR" + rm -rf -- "$VENV_DIR" fi echo "Creating virtual environment in: $VENV_DIR" -python -m venv "$VENV_DIR" +"$PYTHON_CMD" -m venv "$VENV_DIR" if [ -f "$VENV_DIR/bin/activate" ]; then echo "Virtual environment created: $VENV_DIR"