diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3d26fa0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.json] +insert_final_newline = ignore + +[Makefile] +indent_style = tab + +# Shell scripts — shfmt reads these properties +[*.sh] +switch_case_indent = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..9a0b7d1 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,57 @@ +name: check + +on: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Find bash scripts + id: find-bash + run: | + bash_files="" + # .sh files with bash shebangs + for f in $(find . -name '*.sh' -not -path './.git/*' -not -path './node_modules/*'); do + shebang=$(head -1 "$f") + if echo "$shebang" | grep -qE '(bash|^#!/bin/sh)'; then + bash_files="$bash_files $f" + fi + done + # bin/ scripts without .sh extension + for f in $(find ./bin -maxdepth 1 -type f -not -name '*.sh' -not -path './.git/*'); do + shebang=$(head -1 "$f") + if echo "$shebang" | grep -qE '(bash|^#!/bin/sh)'; then + bash_files="$bash_files $f" + fi + done + echo "files=$bash_files" >> "$GITHUB_OUTPUT" + echo "Found bash files:$bash_files" + + - name: ShellCheck + if: steps.find-bash.outputs.files != '' + run: | + bash_files="${{ steps.find-bash.outputs.files }}" + echo "Running ShellCheck on:$bash_files" + shellcheck $bash_files + + - name: shfmt check + if: steps.find-bash.outputs.files != '' + run: | + bash_files="${{ steps.find-bash.outputs.files }}" + echo "Running shfmt on:$bash_files" + # -d shows diff; indent settings sourced from .editorconfig + shfmt -d $bash_files + + - name: EditorConfig check + uses: editorconfig-checker/action-editorconfig-checker@main + + - name: Run editorconfig-checker + run: editorconfig-checker diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..144a3e1 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,11 @@ +# ShellCheck configuration for nsheaps/git-wt +# https://www.shellcheck.net/wiki/ + +# Default shell dialect — all scripts in this repo are bash. +shell=bash + +# SC1091: Not following sourced files — sourced paths are dynamic +disable=SC1091 + +# SC2034: Variable appears unused — some variables are used in tests or exported +disable=SC2034 diff --git a/bin/git-wt b/bin/git-wt index 0a08b5a..2a84013 100755 --- a/bin/git-wt +++ b/bin/git-wt @@ -60,10 +60,10 @@ SCAN_DIR="${HOME}/src" TARGET_BRANCH="" DELETE_MODE=false FORCE_DELETE=false -EXEC_SHELL="" # Empty means use default based on context +EXEC_SHELL="" # Empty means use default based on context UPDATE_CHECK_FILE="/tmp/git-wt-update-check-$$" PR_CACHE_DIR="/tmp/git-wt-pr-cache-$$" -export PR_CACHE_DIR # Export for fzf preview subshell +export PR_CACHE_DIR # Export for fzf preview subshell # Detect if running interactively (TTY on stdin) IS_INTERACTIVE=false @@ -98,9 +98,9 @@ check_for_updates() { if [[ -n "$latest_version" ]] && [[ "$latest_version" != "$current_version" ]]; then # Simple version comparison - check if latest is newer # This works for semver: compare as strings after normalizing - if [[ "$(printf '%s\n%s' "$current_version" "$latest_version" | sort -V | tail -1)" == "$latest_version" ]] && \ - [[ "$current_version" != "$latest_version" ]]; then - echo "UPDATE_AVAILABLE=$latest_version" > "$UPDATE_CHECK_FILE" + if [[ "$(printf '%s\n%s' "$current_version" "$latest_version" | sort -V | tail -1)" == "$latest_version" ]] && + [[ "$current_version" != "$latest_version" ]]; then + echo "UPDATE_AVAILABLE=$latest_version" >"$UPDATE_CHECK_FILE" fi fi fi @@ -142,26 +142,44 @@ show_help() { while [[ $# -gt 0 ]]; do case "$1" in - --scan-dir) SCAN_DIR="$2"; shift 2 ;; - --exec) EXEC_SHELL="yes"; shift ;; - --no-exec) EXEC_SHELL="no"; shift ;; - --force) FORCE_DELETE=true; shift ;; - -h|--help) show_help ;; - -v|--version) echo "git-wt $(get_version)"; exit 0 ;; - d) - DELETE_MODE=true - shift - # Next arg should be the branch name - if [[ $# -gt 0 ]] && [[ "$1" != -* ]]; then - TARGET_BRANCH="$1" - shift - fi - ;; - -*) echo "Unknown option: $1" >&2; show_help ;; - *) + --scan-dir) + SCAN_DIR="$2" + shift 2 + ;; + --exec) + EXEC_SHELL="yes" + shift + ;; + --no-exec) + EXEC_SHELL="no" + shift + ;; + --force) + FORCE_DELETE=true + shift + ;; + -h | --help) show_help ;; + -v | --version) + echo "git-wt $(get_version)" + exit 0 + ;; + d) + DELETE_MODE=true + shift + # Next arg should be the branch name + if [[ $# -gt 0 ]] && [[ "$1" != -* ]]; then TARGET_BRANCH="$1" shift - ;; + fi + ;; + -*) + echo "Unknown option: $1" >&2 + show_help + ;; + *) + TARGET_BRANCH="$1" + shift + ;; esac done @@ -170,9 +188,9 @@ done # Interactive mode (no args, TTY): default to exec (spawn shell) if [[ -z "$EXEC_SHELL" ]]; then if [[ -n "$TARGET_BRANCH" ]]; then - EXEC_SHELL="no" # CLI mode: print path by default + EXEC_SHELL="no" # CLI mode: print path by default else - EXEC_SHELL="yes" # Interactive mode: spawn shell by default + EXEC_SHELL="yes" # Interactive mode: spawn shell by default fi fi @@ -181,8 +199,8 @@ check_tool() { if ! command -v "$1" &>/dev/null; then echo "Error: $1 is required but not installed" >&2 case "$1" in - fzf) echo "Install with: brew install fzf" >&2 ;; - gum) echo "Install with: brew install gum" >&2 ;; + fzf) echo "Install with: brew install fzf" >&2 ;; + gum) echo "Install with: brew install gum" >&2 ;; esac exit 1 fi @@ -207,7 +225,7 @@ confirm() { input_prompt() { local placeholder="${1:-Enter value}" local result - result=$(fzf --print-query --header="$placeholder" --height=3 --no-info < /dev/null 2>/dev/null | head -1) + result=$(fzf --print-query --header="$placeholder" --height=3 --no-info /dev/null | head -1) echo "$result" } @@ -407,7 +425,8 @@ select_repo() { switch_to_branch() { local branch="$1" local git_root="$2" - local repo_name=$(basename "$git_root") + local repo_name + repo_name=$(basename "$git_root") # Check if branch exists locally local branch_exists_local=false @@ -424,14 +443,17 @@ switch_to_branch() { fi # Determine worktree path - local safe_branch=$(echo "$branch" | tr '/' '-') + local safe_branch + safe_branch=$(echo "$branch" | tr '/' '-') local worktree_path="../${repo_name}.worktrees/${safe_branch}" # Check if worktree already exists local existing_worktree="" while IFS= read -r line; do - local wt_path=$(echo "$line" | cut -d' ' -f1) - local wt_branch=$(git -C "$wt_path" branch --show-current 2>/dev/null || echo "") + local wt_path + wt_path=$(echo "$line" | cut -d' ' -f1) + local wt_branch + wt_branch=$(git -C "$wt_path" branch --show-current 2>/dev/null || echo "") if [[ "$wt_branch" == "$branch" ]]; then existing_worktree="$wt_path" break @@ -460,7 +482,8 @@ switch_to_branch() { echo "Branch: $branch (tracking $remote_ref)" else # Branch doesn't exist - create new branch - local default_branch=$(git -C "$git_root" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main") + local default_branch + default_branch=$(git -C "$git_root" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main") local base_branch="$default_branch" if [[ "$IS_INTERACTIVE" == "true" ]]; then @@ -469,7 +492,8 @@ switch_to_branch() { # Ask what to base it on local base_options=("$default_branch (default)" "other...") - local base_selected=$(printf '%s\n' "${base_options[@]}" | fzf --header="Create new branch based on:" --height=5 --no-info) + local base_selected + base_selected=$(printf '%s\n' "${base_options[@]}" | fzf --header="Create new branch based on:" --height=5 --no-info) if [[ "$base_selected" == "other..."* ]]; then base_branch=$(git -C "$git_root" branch -a --format='%(refname:short)' | fzf --prompt="Select base branch> ") @@ -556,7 +580,7 @@ fi # Setup for PR preview caching mkdir -p "$PR_CACHE_DIR" -export GIT_ROOT # Export for fzf preview subshell +export GIT_ROOT # Export for fzf preview subshell # If branch specified via CLI, create/switch to that worktree if [[ -n "$TARGET_BRANCH" ]]; then @@ -647,13 +671,15 @@ MENU_OPTIONS+=("🔄 (switch repository)") # Show selection menu with fzf # Preview shows PR info from cache, falls back to single fetch if not cached yet KEY_PRESSED="" -FZF_RESULT=$(printf '%s\n' "${MENU_OPTIONS[@]}" | fzf \ - --style=full \ - --expect=d \ - --no-sort \ - --header="enter=select, d=delete worktree" \ - --prompt="Select or create worktree> " \ - --preview=' +# shellcheck disable=SC2016 +FZF_RESULT=$( + printf '%s\n' "${MENU_OPTIONS[@]}" | fzf \ + --style=full \ + --expect=d \ + --no-sort \ + --header="enter=select, d=delete worktree" \ + --prompt="Select or create worktree> " \ + --preview=' item={} # Extract branch name from different formats if [[ "$item" == "📂 [worktree]"* ]] || [[ "$item" == "🏠 [root]"* ]]; then @@ -684,7 +710,8 @@ FZF_RESULT=$(printf '%s\n' "${MENU_OPTIONS[@]}" | fzf \ echo "No PR found for branch: $branch" fi ' \ - --preview-window=right:50%:wrap) + --preview-window=right:50%:wrap +) KEY_PRESSED=$(echo "$FZF_RESULT" | head -1) SELECTED=$(echo "$FZF_RESULT" | tail -1) @@ -704,6 +731,7 @@ if [[ "$KEY_PRESSED" == "d" ]]; then fi # Extract worktree path + # shellcheck disable=SC2001 WT_PATH=$(echo "$SELECTED" | sed 's/.*→ //') WT_BRANCH=$(echo "$SELECTED" | sed 's/📂 \[worktree\] //' | sed 's/ →.*//') @@ -739,90 +767,92 @@ fi # Handle selection case "$SELECTED" in - "📁 (create new worktree)") - # Get branch name - BRANCH_NAME=$(input_prompt "Enter new branch name") - if [[ -z "$BRANCH_NAME" ]]; then - echo "Cancelled" - exit 0 - fi +"📁 (create new worktree)") + # Get branch name + BRANCH_NAME=$(input_prompt "Enter new branch name") + if [[ -z "$BRANCH_NAME" ]]; then + echo "Cancelled" + exit 0 + fi - # Ask for base branch - CURRENT_BRANCH=$(git -C "$GIT_ROOT" branch --show-current) - DEFAULT_BRANCH=$(git -C "$GIT_ROOT" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main") + # Ask for base branch + CURRENT_BRANCH=$(git -C "$GIT_ROOT" branch --show-current) + DEFAULT_BRANCH=$(git -C "$GIT_ROOT" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main") - BASE_OPTIONS=("$DEFAULT_BRANCH (default)" "$CURRENT_BRANCH (current)" "other...") - BASE_SELECTED=$(printf '%s\n' "${BASE_OPTIONS[@]}" | fzf --header="Base branch:" --height=6 --no-info) + BASE_OPTIONS=("$DEFAULT_BRANCH (default)" "$CURRENT_BRANCH (current)" "other...") + BASE_SELECTED=$(printf '%s\n' "${BASE_OPTIONS[@]}" | fzf --header="Base branch:" --height=6 --no-info) - case "$BASE_SELECTED" in - *"(default)"*) BASE_BRANCH="$DEFAULT_BRANCH" ;; - *"(current)"*) BASE_BRANCH="$CURRENT_BRANCH" ;; - *) BASE_BRANCH=$(git -C "$GIT_ROOT" branch -a --format='%(refname:short)' | fzf --prompt="Select base branch> ") ;; - esac + case "$BASE_SELECTED" in + *"(default)"*) BASE_BRANCH="$DEFAULT_BRANCH" ;; + *"(current)"*) BASE_BRANCH="$CURRENT_BRANCH" ;; + *) BASE_BRANCH=$(git -C "$GIT_ROOT" branch -a --format='%(refname:short)' | fzf --prompt="Select base branch> ") ;; + esac - # Create worktree path - SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-') - WORKTREE_PATH="../${REPO_NAME}.worktrees/${SAFE_BRANCH}" + # Create worktree path + SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-') + WORKTREE_PATH="../${REPO_NAME}.worktrees/${SAFE_BRANCH}" - # Create the worktree - mkdir -p "$(dirname "$GIT_ROOT/$WORKTREE_PATH")" - git -C "$GIT_ROOT" worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "$BASE_BRANCH" + # Create the worktree + mkdir -p "$(dirname "$GIT_ROOT/$WORKTREE_PATH")" + git -C "$GIT_ROOT" worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "$BASE_BRANCH" - FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" - echo "" - echo "Created worktree at: $FINAL_PATH" - echo "Branch: $BRANCH_NAME (based on $BASE_BRANCH)" - ;; - - "🏠 [root]"*) - # Extract path from selection - FINAL_PATH=$(echo "$SELECTED" | sed 's/.*→ //') - echo "" - echo "Selected root checkout: $FINAL_PATH" - ;; + FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" + echo "" + echo "Created worktree at: $FINAL_PATH" + echo "Branch: $BRANCH_NAME (based on $BASE_BRANCH)" + ;; + +"🏠 [root]"*) + # Extract path from selection + # shellcheck disable=SC2001 + FINAL_PATH=$(echo "$SELECTED" | sed 's/.*→ //') + echo "" + echo "Selected root checkout: $FINAL_PATH" + ;; - "📂 [worktree]"*) - # Extract path from selection - FINAL_PATH=$(echo "$SELECTED" | sed 's/.*→ //') - echo "" - echo "Selected worktree: $FINAL_PATH" - ;; +"📂 [worktree]"*) + # Extract path from selection + # shellcheck disable=SC2001 + FINAL_PATH=$(echo "$SELECTED" | sed 's/.*→ //') + echo "" + echo "Selected worktree: $FINAL_PATH" + ;; + +"🔄 (switch repository)") + # Reset and re-run with repo selection + NEW_REPO=$(select_repo) + if [[ -n "$NEW_REPO" ]] && [[ -d "$NEW_REPO" ]]; then + exec "$0" --scan-dir "$SCAN_DIR" + fi + ;; - "🔄 (switch repository)") - # Reset and re-run with repo selection - NEW_REPO=$(select_repo) - if [[ -n "$NEW_REPO" ]] && [[ -d "$NEW_REPO" ]]; then - exec "$0" --scan-dir "$SCAN_DIR" - fi - ;; +*) + # Selected an existing branch - create worktree for it + BRANCH_NAME="$SELECTED" - *) - # Selected an existing branch - create worktree for it - BRANCH_NAME="$SELECTED" + SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-') + WORKTREE_PATH="../${REPO_NAME}.worktrees/${SAFE_BRANCH}" - SAFE_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-') - WORKTREE_PATH="../${REPO_NAME}.worktrees/${SAFE_BRANCH}" + if [[ -d "$GIT_ROOT/$WORKTREE_PATH" ]]; then + FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" + echo "Worktree already exists at: $FINAL_PATH" + else + mkdir -p "$(dirname "$GIT_ROOT/$WORKTREE_PATH")" - if [[ -d "$GIT_ROOT/$WORKTREE_PATH" ]]; then - FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" - echo "Worktree already exists at: $FINAL_PATH" + # Check if branch exists locally or only on remote + if git -C "$GIT_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then + git -C "$GIT_ROOT" worktree add "$WORKTREE_PATH" "$BRANCH_NAME" + elif git -C "$GIT_ROOT" show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME" 2>/dev/null; then + git -C "$GIT_ROOT" worktree add --track -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$BRANCH_NAME" else - mkdir -p "$(dirname "$GIT_ROOT/$WORKTREE_PATH")" - - # Check if branch exists locally or only on remote - if git -C "$GIT_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then - git -C "$GIT_ROOT" worktree add "$WORKTREE_PATH" "$BRANCH_NAME" - elif git -C "$GIT_ROOT" show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME" 2>/dev/null; then - git -C "$GIT_ROOT" worktree add --track -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$BRANCH_NAME" - else - git -C "$GIT_ROOT" worktree add "$WORKTREE_PATH" "$BRANCH_NAME" - fi - FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" - echo "Created worktree at: $FINAL_PATH" + git -C "$GIT_ROOT" worktree add "$WORKTREE_PATH" "$BRANCH_NAME" fi + FINAL_PATH="$(cd "$GIT_ROOT/$WORKTREE_PATH" && pwd)" + echo "Created worktree at: $FINAL_PATH" + fi - echo "Branch: $BRANCH_NAME" - ;; + echo "Branch: $BRANCH_NAME" + ;; esac # Change to worktree directory and start new shell diff --git a/mise.toml b/mise.toml index 6207403..ee7bb1e 100644 --- a/mise.toml +++ b/mise.toml @@ -7,10 +7,18 @@ description = "Run CLI tests" run = "bash test/cli-test.sh" [tasks.lint] -description = "Check bash syntax" -run = "bash -n bin/git-wt" +description = "Run ShellCheck on all bash scripts" +run = "shellcheck bin/git-wt test/cli-test.sh" + +[tasks.fmt] +description = "Format all bash scripts with shfmt" +run = "shfmt -w bin/git-wt test/cli-test.sh" + +[tasks.fmt-check] +description = "Check formatting of all bash scripts" +run = "shfmt -d bin/git-wt test/cli-test.sh" [tasks.check] -description = "Run all checks (lint + test)" -depends = ["lint", "test"] +description = "Run all checks (lint + fmt-check + test)" +depends = ["lint", "fmt-check", "test"] run = "echo 'All checks passed'" diff --git a/test/cli-test.sh b/test/cli-test.sh index 3016bc2..e5eb3a9 100755 --- a/test/cli-test.sh +++ b/test/cli-test.sh @@ -24,7 +24,7 @@ setup() { cd "$TEST_DIR/test-repo" git config user.email "test@test.com" git config user.name "Test User" - echo "initial" > README.md + echo "initial" >README.md git add README.md git commit -m "Initial commit" >/dev/null 2>&1