From d23d325693ba181aa50307ff464a9a4cc876063b Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 27 Apr 2026 08:39:26 -0700 Subject: [PATCH] easy worktree switching (#37) --- git-recent | 46 ++++++++++++-- git-watch | 144 ++++++++++++++++++++++++++++++++++++++++++ man/man1/git-recent.1 | 2 +- package.json | 3 +- readme.md | 24 +++++++ 5 files changed, 211 insertions(+), 8 deletions(-) create mode 100755 git-watch diff --git a/git-recent b/git-recent index e6084e7..1685033 100755 --- a/git-recent +++ b/git-recent @@ -19,7 +19,7 @@ if [[ "$1" == "--help" ]]; then echo "git-recent: Browse and checkout recently used Git branches." echo echo "Keybindings:" - echo " Enter: Checkout the selected branch" + echo " Enter: Checkout the selected branch (or jump to its worktree if open elsewhere)" echo " Ctrl-O: Show the diff of the selected branch against the main/master branch" echo " Ctrl-C: Exit" exit 0 @@ -29,7 +29,7 @@ fi # The HEAD of the primary branch (eg main or master or w/e), for diffing. # TODO: some branch mgmt approaches don't work well with this. And may prefer `git log --pretty=format:%H --merges -n 1`. See https://github.com/paulirish/git-recent/issues/28 -diff_base=$(cat $(git rev-parse --show-cdup).git/refs/remotes/origin/HEAD | awk '{print $2}') +diff_base=$(git symbolic-ref refs/remotes/origin/HEAD) # Extract branch name (without any trailing text, like the Chromium link) define_branchname="branchname=\\\$(echo {1} | cut -d' ' -f1)" @@ -55,6 +55,24 @@ elif command -v xsel >/dev/null; then copy_cmd="printf '%s' {} | xsel --clipboard" fi +_get_worktree_for_branch() { + local branch="$1" + local current_toplevel + current_toplevel=$(git rev-parse --show-toplevel 2>/dev/null) + + # Find the worktree path for the given branch, excluding the current one. + # Using substr to handle paths with spaces correctly. + git worktree list --porcelain | awk -v target="refs/heads/$branch" -v current="$current_toplevel" ' + /^worktree / { wt = substr($0, 10) } + /^branch / && $2 == target { + if (wt != current) { + print wt + exit + } + } + ' +} + YELLOW='\033[0;33m' DIM='\033[2m' NC='\033[0m' # No Color @@ -77,18 +95,26 @@ filterarg=${GIT_RECENT_QUERY:+"--filter=$GIT_RECENT_QUERY"} # Chromium hackers may want reference to their relevant CL. CL_STATUS=$([ "$show_cl" = true ] && git cl status --fast --no-branch-color | grep 'https://' | sed 's| (.*||') +# List of branches currently checked out in any worktree (including the current one) +WT_BRANCHES=$(git worktree list --porcelain | grep "^branch " | sed 's|^branch refs/heads/||') + _browse_branches() { git for-each-ref --sort=-authordate "$heads" --format="%(refname:short)" \ | while read -r branch_name; do + wt_symbol="" + if echo "$WT_BRANCHES" | grep -qFx "$branch_name"; then + wt_symbol=" ⇶" + fi + if [ "$show_cl" != true ]; then - printf "$YELLOW%s$NC\n" "$branch_name" + printf "$YELLOW%s$DIM%s$NC\n" "$branch_name" "$wt_symbol" continue fi review_url=$(echo "$CL_STATUS" | grep -E "\b${branch_name} :" | grep -o -E 'https://.*' | sed 's|https://||') # Using fancy integrated hyperlinks: https://iterm2.com/feature-reporting/Hyperlinks_in_Terminal_Emulators.html # TODO: maybe get rid of the crrev.com/c/ text as the link? # TODO: use `git config branch.$(git rev-parse --abbrev-ref HEAD).gerritissue` and gerritserver to avoid using `git cl status` - printf "$YELLOW%s $DIM\033]8;;%s\a%s\033]8;;\a$NC\n" "$branch_name" "https://$review_url" "$review_url" + printf "$YELLOW%s$DIM%s \033]8;;%s\a%s\033]8;;\a$NC\n" "$branch_name" "$wt_symbol" "https://$review_url" "$review_url" done \ | fzf \ $filterarg --ansi -- --layout=reverse --multi --height=90% --min-height=20 \ @@ -106,8 +132,16 @@ line_count=$(printf "%s" "$output" | wc -l) if [[ -n "$output" ]] && (( line_count == 0 )); then chosen_branch=$(echo "$output" | cut -d' ' -f1) - echo git checkout "$chosen_branch" - git checkout "$chosen_branch" + + wt_path=$(_get_worktree_for_branch "$chosen_branch") + + if [[ -n "$wt_path" ]]; then + echo "Branch '$chosen_branch' is open in worktree: $wt_path" >&2 + echo "$wt_path" + else + echo "git checkout $chosen_branch" >&2 + git checkout "$chosen_branch" + fi else echo "$output" fi diff --git a/git-watch b/git-watch new file mode 100755 index 0000000..153b910 --- /dev/null +++ b/git-watch @@ -0,0 +1,144 @@ +#!/bin/bash + +# +# git-watch: A streaming perspective of git changes and commits. +# +# Monitors the current repository for: +# - New commits (displays 'git show') +# - Working directory & index changes (displays 'git diff HEAD' and untracked files) +# +# Requires 'watchexec' for responsiveness. +# + +# Check if we're in a git repo +[ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] || { + echo "Error: Not a git repository." >&2 + exit 1 +} + +# Check for dependencies +if ! command -v watchexec >/dev/null 2>&1; then + echo "Error: 'watchexec' is required for git-watch. Please install it (e.g., 'brew install watchexec')." >&2 + exit 1 +fi + +if [[ "$1" == "--help" || "$1" == "-h" ]]; then + echo "git-watch: A streaming perspective of git changes and commits." + echo + echo "Usage: git-watch [--debug]" + echo + echo "Options:" + echo " --debug Log what would be run instead of running it" + echo + echo "Features:" + echo " - Shows 'git show' on new commits" + echo " - Shows 'git diff HEAD' on working tree/index changes" + echo " - Uses 'watchexec' for high-speed responsiveness" + echo " - Uses your fancy pager (delta/diff-so-fancy) without blocking" + exit 0 +fi + +DEBUG_MODE=false +if [[ "$1" == "--debug" || "$2" == "--debug" ]]; then + DEBUG_MODE=true +fi + +# Pager detection (compatible with delta, diff-so-fancy, etc.) +pager_cmd=$(command -v delta || command -v diff-so-fancy) +if [ -n "$pager_cmd" ]; then + if [[ "$pager_cmd" == *"delta"* ]]; then + pager_cmd="$pager_cmd --paging=never" + fi + pipe_to_pager=" | $pager_cmd" +fi + +git_run() { + if [ "$DEBUG_MODE" = true ]; then + echo -e "\033[0;35m[DEBUG] Would run: git --no-pager \"$@\" --color=always$pipe_to_pager\033[0m" + return + fi + eval "git --no-pager \"\$@\" --color=always$pipe_to_pager" +} + +# Cross-platform hashing for change detection +hash_cmd=$(command -v shasum || command -v sha1sum || echo "cksum") + +update() { + local mode="$1" + local git_dir state_file + git_dir=$(git rev-parse --git-dir 2>/dev/null) + [ -n "$git_dir" ] || return + state_file="$git_dir/watch-state" + + local current_head last_head current_state_hash last_state_hash + current_head=$(git rev-parse HEAD 2>/dev/null) + + # Capture status and diff for hashing (ignoring untracked files for now) + local status_output=$(git status --porcelain --untracked-files=no) + local diff_output=$(git diff HEAD 2>/dev/null) + # TODO: Re-evaluate untracked file support later. + current_state_hash=$( (echo "$status_output"; echo "$diff_output") | $hash_cmd | cut -d' ' -f1) + + if [[ -f "$state_file" && "$mode" != "--initial" ]]; then + read -r last_head last_state_hash < "$state_file" + fi + + if [ "$DEBUG_MODE" = true ]; then + echo -e "\033[0;35m[DEBUG] Mode: $mode\033[0m" + echo -e "\033[0;35m[DEBUG] HEAD: $current_head (Last: ${last_head:-NONE})\033[0m" + echo -e "\033[0;35m[DEBUG] State Hash: $current_state_hash (Last: ${last_state_hash:-NONE})\033[0m" + fi + + local did_something=false + + # 1. Commit detection + if [[ "$current_head" != "$last_head" || "$mode" == "--initial" ]]; then + echo -e "\n\033[1;34m=== Current Commit: $(git log -1 --format='%h %s' 2>/dev/null) ===\033[0m" + git_run show --summary --stat -p + did_something=true + fi + + # 2. Working directory detection + if [[ "$current_state_hash" != "$last_state_hash" || "$mode" == "--initial" ]]; then + if [[ -n "$status_output" ]]; then + echo -e "\n\033[1;32m=== Changes ===\033[0m" + + # Show diff of tracked changes + if [[ -n "$diff_output" ]]; then + git_run diff HEAD + fi + + # TODO: Implement surgical untracked file support here if needed. + did_something=true + fi + fi + + # Update state so we don't repeat this in the next --step + if [[ "$did_something" == "true" ]]; then + if [ "$DEBUG_MODE" = true ]; then + echo -e "\033[0;35m[DEBUG] Would update state file: $current_head $current_state_hash\033[0m" + else + echo "$current_head $current_state_hash" > "$state_file" + fi + fi +} + +# Internal flag for watchexec to call back into the script +if [[ "$1" == "--step" ]]; then + update "--step" + exit 0 +fi + +# Main execution loop +update "--initial" + +pass_debug="" +if [ "$DEBUG_MODE" = true ]; then + pass_debug="--debug" +fi + +echo -e "\033[0;32mWatching with watchexec...\033[0m" +# --postpone: don't run immediately on start (we already did update --initial) +# --quiet: suppress watchexec status messages +# exec is used so that Ctrl-C goes directly to watchexec and terminates the session +exec watchexec --quiet --postpone -i .git -w . -w "$(git rev-parse --git-dir)/HEAD" -- "$0" --step $pass_debug diff --git a/man/man1/git-recent.1 b/man/man1/git-recent.1 index 0c26e63..ab9c2c9 100644 --- a/man/man1/git-recent.1 +++ b/man/man1/git-recent.1 @@ -17,7 +17,7 @@ Display a help message. .SH KEYBINDINGS .TP .B Enter -Checkout the selected branch. +Checkout the selected branch. If the branch is already open in a separate git worktree, the script will output the path to jump there. (See README for automatic switching via a small shell wrapper). .TP .B Up/Down arrow keys Navigate the branch list. diff --git a/package.json b/package.json index 85c9c73..8218ba9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "List recent git branches, select one for checkout. See branch commits and diffs, all formatted so fancy", "bin": { "git-recent": "git-recent", - "git-recent-og": "git-recent-og" + "git-recent-og": "git-recent-og", + "git-watch": "git-watch" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/readme.md b/readme.md index 0017330..a657d2e 100644 --- a/readme.md +++ b/readme.md @@ -37,6 +37,30 @@ Type or use arrow keys to navigate your list of branches. Hit `ctrl-o` to see the branch diff. +#### Worktree Support + +If a branch is already open in a separate git worktree, `git recent` will detect it and provide instructions to `cd` there. To enable **automatic** directory switching, add one of these wrapper functions to your shell config: + +##### Bash / Zsh (in `.bashrc` or `.zshrc`) +```bash +recent() { + local o=$(command git-recent "$@") + [ -d "$o" ] && cd "$o" || [ -n "$o" ] && echo "$o" +} +``` + +##### Fish (in `~/.config/fish/config.fish`) +```fish +function recent + set -l o (command git-recent $argv) + if test -d "$o" + cd $o + else if test -n "$o" + echo $o + end +end +``` + -------------------------------- # `git recent-og`