-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathworktree.sh
More file actions
executable file
·152 lines (127 loc) · 4.46 KB
/
Copy pathworktree.sh
File metadata and controls
executable file
·152 lines (127 loc) · 4.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#!/usr/bin/env bash
# Worktree helper: keep the main checkout clean by doing all feature work in
# isolated worktrees under .worktrees/.
#
# bash .github/scripts/worktree.sh new fix/my-thing
# bash .github/scripts/worktree.sh list
# bash .github/scripts/worktree.sh done fix/my-thing
# bash .github/scripts/worktree.sh prune
set -euo pipefail
REMOTE="projectbluefin"
BASE_BRANCH="testing"
# --show-toplevel returns the *current* worktree's root, so it cannot identify
# the main checkout when this script runs from inside a worktree. The common
# git dir always lives in the main checkout, so derive the root from that.
GIT_COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
ROOT="$(dirname "$GIT_COMMON")"
WORKTREE_DIR="${ROOT}/.worktrees"
die() {
echo "ERROR: $*" >&2
exit 1
}
slugify() {
printf '%s' "${1//\//-}"
}
require_remote() {
git remote get-url "$REMOTE" >/dev/null 2>&1 ||
die "no '${REMOTE}' remote. Add it: git remote add ${REMOTE} git@github.com:projectbluefin/bluefin.git"
}
# A branch is disposable once its PR is MERGED or CLOSED. Squash merges leave no
# ancestry, so `git branch --merged` cannot answer this; ask the forge instead.
pr_state() {
local branch="$1"
command -v gh >/dev/null 2>&1 || return 1
gh pr list --repo projectbluefin/bluefin --head "$branch" --state all \
--json state --jq '.[0].state' 2>/dev/null
}
# Emit "<path>\t<branch>" for every worktree that has a branch checked out.
# --porcelain paths may contain spaces, so take everything after the keyword
# rather than a whitespace-split field. Detached worktrees emit no branch line
# and are skipped.
worktree_pairs() {
git worktree list --porcelain |
awk '/^worktree /{p=substr($0, 10)} /^branch /{sub("refs/heads/", "", $2); print p"\t"$2}'
}
cmd_new() {
local branch="${1:-}"
[[ -n "$branch" ]] || die "usage: worktree.sh new <branch-name>"
require_remote
local path
path="${WORKTREE_DIR}/$(slugify "$branch")"
[[ -e "$path" ]] && die "worktree already exists: ${path}"
echo "Fetching ${REMOTE}/${BASE_BRANCH}..."
git fetch "$REMOTE" "$BASE_BRANCH" --quiet
mkdir -p "$WORKTREE_DIR"
git worktree add "$path" -b "$branch" "${REMOTE}/${BASE_BRANCH}"
echo
echo "Worktree ready: ${path}"
echo "Branch '${branch}' is based on ${REMOTE}/${BASE_BRANCH}."
echo " cd ${path}"
echo "Push with: git push ${REMOTE} ${branch}"
}
cmd_list() {
local path branch state
worktree_pairs |
while IFS=$'\t' read -r path branch; do
[[ "$path" == "$ROOT" ]] && { printf '%-50s %-40s %s\n' "$path" "$branch" "(main checkout)"; continue; }
state="$(pr_state "$branch" || true)"
printf '%-50s %-40s %s\n' "$path" "$branch" "PR: ${state:-none}"
done
}
remove_worktree() {
local path="$1" branch="$2"
git worktree remove "$path" --force
git branch -D "$branch" 2>/dev/null || true
echo "Removed ${path} (branch ${branch})"
}
cmd_done() {
local target="${1:-}"
[[ -n "$target" ]] || die "usage: worktree.sh done <branch-name>"
local path
path="${WORKTREE_DIR}/$(slugify "$target")"
[[ -d "$path" ]] || die "no worktree at ${path}"
local branch
branch="$(git -C "$path" branch --show-current)"
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
die "worktree has uncommitted changes: ${path}
Commit or discard them first, or run: git worktree remove ${path} --force"
fi
remove_worktree "$path" "$branch"
}
cmd_prune() {
git worktree prune
local path branch state
while IFS=$'\t' read -r path branch; do
[[ "$path" == "$ROOT" ]] && continue
[[ "$path" == "$WORKTREE_DIR"/* ]] || continue
state="$(pr_state "$branch" || true)"
case "$state" in
MERGED | CLOSED)
if [[ -n "$(git -C "$path" status --porcelain)" ]]; then
echo "SKIP ${path}: PR ${state} but worktree is dirty"
continue
fi
remove_worktree "$path" "$branch"
;;
*)
echo "KEEP ${path} (branch ${branch}, PR: ${state:-none})"
;;
esac
done < <(worktree_pairs)
}
case "${1:-}" in
new) shift && cmd_new "$@" ;;
list) shift && cmd_list "$@" ;;
done) shift && cmd_done "$@" ;;
prune) shift && cmd_prune "$@" ;;
*)
cat >&2 <<'USAGE'
usage: worktree.sh <command>
new <branch> Create a worktree in .worktrees/ based on projectbluefin/testing
list List worktrees with their PR state
done <branch> Remove a worktree and delete its local branch
prune Remove every worktree whose PR is merged or closed
USAGE
exit 1
;;
esac