diff --git a/config/fish/functions/cr.fish b/config/fish/functions/cr.fish new file mode 100644 index 0000000..c2ab491 --- /dev/null +++ b/config/fish/functions/cr.fish @@ -0,0 +1,303 @@ +# cr.fish — Create and manage code review worktrees for Fish shell +# +# Synopsis: +# cr [OPTIONS] +# cr cleanup [OPTIONS] +# +# Description: +# Create a dedicated worktree for reviewing code based on a ticket ID +# extracted from a branch name, or clean up existing cr- worktrees. +# +# Constants: +# CR_DEFAULT_REMOTE Default Git remote used when none specified +# +# Fish Requirements: +# Fish shell >= 3.1.0 for argparse builtin +# +# Based on work by ville6000 (https://github.com/ville6000) +# +# Examples: +# cr feature/1234-add-login +# cr -r upstream 5678 +# cr cleanup --dry-run + +if not set -q CR_DEFAULT_REMOTE + set -g CR_DEFAULT_REMOTE origin +end + +function __cr_show_help + echo 'Usage: cr [OPTIONS] ' + echo ' cr cleanup [OPTIONS]' + echo + echo " -r, --remote Git remote (default: $CR_DEFAULT_REMOTE)" + echo ' -d, --dry-run Show actions without executing' + echo ' -f, --force Skip confirmation prompts' + echo ' -k, --keep-branch In cleanup, keep local branches' + echo ' -b, --branch-only Create only the review branch (no worktree)' + echo ' --cleanup-branches-only In cleanup, delete branches only' + echo ' -h, --help Show this help' +end + +function __cr_run_with_spinner --argument-names msg cmd + set -l tmp (mktemp) + eval $cmd >$tmp 2>&1 & + set -l pid $last_pid + set -l spin_chars '/-\|' + set -l i 1 + while kill -0 $pid 2>/dev/null + printf "\r[%c] %s" (string sub -s $i -l 1 $spin_chars) "$msg" + set i (math (math $i % 4) + 1) + sleep 0.1 + end + printf "\r%s\r" (string repeat -n (math (string length "$msg") + 5) " ") + wait $pid + set -l output (cat $tmp) + rm $tmp + # Remove any leading empty or all-whitespace lines (from spinner clear) + for line in $output + if test -n (string trim -- $line) + echo $line + end + end +end + +function __cr_cleanup + argparse dry_run force keep_branch cleanup_branches_only -- $argv + or return + + set -l dry_run (set -q _flag_dry_run; and echo 1; or echo 0) + set -l force (set -q _flag_force; and echo 1; or echo 0) + set -l keep_branch (set -q _flag_keep_branch; and echo 1; or echo 0) + set -l branches_only (set -q _flag_cleanup_branches_only; and echo 1; or echo 0) + + set -l target_branch + if test (count $argv) -gt 0 + set target_branch $argv[1] + end + + git worktree prune + + for wt in (git worktree list --porcelain | awk '/^worktree /{print $2}') + set -l base (basename $wt) + if string match -r '^cr-.*' $base + if test -n "$target_branch" -a "$base" != "$target_branch" + continue + end + if test "$branches_only" = 0 + if test "$dry_run" = 1 + echo "[DRY-RUN] remove worktree: $wt" + else if test "$force" = 1 + git worktree remove --force $wt + else + echo "git worktree remove --force $wt" + echo "(!) Use --force to actually remove worktree: $wt." + end + end + if test "$keep_branch" = 0 + if test "$branches_only" = 0 + if test "$dry_run" = 1 + echo "[DRY-RUN] delete branch: $base" + else if test "$force" = 1 + git branch -D $base + else + echo "git branch -D $base" + echo "(!) Use --force to actually delete branch: $base." + end + end + end + end + end +end + +# --- Main Entrypoint --- + +function cr --description 'Create or cleanup code-review worktrees' + if not git rev-parse --is-inside-work-tree >/dev/null 2>&1 + echo "Not inside a git repository." >&2 + return 1 + end + + set -l remotes (git remote) + if test (count $remotes) -eq 0 + echo "No git remotes found. Please add a remote before using cr." >&2 + return 1 + end + + if not type -q argparse + echo 'cr.fish requires the argparse builtin' >&2 + return 1 + end + + argparse \ + r/remote= \ + h/help \ + d/dry-run \ + f/force \ + y/yes \ + k/keep-branch \ + b/branch-only= \ + cleanup-branches-only \ + -- $argv + or return + + if set -q _flag_h; or set -q _flag_help + __cr_show_help + return 0 + end + + set -l remote (set -q _flag_remote; and echo $_flag_remote[-1]; or echo $CR_DEFAULT_REMOTE) + if set -q _flag_yes + set -g _flag_force 1 + end + + if not contains $remote $remotes + echo "Remote '$remote' does not exist. Available remotes: $remotes" >&2 + return 1 + end + + if test (count $argv) -gt 0 -a "$argv[1]" = cleanup + __cr_cleanup \ + (set -q _flag_dry_run; and echo --dry_run) \ + (set -q _flag_force; and echo --force) \ + (set -q _flag_keep_branch; and echo --keep_branch) \ + (set -q _flag_cleanup_branches_only; and echo --cleanup_branches_only) + return 0 + end + + set -l source_branch "" + if test (count $argv) -gt 0 + set source_branch $argv[1] + else if type -q fzf + set -l branches (__cr_run_with_spinner "Fetching branches..." \ + "git ls-remote --heads $remote | sed 's|.*refs/heads/||'") + printf "\n" + if test (count $branches) -eq 0 + echo "No branches found on remote '$remote'." >&2 + return 1 + end + set -l exclude_branches main master develop dev trunk + set -l filtered_branches + for b in $branches + set b (string trim -- $b) + if not contains -- $b $exclude_branches + set filtered_branches $filtered_branches $b + end + end + set source_branch (printf '%s\n' $filtered_branches | fzf --prompt='Select branch: ') + echo "Selected branch: $source_branch" + if test $status -ne 0 + echo 'Selection aborted' >&2 + return 1 + end + if test -z "$source_branch" + echo 'No branch selected' >&2 + return 1 + end + else + __cr_show_help >&2 + return 1 + end + + # Extract ticket ID from branch name, or use slug if not found + set -l branch_tail (string split "/" $source_branch)[-1] + set -l ticket_id (printf '%s\n' $branch_tail | grep -o '[0-9]\+' | tail -n1) + set -l review_suffix "" + if test -z "$ticket_id" + # No numeric ticket, use slug of branch name as suffix + set review_suffix (string replace -ra '[^a-zA-Z0-9]+' '-' -- $source_branch) + else + set review_suffix $ticket_id + end + if set -q _flag_b; or set -q _flag_branch_only + set review_suffix $_flag_branch_only[-1] + end + set -l review_branch cr-$review_suffix + set -l folder ../$review_branch + + # Branch-only mode + if set -q _flag_b; or set -q _flag_branch_only + if set -q _flag_dry_run + echo "[DRY-RUN] Create branch $review_branch from $remote/$source_branch" + return 0 + end + __cr_run_with_spinner "Fetching from $remote..." \ + "git fetch $remote $source_branch" + git branch $review_branch $remote/$source_branch + echo "Created branch $review_branch" + return 0 + end + + __cr_run_with_spinner "Checking remote branch..." \ + "git ls-remote --exit-code --heads $remote $source_branch >/dev/null 2>&1" + if test $status -ne 0 + echo "No remote branch $remote/$source_branch" >&2 + return 1 + end + if git show-ref --quiet refs/heads/$review_branch + echo "Local branch $review_branch exists" >&2 + return 1 + end + if test -d $folder + echo "Directory $folder exists" >&2 + return 1 + end + + if set -q _flag_dry_run + echo "[DRY-RUN] Add worktree $folder -b $review_branch $remote/$source_branch" + return 0 + end + + __cr_run_with_spinner "Fetching from $remote..." \ + "git fetch $remote $source_branch" + git worktree add $folder -b $review_branch $remote/$source_branch +end + +# --- Completion Functions --- + +complete -c cr -l help -s h -f -d 'Show help' +complete -c cr -l remote -s r -f -d 'Git remote' -a '(git remote)' +complete -c cr -l dry-run -s d -f -d 'Dry run' +complete -c cr -l force -s f -f -d 'Skip confirmations' +complete -c cr -l keep-branch -s k -f -d 'Keep branches in cleanup' +complete -c cr -l branch-only -s b -f -d 'Branch-only mode' \ + -a '(__fish_cr_ticket_ids)' +complete -c cr -l cleanup-branches-only -f -d 'Branches-only cleanup' +complete -c cr -f -a cleanup -d 'Cleanup mode' \ + -n 'not __fish_seen_subcommand_from cleanup' +complete -c cr -n '__fish_seen_subcommand_from cleanup' -f \ + -a '(__fish_cr_cleanup_branches)' -d 'cr-* branch' +complete -c cr -n '__fish_seen_subcommand_from cleanup' -f +complete -c cr -n 'not __fish_seen_subcommand_from cleanup' -f \ + -a '(__fish_cr_branches)' -d 'Source branch' + +function __fish_cr_cleanup_branches + git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -l wt + set base (basename $wt) + if string match -r '^cr-.*' $base + echo $base + end + end +end + +function __fish_cr_branches --description 'List remote branches' + set -l exclude_branches main master develop dev trunk + set -l branches (git ls-remote --heads $CR_DEFAULT_REMOTE | \ + sed 's|.*refs/heads/||') + for b in $branches + set b (string trim -- $b) + if not contains -- $b $exclude_branches + echo $b + end + end +end + +function __fish_cr_ticket_ids --description 'List ticket IDs from remote branches' + for b in (git ls-remote --heads $CR_DEFAULT_REMOTE | \ + sed 's|.*refs/heads/||') + set b (string trim -- $b) + set -l id (string match -r '[0-9]+' -- $b) + if test -n "$id" + echo $id + end + end +end