From 205db4b36701666a81b946d3c5e5171621d85537 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 5 Sep 2026 10:48:44 -0400 Subject: [PATCH] feat(shell): add devtree worktree helper --- configs/nixos/modules/shell/fish/default.nix | 5 + .../modules/shell/fish/functions/devtree.fish | 273 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 configs/nixos/modules/shell/fish/functions/devtree.fish diff --git a/configs/nixos/modules/shell/fish/default.nix b/configs/nixos/modules/shell/fish/default.nix index 8a4916a..3936a70 100644 --- a/configs/nixos/modules/shell/fish/default.nix +++ b/configs/nixos/modules/shell/fish/default.nix @@ -102,6 +102,11 @@ in description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild"; body = builtins.readFile ./functions/deploy-to.fish; }; + + devtree = { + description = "Manage alexion/dev git worktrees"; + body = builtins.readFile ./functions/devtree.fish; + }; }; # Rendered by home-manager into ~/.config/fish/config.fish. diff --git a/configs/nixos/modules/shell/fish/functions/devtree.fish b/configs/nixos/modules/shell/fish/functions/devtree.fish new file mode 100644 index 0000000..18a2bcb --- /dev/null +++ b/configs/nixos/modules/shell/fish/functions/devtree.fish @@ -0,0 +1,273 @@ +set main_checkout ~/dev +set worktree_root ~/worktrees/dev +set base_branch main +set remote origin + +function __devtree_usage --no-scope-shadowing + echo "usage: devtree [args]" >&2 + echo "commands:" >&2 + echo " new create a branch worktree from main and cd into it" >&2 + echo " cd cd into an existing worktree" >&2 + echo " list list worktrees" >&2 + echo " sync [] fast-forward main, then rebase branch onto main" >&2 + echo " sync --all fast-forward main, then rebase clean topic worktrees onto main" >&2 + echo " remove remove a clean worktree, keeping the branch" >&2 + echo " prune [--dry-run] prune worktree metadata and stale merged local branches" >&2 +end + +function __devtree_path_for_branch --no-scope-shadowing --argument-names branch + set current_path "" + for line in (git -C $main_checkout worktree list --porcelain) + if string match -q 'worktree *' -- $line + set current_path (string replace 'worktree ' '' -- $line) + else if test "$line" = "branch refs/heads/$branch" + echo $current_path + return 0 + end + end + + return 1 +end + +function __devtree_checked_out_branches --no-scope-shadowing + for line in (git -C $main_checkout worktree list --porcelain) + if string match -q 'branch refs/heads/*' -- $line + string replace 'branch refs/heads/' '' -- $line + end + end +end + +function __devtree_update_main --no-scope-shadowing + if not test -d $main_checkout/.git + echo "devtree: main checkout not found at $main_checkout" >&2 + return 1 + end + + set dirty_lines (git -C $main_checkout status --porcelain) + if test (count $dirty_lines) -gt 0 + echo "devtree: refusing to update dirty main checkout: $main_checkout" >&2 + return 1 + end + + git -C $main_checkout fetch --prune $remote + or return $status + + git -C $main_checkout switch $base_branch + or return $status + + git -C $main_checkout merge --ff-only $remote/$base_branch +end + +function __devtree_sync_branch --no-scope-shadowing --argument-names branch + if test "$branch" = "$base_branch" + echo "$branch: already updated" + return 0 + end + + set path (__devtree_path_for_branch $branch) + if test -z "$path" + echo "devtree: no worktree found for branch '$branch'" >&2 + return 1 + end + + set dirty_lines (git -C $path status --porcelain) + if test (count $dirty_lines) -gt 0 + echo "$branch: skipped: dirty ($path)" + return 0 + end + + echo "$branch: rebasing onto $base_branch" + git -C $path rebase $base_branch +end + +if test (count $argv) -eq 0 + __devtree_usage + return 2 +end + +set command $argv[1] +set args $argv[2..-1] + +switch $command + case new + if test (count $args) -ne 1 + echo "usage: devtree new " >&2 + return 2 + end + + set branch $args[1] + set path $worktree_root/(string replace -a / __ $branch) + + if git -C $main_checkout show-ref --verify --quiet refs/heads/$branch + echo "devtree: branch already exists: $branch" >&2 + return 1 + end + + if test -e $path + echo "devtree: worktree path already exists: $path" >&2 + return 1 + end + + __devtree_update_main + or return $status + + mkdir -p $worktree_root + or return $status + + git -C $main_checkout worktree add -b $branch $path $base_branch + or return $status + + cd $path + + case cd + if test (count $args) -ne 1 + echo "usage: devtree cd " >&2 + return 2 + end + + set path (__devtree_path_for_branch $args[1]) + if test -z "$path" + echo "devtree: no worktree found for branch '$args[1]'" >&2 + return 1 + end + + cd $path + + case list + if test (count $args) -ne 0 + echo "usage: devtree list" >&2 + return 2 + end + + git -C $main_checkout worktree list + + case sync + if test (count $args) -gt 1 + echo "usage: devtree sync []" >&2 + echo " devtree sync --all" >&2 + return 2 + end + + __devtree_update_main + or return $status + + if test (count $args) -eq 1; and test "$args[1]" = --all + set current_path "" + set branches + for line in (git -C $main_checkout worktree list --porcelain) + if string match -q 'worktree *' -- $line + set current_path (string replace 'worktree ' '' -- $line) + else if string match -q 'branch refs/heads/*' -- $line + set branch (string replace 'branch refs/heads/' '' -- $line) + if test "$branch" != "$base_branch" + set --append branches $branch + end + end + end + + for branch in $branches + __devtree_sync_branch $branch + or return $status + end + else + if test (count $args) -eq 1 + set branch $args[1] + else + git rev-parse --show-toplevel >/dev/null 2>&1 + or begin + echo "devtree: run 'devtree sync' from a git checkout, or pass a branch" >&2 + return 2 + end + + set branch (git branch --show-current) + if test -z "$branch" + echo "devtree: could not determine the current branch" >&2 + return 2 + end + end + + __devtree_sync_branch $branch + end + + case remove + if test (count $args) -ne 1 + echo "usage: devtree remove " >&2 + return 2 + end + + set branch $args[1] + if test "$branch" = "$base_branch" + echo "devtree: refusing to remove $base_branch" >&2 + return 1 + end + + set path (__devtree_path_for_branch $branch) + if test -z "$path" + echo "devtree: no worktree found for branch '$branch'" >&2 + return 1 + end + + set dirty_lines (git -C $path status --porcelain) + if test (count $dirty_lines) -gt 0 + echo "devtree: refusing to remove dirty worktree: $path" >&2 + return 1 + end + + git -C $main_checkout worktree remove $path + + case prune + set dry_run false + if test (count $args) -gt 1 + echo "usage: devtree prune [--dry-run]" >&2 + return 2 + else if test (count $args) -eq 1 + if test "$args[1]" = --dry-run + set dry_run true + else + echo "usage: devtree prune [--dry-run]" >&2 + return 2 + end + end + + git -C $main_checkout worktree prune + or return $status + + git -C $main_checkout fetch --prune $remote + or return $status + + set checked_out (__devtree_checked_out_branches) + for line in (git -C $main_checkout for-each-ref --format='%(refname:short)|%(upstream:track)' refs/heads) + set parts (string split '|' -- $line) + set branch $parts[1] + set upstream_track $parts[2] + + if test "$branch" = "$base_branch" + continue + end + + if contains -- $branch $checked_out + continue + end + + if not string match -q '*gone*' -- $upstream_track + continue + end + + if not git -C $main_checkout merge-base --is-ancestor $branch $base_branch + echo "$branch: skipped: not merged into $base_branch" + continue + end + + if test "$dry_run" = true + echo "$branch: would delete" + else + git -C $main_checkout branch -d $branch + or return $status + end + end + + case '*' + echo "devtree: unknown command: $command" >&2 + __devtree_usage + return 2 +end