From 752310e3862bf02e74df3ea5d83af3a6bd464a2a Mon Sep 17 00:00:00 2001 From: "RingOfStorms (Joshua Bell)" Date: Wed, 10 Sep 2025 18:25:37 -0500 Subject: [PATCH] shell: add worktree branch helpers and link_ignored script; expose branch/branchd aliases --- common/general/shell/branch.sh | 113 +++++++++++++++++ common/general/shell/link_ignored.sh | 175 +++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 common/general/shell/branch.sh create mode 100755 common/general/shell/link_ignored.sh diff --git a/common/general/shell/branch.sh b/common/general/shell/branch.sh new file mode 100644 index 0000000..a8f6807 --- /dev/null +++ b/common/general/shell/branch.sh @@ -0,0 +1,113 @@ +# Branch and branchd helpers (worktree-based) + +# branch — create or jump to a worktree for +branch() { + # Use XDG_DATA_HOME or default to ~/.local/share + local xdg=${XDG_DATA_HOME:-$HOME/.local/share} + # Determine the git common dir (points into the main repo's .git) + local common_dir + common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || { + echo "Not inside a git repository." >&2 + return 1 + } + # Make common_dir absolute if it's relative + if [ "${common_dir#/}" = "$common_dir" ]; then + common_dir="$(pwd)/$common_dir" + fi + # repo_dir is the path before '/.git' in the common_dir (handles worktrees) + local repo_dir + repo_dir="${common_dir%%/.git*}" + if [ -z "$repo_dir" ]; then + echo "Unable to determine repository root." >&2 + return 1 + fi + + local repo_base + repo_base=$(basename "$repo_dir") + local repo_hash + repo_hash=$(printf "%s" "$repo_dir" | sha1sum | awk '{print $1}') + + local branch_name + branch_name=$1 + if [ -z "$branch_name" ]; then + echo "Usage: branch " >&2 + return 1 + fi + + # If user asked for default or master, cd back to repo root on default branch + local default_branch + default_branch=$(getdefault 2>/dev/null || echo "") + if [ "$branch_name" = "default" ] || [ "$branch_name" = "master" ] || [ "$branch_name" = "$default_branch" ]; then + cd "$repo_dir" || return 0 + git fetch + git checkout "$default_branch" + pull + return 0 + fi + + # Ensure we have up-to-date remote info + git fetch --all --prune + + # If branch exists remotely and not locally, create local branch tracking remote + if git ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1; then + if ! git show-ref --verify --quiet "refs/heads/$branch_name"; then + git branch --track "$branch_name" "origin/$branch_name" 2>/dev/null || git branch "$branch_name" "origin/$branch_name" + fi + fi + + # Worktree path + local wt_root + wt_root="$xdg/git_worktrees/${repo_base}_${repo_hash}" + local wt_path + wt_path="$wt_root/$branch_name" + + mkdir -p "$wt_root" + + # If worktree already exists at our expected path, cd to it + if [ -d "$wt_path/.git" ]; then + cd "$wt_path" || return 0 + return 0 + fi + + # If a worktree for this branch is already registered elsewhere, find it and cd + local existing + existing=$(git worktree list --porcelain 2>/dev/null | awk -v b="$branch_name" 'BEGIN{RS=""} $0 ~ "refs/heads/"b{for(i=1;i<=NF;i++) if ($i ~ /^worktree/) print $2 }') + if [ -n "$existing" ]; then + cd "$existing" || return 0 + return 0 + fi + + # Create the worktree + mkdir -p "$wt_path" + git worktree add -B "$branch_name" "$wt_path" "origin/$branch_name" 2>/dev/null || git worktree add "$wt_path" "$branch_name" + cd "$wt_path" || return 0 +} + +# branchd — remove current branch worktree +branchd() { + local current + current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || { + echo "Not inside a git repository." >&2 + return 1 + } + local default_branch + default_branch=$(getdefault 2>/dev/null || echo "") + + if [ "$current" = "$default_branch" ] || [ "$current" = "default" ]; then + echo "Already on default branch ($default_branch). Won't remove." >&2 + return 1 + fi + + # Find the worktree path for the current branch + local wt_path + wt_path=$(git worktree list --porcelain 2>/dev/null | awk -v b="$current" 'BEGIN{RS="";FS="\n"} $0 ~ "refs/heads/"b{for(i=1;i<=NF;i++) if ($i ~ /^worktree /) { sub(/^worktree /,"",$i); print $i }}') + if [ -z "$wt_path" ]; then + echo "Worktree for branch '$current' not found." >&2 + return 1 + fi + + # Switch to default branch (uses branch() helper) and then remove worktree + branch default || { echo "Failed to switch to default branch" >&2; return 1; } + + git worktree remove "$wt_path" +} diff --git a/common/general/shell/link_ignored.sh b/common/general/shell/link_ignored.sh new file mode 100755 index 0000000..95c0a71 --- /dev/null +++ b/common/general/shell/link_ignored.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat </dev/null); then + echo "Error: not in a git repository." >&2 + exit 2 +fi +# Make absolute if relative +if [ "${common_dir#/}" = "$common_dir" ]; then + common_dir="$(pwd)/$common_dir" +fi +repo_root="${common_dir%%/.git*}" +if [ -z "$repo_root" ]; then + echo "Error: unable to determine repository root." >&2 + exit 2 +fi + +# Get list of untracked/ignored files from the repo root (relative paths) +mapfile -d $'\0' -t candidates < <(git -C "$repo_root" ls-files --others --ignored --exclude-standard -z || true) + +if [ ${#candidates[@]} -eq 0 ]; then + echo "No untracked/ignored files found in $repo_root" + exit 0 +fi + +# Collapse to top-level (first path component) and make unique. +# This prevents listing every file under node_modules/ or build/. +declare -A _seen +tops=() +for c in "${candidates[@]}"; do + # remove trailing slash if present + c="${c%/}" + top="${c%%/*}" + [ -z "$top" ] && continue + if [ -z "${_seen[$top]:-}" ]; then + _seen[$top]=1 + tops+=("$top") + fi +done + +if [ ${#tops[@]} -eq 0 ]; then + echo "No top-level ignored/untracked entries found in $repo_root" + exit 0 +fi + +# Filter top-level entries by provided patterns (if any) +if [ ${#PATTERNS[@]} -gt 0 ]; then + filtered=() + for t in "${tops[@]}"; do + for p in "${PATTERNS[@]}"; do + if [[ "$t" == *"$p"* ]]; then + filtered+=("$t") + break + fi + done + done +else + filtered=("${tops[@]}") +fi + +if [ ${#filtered[@]} -eq 0 ]; then + echo "No candidates match the provided patterns." >&2 + exit 0 +fi + +# Present selection +if command -v fzf >/dev/null 2>&1 && [ "$USE_FZF" -eq 1 ]; then + # Show preview of the source file (if text) and allow multi-select + selected=$(printf "%s\n" "${filtered[@]}" | fzf --multi --height=40% --border --prompt="Select files to link: " --preview "if [ -f '$repo_root'/{} ]; then bat --color always --paging=never --style=plain '$repo_root'/{}; else ls -la '$repo_root'/{}; fi") + if [ -z "$selected" ]; then + echo "No files selected." && exit 0 + fi + # Convert to array + IFS=$'\n' read -r -d '' -a chosen < <(printf "%s\n" "$selected" && printf '\0') +else + # Non-interactive: choose all + chosen=("${filtered[@]}") +fi + +# Worktree destination is current working directory +worktree_root=$(pwd) + +echo "Repository root: $repo_root" +echo "Worktree root : $worktree_root" + +# Create symlinks +created=() +skipped=() +errors=() + +for rel in "${chosen[@]}"; do + # Trim trailing newlines/spaces + rel=${rel%%$'\n'} + src="$repo_root/$rel" + dst="$worktree_root/$rel" + + if [ ! -e "$src" ]; then + errors+=("$rel (source missing)") + continue + fi + + if [ -L "$dst" ]; then + # Already a symlink + echo "Skipping $rel (already symlink)" + skipped+=("$rel") + continue + fi + if [ -e "$dst" ]; then + echo "Skipping $rel (destination exists)" + skipped+=("$rel") + continue + fi + + mkdir -p "$(dirname "$dst")" + if [ "$DRY_RUN" -eq 1 ]; then + echo "DRY RUN: ln -s '$src' '$dst'" + else + if ln -s "$src" "$dst"; then + echo "Linked: $rel" + created+=("$rel") + else + echo "Failed to link: $rel" >&2 + errors+=("$rel (link failed)") + fi + fi +done + +# Summary +echo +echo "Summary:" +echo " Linked: ${#created[@]}" +[ ${#created[@]} -gt 0 ] && printf ' %s\n' "${created[@]}" +echo " Skipped: ${#skipped[@]}" +[ ${#skipped[@]} -gt 0 ] && printf ' %s\n' "${skipped[@]}" +echo " Errors: ${#errors[@]}" +[ ${#errors[@]} -gt 0 ] && printf ' %s\n' "${errors[@]}" + +exit 0