From 31941354e4310dbf8f42bc84b8c6eb614f7664ad Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Tue, 23 Dec 2025 22:44:38 -0800 Subject: [PATCH 01/66] chore(.yamllint): drop yamlfmt --- .yamllint | 1 - 1 file changed, 1 deletion(-) 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" From 817b77920c9744ac98715b719a5d1618fe101e6a Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Tue, 23 Dec 2025 23:59:05 -0800 Subject: [PATCH 02/66] test: add tests --- .github/workflows/CI.yml | 10 +++++ Makefile | 92 ++++++++++++++++++++++++++++++++++++++ tests/ach.bats | 66 +++++++++++++++++++++++++++ tests/chdirx.bats | 75 +++++++++++++++++++++++++++++++ tests/git-shed.bats | 52 ++++++++++++++++++++++ tests/how-big.bats | 57 ++++++++++++++++++++++++ tests/mergewith.bats | 56 +++++++++++++++++++++++ tests/test_helper.bash | 76 +++++++++++++++++++++++++++++++ tests/touchx.bats | 61 +++++++++++++++++++++++++ tests/update-mine.bats | 51 +++++++++++++++++++++ tests/venv-now.bats | 96 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 692 insertions(+) create mode 100644 Makefile create mode 100755 tests/ach.bats create mode 100755 tests/chdirx.bats create mode 100755 tests/git-shed.bats create mode 100755 tests/how-big.bats create mode 100755 tests/mergewith.bats create mode 100755 tests/test_helper.bash create mode 100755 tests/touchx.bats create mode 100755 tests/update-mine.bats create mode 100755 tests/venv-now.bats diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 352e37e..6e3ea1b 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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6aa84d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +.ONESHELL: +.WAIT: + +DEBUG ?= false +VERBOSE ?= false + +ifeq ($(DEBUG),true) + MAKEFLAGS += --debug=v +else ifeq ($(VERBOSE),false) + 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) +_COLOR := $(shell tput sgr0 2>/dev/null || echo "\033[0m") +BOLD := $(shell tput bold 2>/dev/null || echo "\033[1m") +CYAN := $(shell tput setaf 6 2>/dev/null || echo "\033[36m") +GREEN := $(shell tput setaf 2 2>/dev/null || echo "\033[32m") +RED := $(shell tput setaf 1 2>/dev/null || echo "\033[31m") +YELLOW := $(shell tput setaf 3 2>/dev/null || echo "\033[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: ## Install 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 + @git lfs install --local; \ + current_branch=$$(git branch --show-current) && \ + if ! git diff --quiet || ! git diff --cached --quiet; then \ + git stash push -m "Auto stash before switching to main"; \ + stash_was_needed=1; \ + else \ + stash_was_needed=0; \ + fi; \ + git switch main && git pull && \ + git lfs pull && git switch $$current_branch; \ + if [ $$stash_was_needed -eq 1 ]; then \ + git stash pop; \ + fi + @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: run-pre-commit +run-pre-commit: ## Run the pre-commit checks + $(PRECOMMIT) run --all-files + +.PHONY: enable-pre-commit +enable-pre-commit: ## Enable pre-commit hooks (along with commit-msg and pre-push hooks) + @pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push diff --git a/tests/ach.bats b/tests/ach.bats new file mode 100755 index 0000000..221576a --- /dev/null +++ b/tests/ach.bats @@ -0,0 +1,66 @@ +#!/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:" +} + +@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" +} + +@test "ach: fails with invalid hash" { + setup_git_repo + + run "$SCRIPTS_DIR/ach" "invalidhash123" + [ "$status" -ne 0 ] +} diff --git a/tests/chdirx.bats b/tests/chdirx.bats new file mode 100755 index 0000000..cf40eb6 --- /dev/null +++ b/tests/chdirx.bats @@ -0,0 +1,75 @@ +#!/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:" +} + +@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/git-shed.bats b/tests/git-shed.bats new file mode 100755 index 0000000..4d088a4 --- /dev/null +++ b/tests/git-shed.bats @@ -0,0 +1,52 @@ +#!/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:" +} + +@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 checkout -b feature-branch + echo "feature" >feature.txt + git add feature.txt + git commit -m "Add feature" + git checkout 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..226a47d --- /dev/null +++ b/tests/how-big.bats @@ -0,0 +1,57 @@ +#!/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:" +} + +@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 + + # On macOS, du -a and -d are mutually exclusive, so this will fail + # This test documents the current behavior + run "$SCRIPTS_DIR/how-big" -a testdir + # On macOS this fails (exit 64), on Linux it might work + # Just verify the script runs (may fail on some systems) + [[ "$status" -eq 0 || "$status" -eq 64 ]] +} diff --git a/tests/mergewith.bats b/tests/mergewith.bats new file mode 100755 index 0000000..c0a5ef3 --- /dev/null +++ b/tests/mergewith.bats @@ -0,0 +1,56 @@ +#!/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:" +} + +@test "mergewith: fails when no reference branch specified" { + setup_git_repo + + run "$SCRIPTS_DIR/mergewith" + [ "$status" -eq 0 ] # usage exits with 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 ../remote.git + git clone --bare . ../remote.git + git remote add origin ../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 with unknown option" { + run "$SCRIPTS_DIR/mergewith" --unknown + [ "$status" -eq 0 ] # usage exits with 0 + assert_output_contains "Unknown option" +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100755 index 0000000..cdc233e --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,76 @@ +#!/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 + +# Setup function - runs before each test +setup() { + # Create a temporary directory for test files + TEST_TEMP_DIR="$(mktemp -d)" + cd "$TEST_TEMP_DIR" || exit 1 + + # For venv-now tests: if python3 exists but python doesn't, create a symlink + if command -v python3 &>/dev/null && ! command -v python &>/dev/null; then + mkdir -p "$TEST_TEMP_DIR/bin" + ln -s "$(command -v python3)" "$TEST_TEMP_DIR/bin/python" + export PATH="$TEST_TEMP_DIR/bin:$PATH" + fi +} + +# 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 +} + +# 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 a string contains a substring +assert_output_contains() { + local expected="$1" + local actual="${2:-$output}" # Use parameter if provided, otherwise fall back to $output + if [[ "$actual" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" + echo "Actual output: $actual" + 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..921e87d --- /dev/null +++ b/tests/touchx.bats @@ -0,0 +1,61 @@ +#!/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:" +} + +@test "touchx: displays usage when no arguments provided" { + run "$SCRIPTS_DIR/touchx" + [ "$status" -eq 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" -eq 0 ] # usage exits with 0 + assert_output_contains "Unknown option" +} diff --git a/tests/update-mine.bats b/tests/update-mine.bats new file mode 100755 index 0000000..5a22a62 --- /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 + [[ "$output" != *"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 + [[ "$output" != *"Unknown option"* ]] +} + +@test "update-mine: fails with unknown option" { + run "$SCRIPTS_DIR/update-mine" --unknown main + [ "$status" -eq 0 ] # usage exits with 0 + assert_output_contains "Usage" +} diff --git a/tests/venv-now.bats b/tests/venv-now.bats new file mode 100755 index 0000000..32ffa9c --- /dev/null +++ b/tests/venv-now.bats @@ -0,0 +1,96 @@ +#!/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:" +} + +@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" -eq 0 ] # usage exits with 0 + assert_output_contains "Unknown option" +} From a18fbbc42daffe899b4c1eddff7ba78f0b1b62a8 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 00:19:54 -0800 Subject: [PATCH 03/66] docs(Makefile): clarify a target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6aa84d3..abc7aea 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ help: ## Show this help message .PHONY: develop WITH_HOOKS ?= true -develop: ## Install the project for development (WITH_HOOKS={true|false}, default=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 From cb985e1e2d89e9bc5e36f8b93b9c71cfc566f28b Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 05:41:48 -0800 Subject: [PATCH 04/66] chore(Makefile): match to boilerplate --- Makefile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index abc7aea..8aec94a 100644 --- a/Makefile +++ b/Makefile @@ -21,13 +21,13 @@ ifneq ($(shell command -v prek >/dev/null 2>&1 && echo y),) endif endif -# Terminal formatting (tput with fallbacks) +# Terminal formatting (tput with fallbacks to ANSI codes) _COLOR := $(shell tput sgr0 2>/dev/null || echo "\033[0m") BOLD := $(shell tput bold 2>/dev/null || echo "\033[1m") -CYAN := $(shell tput setaf 6 2>/dev/null || echo "\033[36m") -GREEN := $(shell tput setaf 2 2>/dev/null || echo "\033[32m") -RED := $(shell tput setaf 1 2>/dev/null || echo "\033[31m") -YELLOW := $(shell tput setaf 3 2>/dev/null || echo "\033[33m") +CYAN := $(shell tput setaf 6 2>/dev/null || echo "\033[0;36m") +GREEN := $(shell tput setaf 2 2>/dev/null || echo "\033[0;32m") +RED := $(shell tput setaf 1 2>/dev/null || echo "\033[0;31m") +YELLOW := $(shell tput setaf 3 2>/dev/null || echo "\033[0;33m") .DEFAULT_GOAL := help .PHONY: help @@ -52,7 +52,7 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default git config --local --add include.path "$(CURDIR)/.gitconfigs/alias"; \ fi @git config blame.ignoreRevsFile .git-blame-ignore-revs - @git lfs install --local; \ + @command -v git-lfs >/dev/null 2>&1 && git lfs install --local || true; \ current_branch=$$(git branch --show-current) && \ if ! git diff --quiet || ! git diff --cached --quiet; then \ git stash push -m "Auto stash before switching to main"; \ @@ -61,7 +61,7 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default stash_was_needed=0; \ fi; \ git switch main && git pull && \ - git lfs pull && git switch $$current_branch; \ + (command -v git-lfs >/dev/null 2>&1 && git lfs pull || true) && git switch "$$current_branch"; \ if [ $$stash_was_needed -eq 1 ]; then \ git stash pop; \ fi From aaab38296efcaf2b0fc77f69a48770ca052d8079 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 06:00:44 -0800 Subject: [PATCH 05/66] build(Makefile): improve make develop --- Makefile | 52 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 8aec94a..a528985 100644 --- a/Makefile +++ b/Makefile @@ -52,19 +52,45 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default git config --local --add include.path "$(CURDIR)/.gitconfigs/alias"; \ fi @git config blame.ignoreRevsFile .git-blame-ignore-revs - @command -v git-lfs >/dev/null 2>&1 && git lfs install --local || true; \ - current_branch=$$(git branch --show-current) && \ - if ! git diff --quiet || ! git diff --cached --quiet; then \ - git stash push -m "Auto stash before switching to main"; \ - stash_was_needed=1; \ - else \ - stash_was_needed=0; \ - fi; \ - git switch main && git pull && \ - (command -v git-lfs >/dev/null 2>&1 && git lfs pull || true) && git switch "$$current_branch"; \ - if [ $$stash_was_needed -eq 1 ]; then \ - git stash pop; \ - fi + @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)Warning: Still on $$(git branch --show-current). Attempting to return to $$current_branch...$(_COLOR)"; \ + if git switch "$$current_branch" 2>/dev/null; then \ + echo "Successfully returned to $$current_branch"; \ + else \ + echo "$(YELLOW)Could not return to $$current_branch. You are on $$(git branch --show-current).$(_COLOR)"; \ + 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 "$(YELLOW)Warning: 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 From 276b5544cf09c88e8448b900019e7a78e5d22621 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 06:12:10 -0800 Subject: [PATCH 06/66] chore(Makefile): match to updated boilerplate --- Makefile | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index a528985..988af18 100644 --- a/Makefile +++ b/Makefile @@ -109,10 +109,15 @@ test: ## Run all tests (PARALLEL={true|false}, default=true) .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 - -.PHONY: enable-pre-commit -enable-pre-commit: ## Enable pre-commit hooks (along with commit-msg and pre-push hooks) - @pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push From ab24b7cd99291bb79c6c509467da6acf4c048c23 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 06:42:49 -0800 Subject: [PATCH 07/66] fix(Makefile): fix verbosity --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 988af18..88b5c8d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERBOSE ?= false ifeq ($(DEBUG),true) MAKEFLAGS += --debug=v -else ifeq ($(VERBOSE),false) +else ifneq ($(VERBOSE),true) MAKEFLAGS += --silent endif From 30fd8ecad8ec7318d4c27bcded5428a69a3a0987 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 06:50:16 -0800 Subject: [PATCH 08/66] refactor: exit on error instead of warning --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 88b5c8d..d4e0423 100644 --- a/Makefile +++ b/Makefile @@ -87,7 +87,7 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default if git stash apply; then \ git stash drop; \ else \ - echo "$(YELLOW)Warning: Stash apply had conflicts. Resolve them, then run: git stash drop$(_COLOR)"; \ + echo "$(RED)Error: Stash apply had conflicts. Resolve them, then run: git stash drop$(_COLOR)"; \ fi; \ fi; \ trap - EXIT From 89b7ff0edff79998faaae19abc94a03293cbf950 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 03:39:07 -0800 Subject: [PATCH 09/66] chore: address Copilot feedback --- mergewith | 9 +++++---- tests/ach.bats | 4 +++- tests/chdirx.bats | 1 + tests/git-shed.bats | 1 + tests/how-big.bats | 1 + tests/mergewith.bats | 5 +++-- tests/test_helper.bash | 5 ++++- tests/touchx.bats | 5 +++-- tests/update-mine.bats | 2 +- tests/venv-now.bats | 3 ++- touchx | 9 +++++---- update-mine | 7 ++++--- venv-now | 7 ++++--- 13 files changed, 37 insertions(+), 22 deletions(-) diff --git a/mergewith b/mergewith index fb3183b..9972a4e 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 diff --git a/tests/ach.bats b/tests/ach.bats index 221576a..5cf9285 100755 --- a/tests/ach.bats +++ b/tests/ach.bats @@ -19,6 +19,7 @@ load 'test_helper' 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" { @@ -55,7 +56,8 @@ load 'test_helper' 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" + run grep -q "# Initial commit" ".git-blame-ignore-revs" + [ "$status" -ne 0 ] } @test "ach: fails with invalid hash" { diff --git a/tests/chdirx.bats b/tests/chdirx.bats index cf40eb6..3bcaa05 100755 --- a/tests/chdirx.bats +++ b/tests/chdirx.bats @@ -17,6 +17,7 @@ load 'test_helper' run "$SCRIPTS_DIR/chdirx" -h [ "$status" -eq 0 ] assert_output_contains "Usage:" + assert_output_contains "directory" } @test "chdirx: fails when no directory specified" { diff --git a/tests/git-shed.bats b/tests/git-shed.bats index 4d088a4..f38f83f 100755 --- a/tests/git-shed.bats +++ b/tests/git-shed.bats @@ -17,6 +17,7 @@ load 'test_helper' 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" { diff --git a/tests/how-big.bats b/tests/how-big.bats index 226a47d..e4c10c3 100755 --- a/tests/how-big.bats +++ b/tests/how-big.bats @@ -17,6 +17,7 @@ load 'test_helper' 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" { diff --git a/tests/mergewith.bats b/tests/mergewith.bats index c0a5ef3..e69d6e5 100755 --- a/tests/mergewith.bats +++ b/tests/mergewith.bats @@ -17,13 +17,14 @@ load 'test_helper' 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" -eq 0 ] # usage exits with 0 + [ "$status" -ne 0 ] assert_output_contains "reference_branch" } @@ -51,6 +52,6 @@ load 'test_helper' @test "mergewith: fails with unknown option" { run "$SCRIPTS_DIR/mergewith" --unknown - [ "$status" -eq 0 ] # usage exits with 0 + [ "$status" -ne 0 ] assert_output_contains "Unknown option" } diff --git a/tests/test_helper.bash b/tests/test_helper.bash index cdc233e..da0d9e6 100755 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -9,7 +9,7 @@ export SCRIPTS_DIR setup() { # Create a temporary directory for test files TEST_TEMP_DIR="$(mktemp -d)" - cd "$TEST_TEMP_DIR" || exit 1 + cd "$TEST_TEMP_DIR" || return 1 # For venv-now tests: if python3 exists but python doesn't, create a symlink if command -v python3 &>/dev/null && ! command -v python &>/dev/null; then @@ -38,6 +38,9 @@ setup_git_repo() { } # Helper to check if a string contains a substring +# Parameters: +# $1 - expected substring +# $2 - (optional) string to search; defaults to $output from the most recent Bats run assert_output_contains() { local expected="$1" local actual="${2:-$output}" # Use parameter if provided, otherwise fall back to $output diff --git a/tests/touchx.bats b/tests/touchx.bats index 921e87d..5c33725 100755 --- a/tests/touchx.bats +++ b/tests/touchx.bats @@ -17,11 +17,12 @@ load 'test_helper' 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" -eq 0 ] + [ "$status" -ne 0 ] assert_output_contains "Usage:" } @@ -56,6 +57,6 @@ load 'test_helper' @test "touchx: fails with unknown option" { run "$SCRIPTS_DIR/touchx" --unknown - [ "$status" -eq 0 ] # usage exits with 0 + [ "$status" -ne 0 ] assert_output_contains "Unknown option" } diff --git a/tests/update-mine.bats b/tests/update-mine.bats index 5a22a62..d5238a5 100755 --- a/tests/update-mine.bats +++ b/tests/update-mine.bats @@ -46,6 +46,6 @@ load 'test_helper' @test "update-mine: fails with unknown option" { run "$SCRIPTS_DIR/update-mine" --unknown main - [ "$status" -eq 0 ] # usage exits with 0 + [ "$status" -ne 0 ] assert_output_contains "Usage" } diff --git a/tests/venv-now.bats b/tests/venv-now.bats index 32ffa9c..55a7326 100755 --- a/tests/venv-now.bats +++ b/tests/venv-now.bats @@ -17,6 +17,7 @@ load 'test_helper' 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" { @@ -91,6 +92,6 @@ load 'test_helper' @test "venv-now: fails with unknown option" { run "$SCRIPTS_DIR/venv-now" --unknown - [ "$status" -eq 0 ] # usage exits with 0 + [ "$status" -ne 0 ] # unknown option should exit with non-zero status assert_output_contains "Unknown option" } 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..747aebd 100755 --- a/update-mine +++ b/update-mine @@ -8,6 +8,7 @@ SCRIPT_NAME=$(basename "$0") # Function to display usage information usage() { + local exit_code=${1:-0} cat < @@ -30,7 +31,7 @@ Examples: $SCRIPT_NAME --all main $SCRIPT_NAME --debug --all main EOF - exit 0 + exit "$exit_code" } # Parse command-line arguments @@ -50,14 +51,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 ;; diff --git a/venv-now b/venv-now index e1d5d57..7cb3349 100755 --- a/venv-now +++ b/venv-now @@ -10,6 +10,7 @@ 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,7 +56,7 @@ 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 ;; From 9daa8e342d3ac478d7d3d4bee296432a7c8a0f7f Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 07:28:59 -0800 Subject: [PATCH 10/66] fix(tests): update assertion for unknown option --- tests/update-mine.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/update-mine.bats b/tests/update-mine.bats index d5238a5..a7589a4 100755 --- a/tests/update-mine.bats +++ b/tests/update-mine.bats @@ -47,5 +47,5 @@ load 'test_helper' @test "update-mine: fails with unknown option" { run "$SCRIPTS_DIR/update-mine" --unknown main [ "$status" -ne 0 ] - assert_output_contains "Usage" + assert_output_contains "Unknown option" } From 2b8830d8b2e1563e11545d27a4659aff76090382 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:35:19 +0000 Subject: [PATCH 11/66] chore: prefer switch over checkout Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/git-shed.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/git-shed.bats b/tests/git-shed.bats index f38f83f..84e8897 100755 --- a/tests/git-shed.bats +++ b/tests/git-shed.bats @@ -30,11 +30,11 @@ load 'test_helper' @test "git-shed: --dry-run shows what would be deleted without deleting" { setup_git_repo - git checkout -b feature-branch + git switch -c feature-branch echo "feature" >feature.txt git add feature.txt git commit -m "Add feature" - git checkout main + git switch main git merge feature-branch run "$SCRIPTS_DIR/git-shed" --dry-run -y main From 741a74fc817f6406837084fd9d4607a09a83f1f7 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 08:42:03 -0800 Subject: [PATCH 12/66] chore(ach): clarify a message --- ach | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ach b/ach index 8eff1bb..3f27e8e 100755 --- a/ach +++ b/ach @@ -104,7 +104,7 @@ 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." + echo "Staged changes stashed (kept in index)." fi { From 5c4851d410c91146ae4c22589c3873d1a897784c Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 08:44:33 -0800 Subject: [PATCH 13/66] fix(git-shed): update string search --- git-shed | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git-shed b/git-shed index d957787..196d35a 100755 --- a/git-shed +++ b/git-shed @@ -63,9 +63,9 @@ git fetch --prune # Identify local branches fully merged into the target branch. MERGED_BRANCHES=$(git branch --merged "$TARGET_BRANCH" | - grep -v "$TARGET_BRANCH" | grep -v "^\*" | - sed 's/^ //') + sed 's/^ //' | + grep -v "^${TARGET_BRANCH}\$") if [ -z "$MERGED_BRANCHES" ]; then echo "No local branches merged into '$TARGET_BRANCH' found. Nothing to clean." From 5230615971e32e536bc909138fd0355106d2914d Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 08:47:23 -0800 Subject: [PATCH 14/66] fix: improve portability --- how-big | 18 +++++++++++++----- tests/how-big.bats | 8 +++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/how-big b/how-big index f150d81..1098a37 100755 --- a/how-big +++ b/how-big @@ -26,14 +26,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 +48,12 @@ 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 +if [[ $SHOW_FILES == true ]]; then + # Cross-platform: use find + du for files, then du for directories + { + find "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + + du -h -d 1 "$TARGET_DIR" + } 2>/dev/null | sort -hr | uniq +else + du -h -d 1 "$TARGET_DIR" | sort -hr +fi diff --git a/tests/how-big.bats b/tests/how-big.bats index e4c10c3..55eb6dd 100755 --- a/tests/how-big.bats +++ b/tests/how-big.bats @@ -49,10 +49,8 @@ load 'test_helper' mkdir -p testdir echo "content" >testdir/file.txt - # On macOS, du -a and -d are mutually exclusive, so this will fail - # This test documents the current behavior run "$SCRIPTS_DIR/how-big" -a testdir - # On macOS this fails (exit 64), on Linux it might work - # Just verify the script runs (may fail on some systems) - [[ "$status" -eq 0 || "$status" -eq 64 ]] + [ "$status" -eq 0 ] + # Verify it shows the file (cross-platform fix uses find + du) + assert_output_contains "file.txt" } From 81c6349a7c5fee59b6a832626e1b3df347fc8eac Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 08:47:57 -0800 Subject: [PATCH 15/66] fix(update-mine): handle empty branches --- update-mine | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/update-mine b/update-mine index 747aebd..b5e901e 100755 --- a/update-mine +++ b/update-mine @@ -96,7 +96,15 @@ else fi # Iterate through branches and update them +if [ -z "$branches" ]; then + echo "No branches found." + exit 0 +fi + echo "$branches" | while read -r branch; do + # Skip empty lines + [ -z "$branch" ] && continue + echo "Processing branch: $branch" # Checkout the branch From ae3fde40aa8ac9e09a9ea535b56e04ef6c36d467 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:02:33 -0800 Subject: [PATCH 16/66] test(tests/gcfixup.bats): add a test --- tests/gcfixup.bats | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100755 tests/gcfixup.bats diff --git a/tests/gcfixup.bats b/tests/gcfixup.bats new file mode 100755 index 0000000..24ac523 --- /dev/null +++ b/tests/gcfixup.bats @@ -0,0 +1,53 @@ +#!/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 "fixup 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: creates fixup commit for valid hash" { + setup_git_repo + + # Get the initial commit hash + local initial_hash + initial_hash=$(git rev-parse HEAD) + + # Make a new change to fixup + echo "change" >>README.md + git add README.md + + # Run gcfixup (will fail on rebase since we're in non-interactive mode, but commit should work) + # We use GIT_SEQUENCE_EDITOR to auto-proceed with rebase + GIT_SEQUENCE_EDITOR=true run "$SCRIPTS_DIR/gcfixup" "$initial_hash" + + # Verify the fixup commit was created (check git log for fixup! prefix) + run git log --oneline -2 + assert_output_contains "fixup!" +} From 5cfbf6f1cb93fe702958ebf0d7016a19e67d979c Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:06:17 -0800 Subject: [PATCH 17/66] feat(how-big): improve security --- how-big | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/how-big b/how-big index 1098a37..e6d363b 100755 --- a/how-big +++ b/how-big @@ -51,9 +51,9 @@ fi if [[ $SHOW_FILES == true ]]; then # Cross-platform: use find + du for files, then du for directories { - find "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + - du -h -d 1 "$TARGET_DIR" + find -- "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + + du -h -d 1 -- "$TARGET_DIR" } 2>/dev/null | sort -hr | uniq else - du -h -d 1 "$TARGET_DIR" | sort -hr + du -h -d 1 -- "$TARGET_DIR" | sort -hr fi From d0ee5f50ab6fce9e6e9410a83881868de2b91c86 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:16:50 -0800 Subject: [PATCH 18/66] fix(git-shed): fix grep pattern for target branch --- git-shed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-shed b/git-shed index 196d35a..b0bd11c 100755 --- a/git-shed +++ b/git-shed @@ -65,7 +65,7 @@ git fetch --prune MERGED_BRANCHES=$(git branch --merged "$TARGET_BRANCH" | grep -v "^\*" | sed 's/^ //' | - grep -v "^${TARGET_BRANCH}\$") + grep -v -F -x "$TARGET_BRANCH") if [ -z "$MERGED_BRANCHES" ]; then echo "No local branches merged into '$TARGET_BRANCH' found. Nothing to clean." From 33d7959cfc5cafa2d124a0aeac088f7bc7fc44a7 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:25:42 -0800 Subject: [PATCH 19/66] fix(ach): remove index from stash message --- ach | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ach b/ach index 3f27e8e..d27262c 100755 --- a/ach +++ b/ach @@ -102,9 +102,9 @@ 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" + git stash push -m "Temporary stash for $SCRIPT_NAME" STASHED=true - echo "Staged changes stashed (kept in index)." + echo "Staged changes stashed." fi { From 74e7866638ff9a623274fd2b3a1ddcc17c8facb0 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:26:28 -0800 Subject: [PATCH 20/66] fix(gcfixup): add warning for root commit --- gcfixup | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gcfixup b/gcfixup index c257149..1511b06 100755 --- a/gcfixup +++ b/gcfixup @@ -37,6 +37,12 @@ EOF 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 "Warning: Cannot autosquash root commit. Fixup commit created but not squashed." + return 0 + fi + git rebase -i --autosquash --autostash "$commit_hash"~1 } From adaba93414ae17232355a8f3d780a59b6ef24501 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:27:07 -0800 Subject: [PATCH 21/66] fix(git-shed): add sed for whitespace handling --- git-shed | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/git-shed b/git-shed index b0bd11c..6b5dd5e 100755 --- a/git-shed +++ b/git-shed @@ -91,7 +91,8 @@ else fi # Identify local branches that no longer have a remote. -STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | awk '{print $1}') +# Use sed to strip leading whitespace and *, then awk to get branch name +STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ]*//' | awk '{print $1}') if [ -z "$STALE_BRANCHES" ]; then echo "No stale branches (i.e., branches with no remote) found." From b7b33328351186818b46dcd46e2f6abb8331b12f Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:27:43 -0800 Subject: [PATCH 22/66] fix(update-mine): clarify --all option usage --- update-mine | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/update-mine b/update-mine index b5e901e..77ba655 100755 --- a/update-mine +++ b/update-mine @@ -19,7 +19,8 @@ Updates branches with open pull requests authored by you by: 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: @@ -88,7 +89,7 @@ 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..." + echo "Fetching all non-protected branches (use with caution)..." branches=$(gh api repos/:owner/:repo/branches --paginate --jq '.[] | select(.protected == false) | .name') else echo "Fetching branches with open PRs authored by you..." From d051b8652eb37e1745d6ad88d12312b8f4b8d196 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:28:27 -0800 Subject: [PATCH 23/66] fix(venv-now): add python fallback --- venv-now | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/venv-now b/venv-now index 7cb3349..a9fec55 100755 --- a/venv-now +++ b/venv-now @@ -68,8 +68,14 @@ if $REMOVE_EXISTING && [ -d "$VENV_DIR" ]; then rm -rf "$VENV_DIR" fi +# 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 + 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" From 58a7cd21e8b569656a887cddbfff813fe0da6f5e Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 17:41:18 +0000 Subject: [PATCH 24/66] chore: remove unused WAIT Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Michael I Chen --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index d4e0423..523e239 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ .ONESHELL: -.WAIT: DEBUG ?= false VERBOSE ?= false From 86ea3e777c7330285559d9c93c59a5b5785b00ef Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:48:35 -0800 Subject: [PATCH 25/66] fix(mergewith): add upstream guard --- mergewith | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mergewith b/mergewith index 9972a4e..1de42d1 100755 --- a/mergewith +++ b/mergewith @@ -69,10 +69,15 @@ 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 From d9c45544862ecae4fd18409d0a39addb2b18c2d4 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:49:04 -0800 Subject: [PATCH 26/66] feat(update-mine): improve error handling --- update-mine | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/update-mine b/update-mine index 77ba655..1232e70 100755 --- a/update-mine +++ b/update-mine @@ -90,10 +90,18 @@ git remote update >/dev/null 2>&1 # Determine the list of branches to update if [ "$all_branches" = true ]; then echo "Fetching all non-protected branches (use with caution)..." - branches=$(gh api repos/:owner/:repo/branches --paginate --jq '.[] | select(.protected == false) | .name') + 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 @@ -108,10 +116,12 @@ echo "$branches" | while read -r branch; do 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 From 8b2738965bd99374ab4610531aa52e6e71a837ba Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 09:49:29 -0800 Subject: [PATCH 27/66] feat(venv-now): reject dangerous paths --- venv-now | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/venv-now b/venv-now index a9fec55..1c89b6f 100755 --- a/venv-now +++ b/venv-now @@ -64,6 +64,13 @@ while [[ $# -gt 0 ]]; do done if $REMOVE_EXISTING && [ -d "$VENV_DIR" ]; then + # Safety check: reject dangerous paths + case "$VENV_DIR" in + "" | "." | ".." | "/" | "~" | "$HOME") + echo "Error: Refusing to remove dangerous path: '$VENV_DIR'" >&2 + return-or-exit 1 + ;; + esac echo "Removing existing virtual environment: $VENV_DIR" rm -rf "$VENV_DIR" fi From 4dbf6a53b70278202ddcbe1af66e84117c67f802 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 10:09:33 -0800 Subject: [PATCH 28/66] fix(chdirx): skip symlinks --- chdirx | 2 ++ 1 file changed, 2 insertions(+) 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 From 305fd7fd5e10126c8ee83d9d98ea9e92321e0d08 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 10:10:24 -0800 Subject: [PATCH 29/66] fix(git-shed): compare against the remote --- git-shed | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/git-shed b/git-shed index 6b5dd5e..c109f28 100755 --- a/git-shed +++ b/git-shed @@ -61,11 +61,13 @@ 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" | +# Identify local branches fully merged into the remote target branch. +# Using origin/$TARGET_BRANCH ensures we compare against the up-to-date remote. +MERGED_BRANCHES=$(git branch --merged "origin/$TARGET_BRANCH" 2>/dev/null || + git branch --merged "$TARGET_BRANCH" | grep -v "^\*" | - sed 's/^ //' | - grep -v -F -x "$TARGET_BRANCH") + 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." From 13b7963dab17461c586c61fe99b6af8ea67c9cbd Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 10:10:59 -0800 Subject: [PATCH 30/66] feat(how-big): remove pipefail --- how-big | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/how-big b/how-big index e6d363b..08020c9 100755 --- a/how-big +++ b/how-big @@ -1,5 +1,6 @@ #!/bin/bash -set -euo pipefail +set -eu +# Note: pipefail intentionally omitted to show partial results on permission errors SCRIPT_NAME=$(basename "$0") From 8a132e1be9fbb979121f66d2c948b85618bf411a Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 10:11:19 -0800 Subject: [PATCH 31/66] fix(mergewith): handle detached HEAD --- mergewith | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mergewith b/mergewith index 1de42d1..74b3356 100755 --- a/mergewith +++ b/mergewith @@ -67,6 +67,12 @@ if ! current_branch=$(git branch --show-current); then exit 1 fi +# Abort if in detached HEAD state +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" # Only pull if an upstream is configured From c5608069ffd3fcb5c92646332b39eb2c81daadd3 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 10:12:07 -0800 Subject: [PATCH 32/66] test(venv-now): reject dangerous paths --- tests/venv-now.bats | 21 +++++++++++++++++++++ venv-now | 8 +++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/venv-now.bats b/tests/venv-now.bats index 55a7326..32c56e5 100755 --- a/tests/venv-now.bats +++ b/tests/venv-now.bats @@ -95,3 +95,24 @@ load 'test_helper' [ "$status" -ne 0 ] # unknown option should exit with non-zero status assert_output_contains "Unknown option" } + +@test "venv-now: rejects dangerous path ." { + mkdir -p testdir + cd testdir + run "$SCRIPTS_DIR/venv-now" . + [ "$status" -ne 0 ] + assert_output_contains "dangerous path" +} + +@test "venv-now: rejects dangerous path /" { + run "$SCRIPTS_DIR/venv-now" / + [ "$status" -ne 0 ] + assert_output_contains "dangerous path" +} + +@test "venv-now: rejects path resolving to HOME" { + # $HOME/. resolves to $HOME + run "$SCRIPTS_DIR/venv-now" "$HOME/." + [ "$status" -ne 0 ] + assert_output_contains "dangerous path" +} diff --git a/venv-now b/venv-now index 1c89b6f..194b554 100755 --- a/venv-now +++ b/venv-now @@ -66,11 +66,17 @@ done if $REMOVE_EXISTING && [ -d "$VENV_DIR" ]; then # Safety check: reject dangerous paths case "$VENV_DIR" in - "" | "." | ".." | "/" | "~" | "$HOME") + "" | "." | ".." | "/" | "~") echo "Error: Refusing to remove dangerous path: '$VENV_DIR'" >&2 return-or-exit 1 ;; esac + # Resolve and normalize path to catch $HOME/, $HOME/., etc. + resolved_path=$(cd "$VENV_DIR" 2>/dev/null && pwd -P) + if [[ "$resolved_path" == "$HOME" || "$resolved_path" == "/" ]]; then + echo "Error: Refusing to remove dangerous path: '$VENV_DIR' (resolves to '$resolved_path')" >&2 + return-or-exit 1 + fi echo "Removing existing virtual environment: $VENV_DIR" rm -rf "$VENV_DIR" fi From 9ddd14207fa0582d212e6aa303f97f1a5af2f226 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 11:00:51 -0800 Subject: [PATCH 33/66] test(tests/ach.bats): add a regression test --- tests/ach.bats | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/ach.bats b/tests/ach.bats index 5cf9285..0c1989a 100755 --- a/tests/ach.bats +++ b/tests/ach.bats @@ -66,3 +66,33 @@ load 'test_helper' 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 + git diff --cached --name-only | grep -q "unrelated.txt" + + # 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 (restored from stash) + git diff --cached --name-only | grep -q "unrelated.txt" +} From fa481277b5a4cf42e28da6b5242750bfb2b15d53 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 11:12:00 -0800 Subject: [PATCH 34/66] ci(.github/workflows/CI.yml): bump CACHE_NUMBER --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6e3ea1b..d85c21c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -45,7 +45,7 @@ jobs: 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 From ac4e8ec580e476416b9da6afc081309eb2a29542 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 11:19:12 -0800 Subject: [PATCH 35/66] chore: improve portability of color codes --- Makefile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 523e239..699c9b6 100644 --- a/Makefile +++ b/Makefile @@ -21,12 +21,12 @@ ifneq ($(shell command -v prek >/dev/null 2>&1 && echo y),) endif # Terminal formatting (tput with fallbacks to ANSI codes) -_COLOR := $(shell tput sgr0 2>/dev/null || echo "\033[0m") -BOLD := $(shell tput bold 2>/dev/null || echo "\033[1m") -CYAN := $(shell tput setaf 6 2>/dev/null || echo "\033[0;36m") -GREEN := $(shell tput setaf 2 2>/dev/null || echo "\033[0;32m") -RED := $(shell tput setaf 1 2>/dev/null || echo "\033[0;31m") -YELLOW := $(shell tput setaf 3 2>/dev/null || echo "\033[0;33m") +_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 From 010db322b458eaf9da331948cdfaba3394a1dd0c Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 11:29:51 -0800 Subject: [PATCH 36/66] fix(ci): correct pre-commit installation command --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d85c21c..3e0893e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -52,7 +52,7 @@ jobs: - name: Install pre-commit hooks 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 From f839b18eb51a775d98caf0068b29fa76c4567d16 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 11:30:30 -0800 Subject: [PATCH 37/66] fix(tests/mergewith.bats): update remote repo path --- tests/mergewith.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/mergewith.bats b/tests/mergewith.bats index e69d6e5..fa8a644 100755 --- a/tests/mergewith.bats +++ b/tests/mergewith.bats @@ -39,9 +39,9 @@ load 'test_helper' setup_git_repo # Set up proper tracking so git pull works - rm -rf ../remote.git - git clone --bare . ../remote.git - git remote add origin ../remote.git + 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 From 5a35f55bc4345a280d8613a65136762260c05728 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 12:03:37 -0800 Subject: [PATCH 38/66] chore(tests/gcfixup.bats): clarify a parameter --- gcfixup | 4 ++-- tests/gcfixup.bats | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gcfixup b/gcfixup index 1511b06..8126987 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: diff --git a/tests/gcfixup.bats b/tests/gcfixup.bats index 24ac523..1f903ee 100755 --- a/tests/gcfixup.bats +++ b/tests/gcfixup.bats @@ -10,7 +10,7 @@ load 'test_helper' run "$SCRIPTS_DIR/gcfixup" --help [ "$status" -eq 0 ] assert_output_contains "Usage:" - assert_output_contains "fixup commit hash" + assert_output_contains "target commit hash" } @test "gcfixup: -h displays usage information" { From ea63a2e6c4cc8fe9b108d9bc20903b9d70defb65 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 12:10:36 -0800 Subject: [PATCH 39/66] fix(git-shed): use subshells --- git-shed | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/git-shed b/git-shed index c109f28..2b34c5a 100755 --- a/git-shed +++ b/git-shed @@ -63,11 +63,12 @@ git fetch --prune # Identify local branches fully merged into the remote target branch. # Using origin/$TARGET_BRANCH ensures we compare against the up-to-date remote. -MERGED_BRANCHES=$(git branch --merged "origin/$TARGET_BRANCH" 2>/dev/null || - git branch --merged "$TARGET_BRANCH" | +# 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/^ //' | - grep -v -F -x "$TARGET_BRANCH") + 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." From 61d039178c457c80393d4963ad8914d8fcc613b6 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 13:19:56 -0800 Subject: [PATCH 40/66] perf: don't cache pip since we don't use it --- .github/workflows/CI.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3e0893e..9d8c5d9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -33,7 +33,6 @@ jobs: - uses: actions/setup-python@v6 with: check-latest: true - cache: "pip" - name: Install pre-commit run: | python3 -m pip install --upgrade pip From 582fbd17fbf372821f67508680728306aa9654c1 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 13:20:49 -0800 Subject: [PATCH 41/66] perf: remove date from cache key --- .github/workflows/CI.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9d8c5d9..c896ff4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,9 +37,6 @@ jobs: 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 @@ -47,7 +44,7 @@ jobs: 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 + key: ${{ runner.os }}-precommit-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.CACHE_NUMBER }} # yamllint disable-line rule:line-length - name: Install pre-commit hooks if: steps.cache-pre-commit-hooks.outputs.cache-hit != 'true' run: | From fe3b8375a3601b57b8cb4ba96bbf6b8fa25addd5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:05:30 +0000 Subject: [PATCH 42/66] fix(3611543692): address PR review feedback (#42) * Initial plan * fix: address review comments from thread 3611543692 Co-authored-by: michen00 <29467952+michen00@users.noreply.github.com> * fix: improve sed pattern and add path resolution error handling Co-authored-by: michen00 <29467952+michen00@users.noreply.github.com> * chore: autofix via pre-commit hooks for more information, see https://pre-commit.ci * chore(tests/test_helper.bash): address SC2154 * fix(gcfixup): avoid a race condition * perf: avoid redundant directory creation --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: michen00 <29467952+michen00@users.noreply.github.com> Co-authored-by: Michael I Chen Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Michael I Chen --- Makefile | 3 ++- gcfixup | 10 ++++----- git-shed | 2 +- how-big | 8 +++++-- tests/ach.bats | 8 ++++--- tests/test_helper.bash | 43 +++++++++++++++++++++++++++-------- tests/venv-now.bats | 6 ++--- venv-now | 51 +++++++++++++++++++++++++++--------------- 8 files changed, 89 insertions(+), 42 deletions(-) diff --git a/Makefile b/Makefile index 699c9b6..0b9f93d 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,8 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default if git switch "$$current_branch" 2>/dev/null; then \ echo "Successfully returned to $$current_branch"; \ else \ - echo "$(YELLOW)Could not return to $$current_branch. You are on $$(git branch --show-current).$(_COLOR)"; \ + echo "$(RED)Error: Could not return to $$current_branch. You are on $$(git branch --show-current).$(_COLOR)" >&2; \ + exit_code=1; \ fi; \ fi; \ if [ $$stash_was_needed -eq 1 ] && git stash list | head -1 | grep -q "Auto stash before switching to main"; then \ diff --git a/gcfixup b/gcfixup index 8126987..1912a9c 100755 --- a/gcfixup +++ b/gcfixup @@ -33,14 +33,14 @@ EOF local commit_hash="$1" shift # Remove the commit hash from the arguments - if ! git commit --fixup="$commit_hash" "$@"; then + # Check if this is the root commit (has no parent) + if ! git rev-parse --verify "$commit_hash"~1 &>/dev/null; then + echo "Error: Cannot create fixup for root commit." >&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 "Warning: Cannot autosquash root commit. Fixup commit created but not squashed." - return 0 + if ! git commit --fixup="$commit_hash" "$@"; then + return 1 fi git rebase -i --autosquash --autostash "$commit_hash"~1 diff --git a/git-shed b/git-shed index 2b34c5a..25571c2 100755 --- a/git-shed +++ b/git-shed @@ -95,7 +95,7 @@ fi # Identify local branches that no longer have a remote. # Use sed to strip leading whitespace and *, then awk to get branch name -STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ]*//' | awk '{print $1}') +STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ]\+//' | awk '{print $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 08020c9..2e0530e 100755 --- a/how-big +++ b/how-big @@ -1,6 +1,10 @@ #!/bin/bash set -eu -# Note: pipefail intentionally omitted to show partial results on permission errors +# Note: pipefail intentionally omitted. Permission errors on individual files +# are expected in some use cases (e.g., running without sudo on system dirs), +# and we want to show partial results for accessible files rather than failing +# completely. The sort and uniq commands should succeed as long as they receive +# at least some input. SCRIPT_NAME=$(basename "$0") @@ -49,7 +53,7 @@ if [[ ! -d $TARGET_DIR ]]; then fi # Run the disk usage command with optional file inclusion -if [[ $SHOW_FILES == true ]]; then +if [[ "$SHOW_FILES" == true ]]; then # Cross-platform: use find + du for files, then du for directories { find -- "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + diff --git a/tests/ach.bats b/tests/ach.bats index 0c1989a..0c4e9cc 100755 --- a/tests/ach.bats +++ b/tests/ach.bats @@ -75,7 +75,8 @@ load 'test_helper' git add unrelated.txt # Verify it's staged - git diff --cached --name-only | grep -q "unrelated.txt" + run bash -c 'git diff --cached --name-only | grep -q "unrelated.txt"' + [ "$status" -eq 0 ] # Capture hash before ach runs local target_hash @@ -93,6 +94,7 @@ load 'test_helper' 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 (restored from stash) - git diff --cached --name-only | grep -q "unrelated.txt" + # unrelated.txt should NOT be staged (ach no longer preserves the index) + run bash -c 'git diff --cached --name-only | grep -q "unrelated.txt"' + [ "$status" -ne 0 ] } diff --git a/tests/test_helper.bash b/tests/test_helper.bash index da0d9e6..82756a7 100755 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -5,18 +5,43 @@ 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. +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 + + 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 - # For venv-now tests: if python3 exists but python doesn't, create a symlink - if command -v python3 &>/dev/null && ! command -v python &>/dev/null; then - mkdir -p "$TEST_TEMP_DIR/bin" - ln -s "$(command -v python3)" "$TEST_TEMP_DIR/bin/python" - export PATH="$TEST_TEMP_DIR/bin:$PATH" - fi + # Ensure a "python" command is available on PATH for tests that expect it. + ensure_python_symlink } # Teardown function - runs after each test @@ -37,13 +62,13 @@ setup_git_repo() { git commit -m "Initial commit" } -# Helper to check if a string contains a substring +# Helper to check if output contains a substring # Parameters: # $1 - expected substring -# $2 - (optional) string to search; defaults to $output from the most recent Bats run +# $2 - actual output (optional, defaults to $output from BATS) assert_output_contains() { local expected="$1" - local actual="${2:-$output}" # Use parameter if provided, otherwise fall back to $output + local actual="${2:-$output}" if [[ "$actual" != *"$expected"* ]]; then echo "Expected output to contain: $expected" echo "Actual output: $actual" diff --git a/tests/venv-now.bats b/tests/venv-now.bats index 32c56e5..42c95a8 100755 --- a/tests/venv-now.bats +++ b/tests/venv-now.bats @@ -96,10 +96,10 @@ load 'test_helper' assert_output_contains "Unknown option" } -@test "venv-now: rejects dangerous path ." { +@test "venv-now: rejects dangerous path resolving to ." { mkdir -p testdir - cd testdir - run "$SCRIPTS_DIR/venv-now" . + # Use a path that resolves to the current directory, but is not literally "." + run "$SCRIPTS_DIR/venv-now" "testdir/.." [ "$status" -ne 0 ] assert_output_contains "dangerous path" } diff --git a/venv-now b/venv-now index 194b554..7258a04 100755 --- a/venv-now +++ b/venv-now @@ -63,30 +63,45 @@ while [[ $# -gt 0 ]]; do esac done -if $REMOVE_EXISTING && [ -d "$VENV_DIR" ]; then - # Safety check: reject dangerous paths - case "$VENV_DIR" in - "" | "." | ".." | "/" | "~") - echo "Error: Refusing to remove dangerous path: '$VENV_DIR'" >&2 - return-or-exit 1 - ;; - esac - # Resolve and normalize path to catch $HOME/, $HOME/., etc. - resolved_path=$(cd "$VENV_DIR" 2>/dev/null && pwd -P) - if [[ "$resolved_path" == "$HOME" || "$resolved_path" == "/" ]]; then - echo "Error: Refusing to remove dangerous path: '$VENV_DIR' (resolves to '$resolved_path')" >&2 - return-or-exit 1 - fi - echo "Removing existing virtual environment: $VENV_DIR" - rm -rf "$VENV_DIR" -fi - # 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' +import os +import sys + +# os.path.realpath resolves '..' and symlinks without requiring the +# final path component to exist. +print(os.path.realpath(sys.argv[1])) +EOF +)" || { + echo "Error: Failed to resolve path '$VENV_DIR'" >&2 + return-or-exit 1 +} + +# Get current directory for comparison +CURRENT_DIR="$(pwd -P)" + +if [[ "$VENV_DIR" == "" || "$VENV_DIR" == "." || "$VENV_DIR" == ".." || + "$VENV_DIR" == "/" || "$VENV_DIR" == "~" || + "$RESOLVED_VENV_DIR" == "$HOME" || "$RESOLVED_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" +fi + echo "Creating virtual environment in: $VENV_DIR" "$PYTHON_CMD" -m venv "$VENV_DIR" From 836385e2742258c8c514ac63b0876e410510bdb6 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:33:11 -0800 Subject: [PATCH 43/66] fix(ach): address identified issues --- ach | 19 +------------------ tests/ach.bats | 4 ++-- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/ach b/ach index d27262c..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 -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/tests/ach.bats b/tests/ach.bats index 0c4e9cc..9eb3353 100755 --- a/tests/ach.bats +++ b/tests/ach.bats @@ -94,7 +94,7 @@ load 'test_helper' ach_commit_files=$(git diff-tree --no-commit-id --name-only -r HEAD) [ "$ach_commit_files" = ".git-blame-ignore-revs" ] - # unrelated.txt should NOT be staged (ach no longer preserves the index) + # 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" -ne 0 ] + [ "$status" -eq 0 ] } From 9634795a0bc56717843a146f614e74771f2233a2 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:33:22 -0800 Subject: [PATCH 44/66] fix(gcfixup): address identified issues --- gcfixup | 8 +++++++- tests/gcfixup.bats | 38 +++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/gcfixup b/gcfixup index 1912a9c..273eefc 100755 --- a/gcfixup +++ b/gcfixup @@ -33,9 +33,15 @@ 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 create fixup for root commit." >&2 + echo "Error: Cannot create fixup for root commit. (Root commits cannot be autosquashed without --root)" >&2 return 1 fi diff --git a/tests/gcfixup.bats b/tests/gcfixup.bats index 1f903ee..0fa03b2 100755 --- a/tests/gcfixup.bats +++ b/tests/gcfixup.bats @@ -35,19 +35,35 @@ load 'test_helper' @test "gcfixup: creates fixup commit for valid hash" { setup_git_repo - # Get the initial commit hash - local initial_hash - initial_hash=$(git rev-parse HEAD) + # 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 "change" >>README.md - git add README.md + 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 ] - # Run gcfixup (will fail on rebase since we're in non-interactive mode, but commit should work) - # We use GIT_SEQUENCE_EDITOR to auto-proceed with rebase - GIT_SEQUENCE_EDITOR=true run "$SCRIPTS_DIR/gcfixup" "$initial_hash" + # 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 fixup commit was created (check git log for fixup! prefix) - run git log --oneline -2 - assert_output_contains "fixup!" + # Verify the commit message of HEAD is still "Second commit" + run git log -1 --format=%s + [ "$output" = "Second commit" ] } From 4c5c6f88300e2cf8cec0d8fc647e045f2b44638b Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:35:36 -0800 Subject: [PATCH 45/66] fix(git-shed): address identified issues --- git-shed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-shed b/git-shed index 25571c2..fd05fae 100755 --- a/git-shed +++ b/git-shed @@ -95,7 +95,7 @@ fi # Identify local branches that no longer have a remote. # Use sed to strip leading whitespace and *, then awk to get branch name -STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ]\+//' | awk '{print $1}') +STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ][* ]*//' | awk '{print $1}') if [ -z "$STALE_BRANCHES" ]; then echo "No stale branches (i.e., branches with no remote) found." From 193809d1168671a61312355c04695d91d24e93fc Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:35:47 -0800 Subject: [PATCH 46/66] fix(how-big): address identified issues --- how-big | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/how-big b/how-big index 2e0530e..759c039 100755 --- a/how-big +++ b/how-big @@ -1,10 +1,8 @@ #!/bin/bash -set -eu -# Note: pipefail intentionally omitted. Permission errors on individual files -# are expected in some use cases (e.g., running without sudo on system dirs), -# and we want to show partial results for accessible files rather than failing -# completely. The sort and uniq commands should succeed as long as they receive -# at least some input. +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") @@ -31,14 +29,14 @@ EOF } # Default behavior (show directories only) -SHOW_FILES=false +SHOW_FILES="false" # Parse command-line arguments while [[ $# -gt 0 ]]; do case "$1" in - -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 + -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 @@ -53,12 +51,26 @@ if [[ ! -d $TARGET_DIR ]]; then fi # Run the disk usage command with optional file inclusion -if [[ "$SHOW_FILES" == true ]]; then +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 - { - find -- "$TARGET_DIR" -maxdepth 1 -type f -exec du -h {} + - du -h -d 1 -- "$TARGET_DIR" - } 2>/dev/null | sort -hr | uniq + 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 - du -h -d 1 -- "$TARGET_DIR" | sort -hr + 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 From b614dd345c859428f737956252c421cdb1b46ffb Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:36:08 -0800 Subject: [PATCH 47/66] fix(Makefile): address identified issues --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0b9f93d..6b7f192 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default cleanup() { \ exit_code=$$?; \ if [ "$$current_branch" != "$$(git branch --show-current)" ]; then \ - echo "$(YELLOW)Warning: Still on $$(git branch --show-current). Attempting to return to $$current_branch...$(_COLOR)"; \ + 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 \ From 2d4bf824c879377bc949d53402525b7bb67da71e Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:36:22 -0800 Subject: [PATCH 48/66] fix(venv-now): address identified issues --- venv-now | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/venv-now b/venv-now index 7258a04..a1f411d 100755 --- a/venv-now +++ b/venv-now @@ -74,12 +74,12 @@ fi # even if the directory does not exist yet. RESOLVED_VENV_DIR="$( "$PYTHON_CMD" - "$VENV_DIR" <<'EOF' -import os +from pathlib import Path import sys -# os.path.realpath resolves '..' and symlinks without requiring the -# final path component to exist. -print(os.path.realpath(sys.argv[1])) +# 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()) EOF )" || { echo "Error: Failed to resolve path '$VENV_DIR'" >&2 From 823388642a61a57d1c24f4845783272e7a1fc6d6 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:43:00 -0800 Subject: [PATCH 49/66] fix: address identified issues --- tests/test_helper.bash | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 82756a7..b10d43f 100755 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -7,7 +7,9 @@ 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. +# 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 @@ -25,6 +27,7 @@ ensure_python_symlink() { 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. @@ -65,13 +68,13 @@ setup_git_repo() { # Helper to check if output contains a substring # Parameters: # $1 - expected substring -# $2 - actual output (optional, defaults to $output from BATS) +# Note: $output is set by BATS 'run' command assert_output_contains() { local expected="$1" - local actual="${2:-$output}" - if [[ "$actual" != *"$expected"* ]]; then + # shellcheck disable=SC2154 # $output is set by BATS + if [[ "$output" != *"$expected"* ]]; then echo "Expected output to contain: $expected" - echo "Actual output: $actual" + echo "Actual output: $output" return 1 fi } From ec5f9ae846be8f640107a489160aa69d774677c9 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:51:52 -0800 Subject: [PATCH 50/66] docs(README.md): remove a TODO --- README.md | 4 ---- 1 file changed, 4 deletions(-) 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 From fa382fe4d909535a9acdf4afc252af67319a0c37 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 15:59:42 -0800 Subject: [PATCH 51/66] fix(git-shed): address identified issues --- git-shed | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/git-shed b/git-shed index fd05fae..bf22f68 100755 --- a/git-shed +++ b/git-shed @@ -94,8 +94,8 @@ else fi # Identify local branches that no longer have a remote. -# Use sed to strip leading whitespace and *, then awk to get branch name -STALE_BRANCHES=$(git branch -v | grep '\[gone\]' | sed 's/^[* ][* ]*//' | 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." From f5ffb0c1bbee8c96f97953edd73674032400270e Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:00:12 -0800 Subject: [PATCH 52/66] fix(update-mine): address identified issues --- update-mine | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/update-mine b/update-mine index 1232e70..a477c9c 100755 --- a/update-mine +++ b/update-mine @@ -84,6 +84,13 @@ 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 @@ -110,7 +117,7 @@ if [ -z "$branches" ]; then exit 0 fi -echo "$branches" | while read -r branch; do +while read -r branch; do # Skip empty lines [ -z "$branch" ] && continue @@ -137,4 +144,4 @@ echo "$branches" | while read -r branch; do else echo "Error: Failed to push branch $branch. Skipping..." fi -done +done <<<"$branches" From bd2078a5ac1ebf295ad26219f824dacb040283eb Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:13:18 -0800 Subject: [PATCH 53/66] fix(Makefile): improve error handling in cleanup --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6b7f192..77aa118 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ develop: ## Set up the project for development (WITH_HOOKS={true|false}, default 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; \ - exit_code=1; \ + 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 \ From 158e4029a0fcd60fc32281372e6b696cd7c8c2a6 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:13:44 -0800 Subject: [PATCH 54/66] docs(update-mine): clarify a usage string --- update-mine | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-mine b/update-mine index a477c9c..14cd26f 100755 --- a/update-mine +++ b/update-mine @@ -12,7 +12,7 @@ usage() { 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. From 066bfc6028da22c01036ef91e20df605fd092e12 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:21:02 -0800 Subject: [PATCH 55/66] fix: fix test cleanup --- tests/test_helper.bash | 22 ++++++++++++++++++++++ tests/update-mine.bats | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_helper.bash b/tests/test_helper.bash index b10d43f..819e7f4 100755 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -55,6 +55,14 @@ teardown() { 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 @@ -79,6 +87,20 @@ assert_output_contains() { 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" diff --git a/tests/update-mine.bats b/tests/update-mine.bats index a7589a4..7e21aa0 100755 --- a/tests/update-mine.bats +++ b/tests/update-mine.bats @@ -32,7 +32,7 @@ load 'test_helper' # 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 - [[ "$output" != *"Unknown option"* ]] + assert_output_not_contains "Unknown option" } @test "update-mine: accepts --all flag" { @@ -41,7 +41,7 @@ load 'test_helper' # 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 - [[ "$output" != *"Unknown option"* ]] + assert_output_not_contains "Unknown option" } @test "update-mine: fails with unknown option" { From 7b0f81f2a155ab79d4441986fdcbe2472b2754f7 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:32:52 -0800 Subject: [PATCH 56/66] refactor: improve test safety --- tests/venv-now.bats | 77 +++++++++++++++++++++++++++++++++++++-------- venv-now | 28 ++++++++++++++--- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/tests/venv-now.bats b/tests/venv-now.bats index 42c95a8..e1dcfa8 100755 --- a/tests/venv-now.bats +++ b/tests/venv-now.bats @@ -96,23 +96,74 @@ load 'test_helper' assert_output_contains "Unknown option" } -@test "venv-now: rejects dangerous path resolving to ." { - mkdir -p testdir - # Use a path that resolves to the current directory, but is not literally "." - run "$SCRIPTS_DIR/venv-now" "testdir/.." - [ "$status" -ne 0 ] - assert_output_contains "dangerous path" +# 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 "venv-now: rejects dangerous path /" { - run "$SCRIPTS_DIR/venv-now" / - [ "$status" -ne 0 ] - assert_output_contains "dangerous path" +@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 "venv-now: rejects path resolving to HOME" { - # $HOME/. resolves to $HOME - run "$SCRIPTS_DIR/venv-now" "$HOME/." +@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/venv-now b/venv-now index a1f411d..4c2dcce 100755 --- a/venv-now +++ b/venv-now @@ -5,6 +5,29 @@ 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") @@ -89,10 +112,7 @@ EOF # Get current directory for comparison CURRENT_DIR="$(pwd -P)" -if [[ "$VENV_DIR" == "" || "$VENV_DIR" == "." || "$VENV_DIR" == ".." || - "$VENV_DIR" == "/" || "$VENV_DIR" == "~" || - "$RESOLVED_VENV_DIR" == "$HOME" || "$RESOLVED_VENV_DIR" == "/" || - "$RESOLVED_VENV_DIR" == "$CURRENT_DIR" ]]; then +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 From 75c62c8e7fe80134e4b70f25ec7c59bf7a8f73a4 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Thu, 25 Dec 2025 00:45:13 +0000 Subject: [PATCH 57/66] ci: clarify a name Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Michael I Chen --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c896ff4..14aa106 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -45,7 +45,7 @@ jobs: with: path: ~/.cache/pre-commit key: ${{ runner.os }}-precommit-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.CACHE_NUMBER }} # yamllint disable-line rule:line-length - - name: Install pre-commit hooks + - name: Install pre-commit hook environments if: steps.cache-pre-commit-hooks.outputs.cache-hit != 'true' run: | pre-commit install-hooks From e1a08caae56d17d1ea2fc2fd88aa8dc79c1a1926 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Thu, 25 Dec 2025 00:46:06 +0000 Subject: [PATCH 58/66] refactor: use explicit default Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Michael I Chen --- venv-now | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/venv-now b/venv-now index 4c2dcce..442be6e 100755 --- a/venv-now +++ b/venv-now @@ -102,7 +102,7 @@ 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()) +print(Path(sys.argv[1]).resolve(strict=False)) EOF )" || { echo "Error: Failed to resolve path '$VENV_DIR'" >&2 From c6514fc9d69b1f148054ce944144f240df7dcf4c Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Thu, 25 Dec 2025 00:46:42 +0000 Subject: [PATCH 59/66] style: edit whitespace Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Michael I Chen --- how-big | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/how-big b/how-big index 759c039..a92903c 100755 --- a/how-big +++ b/how-big @@ -35,8 +35,8 @@ SHOW_FILES="false" while [[ $# -gt 0 ]]; do case "$1" in -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 + -h | --help) usage ;; # Show help and exit + *) TARGET_DIR="$1" ;; # Assume anything else is the directory argument esac shift done From e7fce0f0a2326280f78c201af79fdf018c559d38 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:46:54 +0000 Subject: [PATCH 60/66] chore: autofix via pre-commit hooks for more information, see https://pre-commit.ci --- how-big | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/how-big b/how-big index a92903c..759c039 100755 --- a/how-big +++ b/how-big @@ -35,8 +35,8 @@ SHOW_FILES="false" while [[ $# -gt 0 ]]; do case "$1" in -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 + -h | --help) usage ;; # Show help and exit + *) TARGET_DIR="$1" ;; # Assume anything else is the directory argument esac shift done From ba828328132b3eabdc9ba830b4d3cd9e0719eab8 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 16:46:52 -0800 Subject: [PATCH 61/66] docs(blame): ignore c6514fc --- .git-blame-ignore-revs | 1 + 1 file changed, 1 insertion(+) 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 From 69b05ecc1cccd59c54ffc1bd5792000912c3205c Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 17:06:00 -0800 Subject: [PATCH 62/66] docs(mergewith): add a comment --- mergewith | 1 + 1 file changed, 1 insertion(+) diff --git a/mergewith b/mergewith index 74b3356..0c7460a 100755 --- a/mergewith +++ b/mergewith @@ -68,6 +68,7 @@ if ! current_branch=$(git branch --show-current); then 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 From 47597f4e119d98f3802d20eff0436823eb8b7b76 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 17:06:38 -0800 Subject: [PATCH 63/66] test: improve coverage --- tests/ach.bats | 32 ++++++++++++++++++++++++++++++++ tests/how-big.bats | 34 ++++++++++++++++++++++++++++++++++ tests/mergewith.bats | 11 +++++++++++ 3 files changed, 77 insertions(+) diff --git a/tests/ach.bats b/tests/ach.bats index 9eb3353..2176817 100755 --- a/tests/ach.bats +++ b/tests/ach.bats @@ -98,3 +98,35 @@ load 'test_helper' 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/how-big.bats b/tests/how-big.bats index 55eb6dd..7ad490a 100755 --- a/tests/how-big.bats +++ b/tests/how-big.bats @@ -54,3 +54,37 @@ load 'test_helper' # 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 index fa8a644..7672bad 100755 --- a/tests/mergewith.bats +++ b/tests/mergewith.bats @@ -50,6 +50,17 @@ load 'test_helper' 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 ] From 78aaefef1b3be46d3729f34410dde2955136b344 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 17:07:37 -0800 Subject: [PATCH 64/66] fix(venv-now): use -- for directory name safety --- venv-now | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/venv-now b/venv-now index 442be6e..e086e15 100755 --- a/venv-now +++ b/venv-now @@ -119,7 +119,7 @@ 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" From 6cc6d92adda1195fc361cfcae18461866245cac7 Mon Sep 17 00:00:00 2001 From: Michael I Chen Date: Wed, 24 Dec 2025 17:07:59 -0800 Subject: [PATCH 65/66] fix(gcfixup): improve handling of root commits --- gcfixup | 3 ++- tests/gcfixup.bats | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/gcfixup b/gcfixup index 273eefc..741d994 100755 --- a/gcfixup +++ b/gcfixup @@ -41,7 +41,8 @@ EOF # Check if this is the root commit (has no parent) if ! git rev-parse --verify "$commit_hash"~1 &>/dev/null; then - echo "Error: Cannot create fixup for root commit. (Root commits cannot be autosquashed without --root)" >&2 + 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 diff --git a/tests/gcfixup.bats b/tests/gcfixup.bats index 0fa03b2..2b9d766 100755 --- a/tests/gcfixup.bats +++ b/tests/gcfixup.bats @@ -32,6 +32,20 @@ load 'test_helper' [ "$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 From 8f36cdcc1cc529430f45b12c4a46a59cdf756287 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:21:07 +0000 Subject: [PATCH 66/66] Initial plan