diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad0f007 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install linters + run: | + sudo apt-get update + sudo apt-get install --yes fish shellcheck + + - name: Check Bash + run: | + bash -n bin/* bootstrap/linux/* bootstrap/macos/* + shellcheck --external-sources --exclude=SC2155 bin/* bootstrap/linux/* bootstrap/macos/* + + - name: Check Fish + run: fish -n fish/config.fish fish/conf.d/*.fish fish/functions/*.fish + + - name: Check PowerShell + shell: pwsh + run: | + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path ./windows.ps1), + [ref]$tokens, + [ref]$errors + ) | Out-Null + + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_ } + exit 1 + } + + - name: Check TOML + run: | + python3 - <<'PY' + import pathlib + import subprocess + import tomllib + + paths = [pathlib.Path(filename) for filename in subprocess.check_output( + ['git', 'ls-files', '--', '*.toml'], + text=True, + ).splitlines()] + + for path in paths: + with path.open('rb') as config: + tomllib.load(config) + PY + + - name: Check whitespace + run: git diff --check diff --git a/README.md b/README.md index 313ca42..0fc267a 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,13 @@ Preview bootstrap changes or inspect current state: mise bootstrap --dry-run mise bootstrap status ``` + +## Utilities + +Rename photos from their EXIF timestamps, without changing anything first: + +```sh +photon /path/to/photos --dry-run +``` + +Remove `--dry-run` to apply names in the `IMG_yyyyMMdd_HHmmss` format. Duplicate timestamps receive a numeric suffix. diff --git a/bin/git-update b/bin/git-update new file mode 100755 index 0000000..b1e1107 --- /dev/null +++ b/bin/git-update @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# Fetch updates and delete local branches whose upstream no longer exists. + +set -euo pipefail + +git fetch --prune +git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads | + awk '$NF == "[gone]" {print $1}' | + while IFS= read -r branch; do + git branch -D -- "$branch" + done diff --git a/bin/gw b/bin/gw deleted file mode 100755 index 29f1dd9..0000000 --- a/bin/gw +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -# Die with 1 exit code (defined first so the variable block below can use it). -die() { - echo "gw (error): $*" >&2 - exit 1 -} - -# Variables -#------------------------------------------------------------------------------ -CURRENT_WORKTREE="$(git rev-parse --show-toplevel 2>/dev/null || die "not inside a git worktree")" -# Worktree name pool, also the WORKTREE_ID space (assign_slot indexes into it). -# 16 names map onto Redis's default 16 databases (0-15); WORKTREE_ID is a DB. -NAME_POOL=(black blue brown cyan gray green lime magenta navy olive orange pink purple red white yellow) -SYMLINK_DIRS=(node_modules vendor/bundle) -REPO_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')" -WORKTREES_DIR="$REPO_ROOT/.worktrees" - -# Worktree utilities -#------------------------------------------------------------------------------ -# Check if a directory is in the list of symlinked directories (e.g. node_modules). -is_symlink_dir() { - local candidate="$1" - local dir - - for dir in "${SYMLINK_DIRS[@]}"; do - [[ "$candidate" == "$dir" ]] && return 0 - done - - return 1 -} - -# Assign name for branch: prefer the hash of the branch, fallback to the first available name. -# Stable per branch (reclaims its preferred slot when free) and never collides -# with a live worktree. -assign_slot() { - local branch="$1" - local size=${#NAME_POOL[@]} - local sum preferred offset index - - sum="$(printf '%s' "$branch" | cksum | cut -d' ' -f1)" - preferred=$(( sum % size )) - - for (( offset = 0; offset < size; offset++ )); do - index=$(( (preferred + offset) % size )) - [[ -e "$WORKTREES_DIR/${NAME_POOL[$index]}" ]] || { echo "$index"; return; } - done - - die "all worktree slots are in use" -} - -# Print current branch for a worktree path (empty on detached HEAD). -worktree_branch() { - [[ -d "$1" ]] || return 1 - git -C "$1" branch --show-current 2>/dev/null || true -} - -# Find worktree name associated with $1 path -worktree_name() { - local path="$1" - - # Verify worktrees path - [[ "$path" == "$REPO_ROOT/.worktrees/"* ]] || return 1 - - # Remove worktrees path prefix. - path="${path#"$REPO_ROOT/.worktrees/"}" - # After removing the .worktrees prefix, expect a non-empty single path segment. - [[ "$path" != */* && -n "$path" ]] || return 1 - - echo "$path" -} - -# Return path to worktree with $1 name -worktree_path() { - local name="$1" - - [[ -d "$WORKTREES_DIR/$name" ]] || die "no worktree named '$name'" - echo "$WORKTREES_DIR/$name" -} - -# Remove a linked worktree and delete its local branch when present. -worktree_remove() { - local path="$1" - local branch - - branch="$(worktree_branch "$path")" - - # Run the remove hook while the worktree still exists (e.g. drop its database). - run_task_hook "$path" "worktree:remove" - - cd "$REPO_ROOT" - git worktree remove --force "$path" >&2 - if [[ -n "$branch" ]] && git show-ref --verify --quiet "refs/heads/$branch"; then - git branch -D "$branch" >&2 - fi - - echo "$REPO_ROOT" -} - -# Worktree setup -#------------------------------------------------------------------------------ -# Print entries listed in .worktreeinclude, falling back to mise vars.worktreeinclude. -included_file_entries() { - local manifest="$REPO_ROOT/.worktreeinclude" - - if [[ -f "$manifest" ]]; then - cat "$manifest" - return 0 - fi - - - # Defined as multi-line string in mise config file - # [vars] - # worktreeinclude = """ - # .env - # .env.local - # mise.toml - # node_modules - # vendor/bundle - # """ - command -v mise >/dev/null 2>&1 || return 0 - mise config get -C "$REPO_ROOT" vars.worktreeinclude 2>/dev/null || true -} - -# Copy entries listed in .worktreeinclude or mise vars.worktreeinclude (if present). -copy_included_files() { - local target_root="$1" - local entry - - while IFS= read -r entry || [[ -n "$entry" ]]; do - # Trim leading/trailing whitespace. - entry="$(printf '%s' "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - - [[ -z "$entry" ]] && continue - - if [[ -e "$REPO_ROOT/$entry" ]]; then - mkdir -p "$(dirname "$target_root/$entry")" - if [[ -d "$REPO_ROOT/$entry" ]]; then - if is_symlink_dir "$entry"; then - ln -s "$REPO_ROOT/$entry" "$target_root/$entry" - else - cp -R "$REPO_ROOT/$entry/." "$target_root/$entry" - fi - else - cp -R "$REPO_ROOT/$entry" "$target_root/$entry" - fi - else - echo "gw (warning): included entry '$entry' does not exist" >&2 - fi - done < <(included_file_entries) -} - -# Trust the mise config and install its tools when a mise.toml is present. -setup_mise() { - local path="$1" - - [[ -f "$path/mise.toml" ]] || return 0 - command -v mise >/dev/null 2>&1 || { - echo "gw (warning): mise.toml found but mise is not installed; skipping" >&2 - return 0 - } - - mise trust "$path/mise.toml" >&2 - (cd "$path" && mise install) >&2 -} - -# Write a gitignored mise.local.toml exposing this worktree's identity as vars. -write_mise_local() { - [[ -f "$1/mise.toml" ]] || return 0 - - { - printf '[env]\n' - printf 'WORKTREE_ID = "%s"\n' "$2" - printf 'WORKTREE_NAME = "%s"\n' "$3" - } > "$1/mise.local.toml" -} - -# Run the mise task associated with the worktree hook (e.g. worktree:create) -run_task_hook() { - local path="$1" - local task="$2" - local branch - - command -v mise >/dev/null 2>&1 || return 0 - [[ -f "$path/mise.toml" ]] || return 0 - (cd "$path" && mise tasks info "$task" >/dev/null 2>&1) || return 0 - - branch="$(worktree_branch "$path")" - (cd "$path" && mise run "$task" >&2) || echo "gw (warning): mise task '$task' failed" >&2 -} - -# Commands -#------------------------------------------------------------------------------ -# Add new worktree -cmd_add() { - [[ $# -eq 1 ]] || cmd_usage - - # Accept a remote-qualified name (e.g. origin/feature) and use the short branch name locally. - local branch="${1#origin/}" - local name - local path - local index - - index="$(assign_slot "$branch")" - name="${NAME_POOL[$index]}" - path="$WORKTREES_DIR/$name" - - mkdir -p "$WORKTREES_DIR" - cd "$REPO_ROOT" - # Reuse an existing local branch. - if git show-ref --verify --quiet "refs/heads/$branch"; then - git worktree add -- "$path" "$branch" >&2 - # If only the remote branch exists, create a local tracking branch for it. - elif git show-ref --verify --quiet "refs/remotes/origin/$branch"; then - git worktree add --track -b "$branch" -- "$path" "origin/$branch" >&2 - # Otherwise create a new local branch from the current HEAD. - else - git worktree add -b "$branch" -- "$path" >&2 - fi - - copy_included_files "$path" - write_mise_local "$path" "$index" "$name" - setup_mise "$path" - - run_task_hook "$path" "worktree:create" - - cmd_open "$name" -} - -# Print the path to worktree $1 so the caller can cd into it. -cmd_open() { - [[ $# -eq 1 ]] || cmd_usage - - worktree_path "$1" -} - -# Remove worktree with $1 name or in current directory -cmd_remove() { - [[ $# -le 1 ]] || cmd_usage - - local name="${1:-}" - local path - - if [[ -n "$name" ]]; then - path="$(worktree_path "$name")" - else - path="$CURRENT_WORKTREE" - name="$(worktree_name "$path" || true)" - [[ -n "$name" ]] || die "refusing to remove the primary worktree; pass a linked worktree name" - fi - - worktree_remove "$path" -} - -# List all worktrees -cmd_list() { - [[ $# -eq 0 ]] || cmd_usage - - local path - local name - local branch - local marker - local worktrees - worktrees="$(git worktree list --porcelain | sed -n 's/^worktree //p')" - - while IFS= read -r path; do - name="$(worktree_name "$path" || true)" - [[ -n "$name" ]] || continue - - branch="$(worktree_branch "$path")" - marker=" " - [[ "$path" == "$CURRENT_WORKTREE" ]] && marker="*" - printf '%s %-10s %-30s %s\n' "$marker" "$name" "${branch:-detached}" "$path" - done <<< "$worktrees" -} - -cmd_usage() { - cat >&2 <<'USAGE' -Usage: - gw add - gw remove [] - gw open - gw list -USAGE - exit 1 -} - -# Entrypoint -#------------------------------------------------------------------------------ -[[ $# -gt 0 ]] || cmd_usage - -COMMAND="$1" -shift - -case "$COMMAND" in -add) cmd_add "$@" ;; -remove) cmd_remove "$@" ;; -open) cmd_open "$@" ;; -list) cmd_list "$@" ;; -*) cmd_usage ;; -esac diff --git a/bin/rp b/bin/photon similarity index 79% rename from bin/rp rename to bin/photon index ffaf37c..e8975e3 100755 --- a/bin/rp +++ b/bin/photon @@ -3,7 +3,7 @@ # Batch rename photos to IMG_yyyyMMdd_HHmmss format using EXIF data. # Dependencies: exiftool (brew install exiftool / apt install libimage-exiftool-perl) # -# Usage: rp [--dry-run] +# Usage: photon [--dry-run] set -euo pipefail @@ -11,7 +11,7 @@ DIR="" DRY_RUN=false usage() { - echo "Usage: rp [--dry-run]" + echo "Usage: photon [--dry-run]" exit 1 } @@ -24,8 +24,7 @@ check_deps() { get_datetime() { local file="$1" - local datetime - datetime=$(exiftool -s3 -d '%Y%m%d_%H%M%S' -DateTimeOriginal "$file" 2>/dev/null) + local datetime=$(exiftool -s3 -d '%Y%m%d_%H%M%S' -DateTimeOriginal "$file" 2>/dev/null) [[ -z "$datetime" ]] && datetime=$(exiftool -s3 -d '%Y%m%d_%H%M%S' -CreateDate "$file" 2>/dev/null) [[ -z "$datetime" ]] && datetime=$(exiftool -s3 -d '%Y%m%d_%H%M%S' -FileModifyDate "$file" 2>/dev/null) @@ -39,8 +38,7 @@ build_new_path() { # datetime format: "20240115_143045" -> "IMG_20240115_143045" local new_name="IMG_${datetime}" - local ext - ext=$(echo "${file##*.}" | tr '[:upper:]' '[:lower:]') + local ext=$(echo "${file##*.}" | tr '[:upper:]' '[:lower:]') local new_path="$DIR/${new_name}.${ext}" # Handle duplicates by appending _1, _2, etc. @@ -60,25 +58,22 @@ rename_photos() { for file in "$DIR"/*; do [[ -f "$file" ]] || continue - local mime - mime=$(file --brief --mime-type "$file") + local mime=$(file --brief --mime-type "$file") [[ "$mime" == image/* ]] || continue - local datetime - datetime=$(get_datetime "$file") + local datetime=$(get_datetime "$file") if [[ -z "$datetime" || "$datetime" == "00000000_000000" ]]; then echo "SKIP (no date): $file" - ((skipped++)) + ((++skipped)) continue fi - local new_path - new_path=$(build_new_path "$file" "$datetime") + local new_path=$(build_new_path "$file" "$datetime") if [[ -e "$new_path" && "$file" -ef "$new_path" ]]; then echo "SKIP (already named): $file" - ((skipped++)) + ((++skipped)) continue fi @@ -88,7 +83,7 @@ rename_photos() { mv "$file" "$new_path" echo "RENAMED: $file -> $new_path" fi - ((renamed++)) + ((++renamed)) done echo "" diff --git a/bin/worktree-setup b/bin/worktree-setup index 5427e3a..64eb168 100755 --- a/bin/worktree-setup +++ b/bin/worktree-setup @@ -4,13 +4,18 @@ # Copy all files listed in .worktreeinclude from the main worktree. # Dependency directories such as deps, node_modules and vendor/bundle are symlinked to the main worktree. +# Usage: worktree-setup + set -Eeuo pipefail +BRANCH_CHECKOUT="${3:-}" +ENV_SECTION_PATTERN='^[[:space:]]*\[env\][[:space:]]*(#.*)?$' +NEW_HEAD="${2:-}" NULL_SHA=0000000000000000000000000000000000000000 -WORKTREE_PATH="$PWD" +PREVIOUS_HEAD="${1:-}" REPO_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')" SYMLINK_DIRS=(deps node_modules vendor/bundle) -ENV_SECTION_PATTERN='^[[:space:]]*\[env\][[:space:]]*(#.*)?$' +WORKTREE_PATH="$PWD" copy_included_files() { local entry @@ -45,11 +50,7 @@ included_file_entries() { } is_checkout() { - local prev_head="${1:-}" - local new_head="${2:-}" - local checkout_flags="${3:-}" - - [[ "$prev_head" == "$NULL_SHA" && "$checkout_flags" == "1" ]] + [[ "$PREVIOUS_HEAD" == "$NULL_SHA" && "$NEW_HEAD" != "$NULL_SHA" && "$BRANCH_CHECKOUT" == "1" ]] } is_symlink_dir() { @@ -64,8 +65,8 @@ is_symlink_dir() { } is_worktree() { - local git_dir="$(git rev-parse --absolute-git-dir)" - local common_dir="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + local git_dir="$(git -C "$WORKTREE_PATH" rev-parse --absolute-git-dir)" + local common_dir="$(git -C "$WORKTREE_PATH" rev-parse --path-format=absolute --git-common-dir)" # Git worktrees keep their git dir under the main worktree's .git/worktrees [[ "$git_dir" != "$common_dir" ]] @@ -82,7 +83,7 @@ next_worktree_id() { while IFS= read -r path; do id="$(git -C "$path" config --worktree --get worktreeSetup.id 2>/dev/null || true)" if [[ "$id" =~ ^[0-9]+$ ]] && (( id >= 1 && id <= 16 )); then - used[$id]=1 + used[id]=1 fi done < <(git worktree list --porcelain | sed -n 's/^worktree //p') @@ -155,7 +156,7 @@ warn() { trap 'warn "setup did not complete"; exit 0' ERR -is_checkout "$@" || exit 0 +is_checkout || exit 0 is_worktree || exit 0 non_bare_repo || exit 0 diff --git a/fish/completions/gw.fish b/fish/completions/gw.fish deleted file mode 100644 index 607f3b6..0000000 --- a/fish/completions/gw.fish +++ /dev/null @@ -1,22 +0,0 @@ -# Completions for gw (git worktree helper) - -function __gw_worktree_names - git worktree list --porcelain 2>/dev/null | string replace -r --filter '^worktree .*/\.worktrees/([^/]+)$' '$1' -end - -function __gw_needs_worktree_name - set -l tokens (commandline -opc) - test (count $tokens) -eq 2; and contains -- $tokens[2] open remove -end - -# No file completions by default. -complete -c gw -f - -set -l commands add remove open list - -complete -c gw -n "not __fish_seen_subcommand_from $commands" -a 'add' -d 'Add a new worktree' -complete -c gw -n "not __fish_seen_subcommand_from $commands" -a 'remove' -d 'Remove a worktree' -complete -c gw -n "not __fish_seen_subcommand_from $commands" -a 'open' -d 'Open a worktree' -complete -c gw -n "not __fish_seen_subcommand_from $commands" -a 'list' -d 'List worktrees' - -complete -c gw -n '__gw_needs_worktree_name' -a '(__gw_worktree_names)' diff --git a/git/config b/git/config index d3d3ad7..ff521d3 100644 --- a/git/config +++ b/git/config @@ -33,8 +33,7 @@ cma = !git add -A && git commit --amend --no-edit --reset-author co = checkout cob = checkout -b - # Fetch and delete branches that have been deleted on the remote - fe = "!f() { git fetch -p && git branch -vv | awk '/: gone]/{print $1}' | xargs git br -D; }; f" + fe = "!f() { echo 'git fe is deprecated; use git up' >&2; git up \"$@\"; }; f" his = log --oneline --graph --decorate pl = pull --rebase ps = push @@ -42,6 +41,7 @@ sw = !git checkout $(git br | fzf) swa = !git checkout $(git br --all | fzf) un = reset HEAD~1 --soft + up = !git-update wip = !git add -A && git commit -m "WIP" # Open modified or added files in neovim edit = "!git status --porcelain | grep -v '^D' | awk '{print $2 \":1:1: [\" $1 \"]\"}' | nvim -q - +copen"