acstack 0.49.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
  3. package/assets/terminal-workspace/helpers/acstack-clip +88 -0
  4. package/assets/terminal-workspace/helpers/acstack-dash +25 -0
  5. package/assets/terminal-workspace/helpers/acstack-db +86 -0
  6. package/assets/terminal-workspace/helpers/acstack-diff +268 -0
  7. package/assets/terminal-workspace/helpers/acstack-edit +25 -0
  8. package/assets/terminal-workspace/helpers/acstack-gh +47 -0
  9. package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
  10. package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
  11. package/assets/terminal-workspace/helpers/acstack-md +401 -0
  12. package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
  13. package/assets/terminal-workspace/helpers/acstack-open +217 -0
  14. package/assets/terminal-workspace/helpers/acstack-popup +13 -0
  15. package/assets/terminal-workspace/helpers/acstack-view +33 -0
  16. package/assets/terminal-workspace/helpers/xclip +31 -0
  17. package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
  18. package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
  19. package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
  20. package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
  21. package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
  22. package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
  23. package/assets/terminal-workspace/lib/commands.sh +296 -0
  24. package/assets/terminal-workspace/lib/core.sh +184 -0
  25. package/assets/terminal-workspace/lib/helper-install.sh +35 -0
  26. package/assets/terminal-workspace/lib/pi-packages.sh +331 -0
  27. package/assets/terminal-workspace/lib/pi-state.sh +479 -0
  28. package/assets/terminal-workspace/lib/versions.sh +254 -0
  29. package/assets/terminal-workspace/setup.sh +24 -4637
  30. package/cli/package.json +1 -1
  31. package/package.json +1 -1
  32. package/skills/handoff/SKILL.md +14 -14
  33. package/skills/handoff/launch-herdr.md +15 -4
  34. package/skills/implement/SKILL.md +5 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acstack",
3
3
  "description": "Agentic coding stack where planning grows only as far as needed: a map of decisions, tasks, checks, and handoffs behind a pluggable tracker.",
4
- "version": "0.49.0",
4
+ "version": "0.49.1",
5
5
  "author": {
6
6
  "name": "ac8318740"
7
7
  },
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bash
2
+ # Look at the Playwriter bridge from the dev machine, and fix it where that is
3
+ # possible from here.
4
+ #
5
+ # acstack-bridge status what is up, on both machines
6
+ # acstack-bridge fix [what] restart relay | tunnel | both (default both)
7
+ #
8
+ # Restarting either is a Windows scheduled task, so this machine cannot do it
9
+ # directly. It asks the opener, which runs over there and can. When the opener
10
+ # is not reachable either, it prints the handoff block to paste into a
11
+ # PowerShell on the laptop instead - the same block the bridge skill defines.
12
+ #
13
+ # What it still cannot do is arm the extension. That is a click inside a
14
+ # third-party extension and nothing on either machine can press it.
15
+ # Installed by acstack.
16
+ set -uo pipefail
17
+
18
+ BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
19
+ OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
20
+ OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
21
+ FREE_PORT="${ACSTACK_FREE_PORT:-$HOME/.claude/acstack/bin/vm-free-port.sh}"
22
+
23
+ CMD="${1:-status}"
24
+ WHAT="${2:-both}"
25
+
26
+ tok() { cat "$OPENER_TOKEN" 2>/dev/null; }
27
+
28
+ clear_port() { # clear_port <port> - let go of a forward this machine still holds
29
+ # A tunnel that will not rebind is usually held from this side: the VM's sshd
30
+ # still owns the forward channel of a session that dropped. Restarting the
31
+ # task on the laptop then fails exactly the way it failed before, because
32
+ # nothing here let go of the port. So this runs first.
33
+ #
34
+ # The clearer ships with acstack and may simply not be installed on a machine
35
+ # that got the terminal workspace some other way. Worth saying, but not a
36
+ # reason to refuse the restart, which may be all that was needed.
37
+ if [ ! -f "$FREE_PORT" ]; then
38
+ echo "no port clearer at $FREE_PORT - asking for the restart without freeing $1 first." >&2
39
+ return 0
40
+ fi
41
+ #
42
+ # A clearer that predates --port is the awkward case in between: it exists,
43
+ # so the missing-file warning never fires, but it only knows how to free
44
+ # 19988. Called bare it would clear the bridge port twice, never touch 19989,
45
+ # and hand back two successes - so the caller would believe both ports were
46
+ # freed. Report the version and clear nothing; guessing is worse than saying.
47
+ # It announces its age two ways: the flag is nowhere in its text, or it takes
48
+ # the flag, does not recognise the argument, and exits 64 - the usage code,
49
+ # which is not "the port is stuck" and is not worth a retry without the flag.
50
+ local older="is older than this branch, so it cannot free $1 on request."
51
+ local fix="Update acstack on this machine to refresh it. Asking for the restart anyway."
52
+ if ! grep -q -- '--port' "$FREE_PORT" 2>/dev/null; then
53
+ echo "$FREE_PORT $older It has no --port support at all. $fix" >&2
54
+ return 0
55
+ fi
56
+ local rc=0
57
+ bash "$FREE_PORT" --port "$1" >/dev/null 2>&1 || rc=$?
58
+ if [ "$rc" = "64" ]; then
59
+ echo "$FREE_PORT $older It refused --port with the usage code 64. $fix" >&2
60
+ elif [ "$rc" != "0" ]; then
61
+ echo "$FREE_PORT could not free $1 - asking for the restart anyway." >&2
62
+ fi
63
+ }
64
+
65
+ opener_call() { # opener_call <method> <path> [data]
66
+ local t; t=$(tok) || return 1
67
+ [ -n "$t" ] || return 1
68
+ if [ "$1" = "POST" ]; then
69
+ curl -fsS --connect-timeout 2 -m 40 -X POST -H "X-ACStack-Token: $t" \
70
+ -H 'Content-Type: application/json' --data "${3:-{\}}" "$OPENER$2" 2>/dev/null
71
+ else
72
+ curl -fsS --connect-timeout 2 -m 10 -H "X-ACStack-Token: $t" "$OPENER$2" 2>/dev/null
73
+ fi
74
+ }
75
+
76
+ handoff() { # what this machine cannot do, in the shape the other side expects
77
+ # Unquoted, because $1 names what is being asked for - so every PowerShell $
78
+ # inside has to be escaped or the shell eats it. A handoff block is pasted
79
+ # verbatim into a shell on the other machine, so a mangled one is worse than
80
+ # no block at all.
81
+ cat >&2 <<HANDOFF
82
+
83
+ --- BEGIN VM-SIDE HANDOFF (to Windows agent) ---
84
+ Context: the bridge needs $1 on the laptop, and the opener on 19989 is not
85
+ reachable from this machine either, so nothing here can do it.
86
+
87
+ Run on the Windows laptop:
88
+ Stop-ScheduledTask -TaskName 'Playwriter-*'
89
+ Start-ScheduledTask -TaskName 'Playwriter-Relay'
90
+ Start-ScheduledTask -TaskName 'Playwriter-Opener'
91
+ Get-ScheduledTask -TaskName 'Playwriter-Tunnel-*','Playwriter-OpenerTunnel-*' |
92
+ ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }
93
+ .\doctor.ps1
94
+
95
+ Expected result:
96
+ doctor.ps1 exits with all green rows, including "Relay listening on 19988"
97
+ and "Opener listening on 19989", and no tunnel-*.stuck markers.
98
+
99
+ Report back:
100
+ The doctor.ps1 output (paste the table).
101
+ --- END VM-SIDE HANDOFF ---
102
+ HANDOFF
103
+ }
104
+
105
+ case "$CMD" in
106
+ status)
107
+ # This machine's own view first: these say whether the tunnels arrived,
108
+ # which is the half the laptop cannot see.
109
+ if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
110
+ armed=$(curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/list" 2>/dev/null | tr -d '[:space:]')
111
+ case "$armed" in
112
+ ''|'[]') echo "relay: reachable, but the extension is armed on no tab" ;;
113
+ *) echo "relay: reachable, extension armed" ;;
114
+ esac
115
+ else
116
+ echo "relay: not reachable on $BRIDGE"
117
+ fi
118
+
119
+ if health=$(opener_call GET /health); then
120
+ echo "opener: reachable - $(printf '%s' "$health" | tr -d '[:space:]')"
121
+ elif [ -z "$(tok)" ]; then
122
+ echo "opener: no token at $OPENER_TOKEN (run register-tasks.ps1 on the laptop)"
123
+ else
124
+ echo "opener: not reachable on $OPENER"
125
+ fi
126
+
127
+ # And the laptop's view, which only the opener can fetch.
128
+ if tasks=$(opener_call GET /bridge/health); then
129
+ echo "tasks: $(printf '%s' "$tasks" | tr -d '[:space:]')"
130
+ else
131
+ echo "tasks: unknown - the opener is the only way to see them from here"
132
+ fi
133
+ echo "route: $(acstack-open --why 2>/dev/null || echo unknown)"
134
+ ;;
135
+ fix)
136
+ case "$WHAT" in
137
+ relay|tunnel|both) ;;
138
+ *) echo "usage: acstack-bridge fix [relay|tunnel|both]" >&2; exit 2 ;;
139
+ esac
140
+ # A tunnel restart is the only case where a port here is in the way. Both
141
+ # ports get cleared, because the opener's 19989 is forwarded by its own task
142
+ # and wedges on its own. fix relay touches no tunnel, so clearing a port
143
+ # there would be tearing down a working connection to fix something else.
144
+ if [ "$WHAT" != "relay" ]; then
145
+ clear_port 19988
146
+ clear_port 19989
147
+ fi
148
+ if out=$(opener_call POST /bridge/restart "$(printf '{"what":"%s"}' "$WHAT")"); then
149
+ echo "asked the laptop to restart: $WHAT"
150
+ printf '%s\n' "$out"
151
+ # Restarting is not recovering. Give the tunnels a moment to rebind, then
152
+ # say whether they actually came back rather than reporting the request.
153
+ sleep 5
154
+ rc=0
155
+ if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
156
+ echo "relay is answering again on $BRIDGE"
157
+ else
158
+ echo "the restart was accepted but the relay is still not answering here on 19988." >&2
159
+ echo " if this persists the port may be wedged on this machine:" >&2
160
+ echo " bash $FREE_PORT --port 19988" >&2
161
+ rc=1
162
+ fi
163
+ # Two tunnels, two verdicts. A relay that came back says nothing about
164
+ # the opener, and an opener that stayed down is the half that leaves this
165
+ # machine unable to reach a browser at all - so it is checked, and named.
166
+ if [ "$WHAT" != "relay" ]; then
167
+ if opener_call GET /health >/dev/null; then
168
+ echo "opener is answering again on $OPENER"
169
+ else
170
+ echo "the restart was accepted but the opener is still not answering here on 19989." >&2
171
+ echo " if this persists the port may be wedged on this machine:" >&2
172
+ echo " bash $FREE_PORT --port 19989" >&2
173
+ rc=1
174
+ fi
175
+ fi
176
+ exit $rc
177
+ fi
178
+ handoff "a restart of $WHAT"
179
+ exit 1
180
+ ;;
181
+ *)
182
+ echo "usage: acstack-bridge [status|fix [relay|tunnel|both]]" >&2
183
+ exit 2
184
+ ;;
185
+ esac
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ # Put text on the clipboard of the machine your terminal is on.
3
+ #
4
+ # acstack-clip "text" copy the arguments
5
+ # ... | acstack-clip copy stdin
6
+ # acstack-clip --out print what was copied last
7
+ #
8
+ # A VM reached over SSH has no display and no clipboard of its own, so
9
+ # xclip and friends have nothing to talk to. OSC 52 is the escape sequence
10
+ # that asks the terminal at the far end - Windows Terminal, iTerm2, kitty -
11
+ # to put text on its own clipboard. It is only bytes in the terminal stream,
12
+ # so it crosses SSH for free, and herdr forwards it from a pane to whatever
13
+ # terminal is hosting it.
14
+ #
15
+ # Reading back is not symmetrical. Windows Terminal refuses OSC 52 reads on
16
+ # purpose, so --out replays a local cache instead. Installed by acstack.
17
+ set -uo pipefail
18
+
19
+ CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/clipboard"
20
+
21
+ # A real clipboard beats an escape sequence when one exists. Anything sitting
22
+ # in this script's own directory is skipped, so the xclip shim installed
23
+ # beside it can never call back into here in a loop.
24
+ native() { # native <tool>; prints the path of a real one, if any
25
+ local tool="$1" self candidate
26
+ self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
27
+ while IFS= read -r candidate; do
28
+ [ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
29
+ printf '%s' "$candidate"; return 0
30
+ done < <(type -ap "$tool" 2>/dev/null)
31
+ return 1
32
+ }
33
+
34
+ has_display() { [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; }
35
+
36
+ if [ "${1:-}" = "--out" ] || [ "${1:-}" = "-o" ]; then
37
+ if has_display; then
38
+ for tool in wl-paste xclip xsel; do
39
+ real="$(native "$tool")" || continue
40
+ case "$tool" in
41
+ wl-paste) exec "$real" --no-newline ;;
42
+ xclip) exec "$real" -out -selection clipboard ;;
43
+ xsel) exec "$real" --output --clipboard ;;
44
+ esac
45
+ done
46
+ fi
47
+ [ -f "$CACHE" ] || { echo "acstack-clip: nothing copied yet" >&2; exit 1; }
48
+ cat "$CACHE"
49
+ exit 0
50
+ fi
51
+
52
+ if [ $# -gt 0 ]; then text="$*"; else text="$(cat)"; fi
53
+
54
+ mkdir -p "$(dirname "$CACHE")"
55
+ printf '%s' "$text" > "$CACHE"
56
+ chmod 600 "$CACHE" 2>/dev/null
57
+
58
+ if has_display; then
59
+ for tool in wl-copy xclip xsel; do
60
+ real="$(native "$tool")" || continue
61
+ case "$tool" in
62
+ wl-copy) printf '%s' "$text" | "$real" && exit 0 ;;
63
+ xclip) printf '%s' "$text" | "$real" -in -selection clipboard && exit 0 ;;
64
+ xsel) printf '%s' "$text" | "$real" --input --clipboard && exit 0 ;;
65
+ esac
66
+ done
67
+ fi
68
+
69
+ b64="$(printf '%s' "$text" | base64 | tr -d '\n')"
70
+ # 74994 bytes is the ceiling tmux puts on an OSC 52 payload, and the lowest
71
+ # of anything in this path. Past it the sequence is dropped without a word,
72
+ # so say so rather than report a copy that did not happen.
73
+ if [ ${#b64} -gt 74994 ]; then
74
+ echo "acstack-clip: too large for the terminal clipboard (${#b64} bytes encoded)" >&2
75
+ exit 1
76
+ fi
77
+
78
+ esc=$'\033]52;c;'"$b64"$'\a'
79
+ # tmux drops escape sequences it does not recognise unless they are wrapped
80
+ # for passthrough. herdr needs no wrapping - it forwards OSC 52 itself.
81
+ if [ -n "${TMUX:-}" ]; then
82
+ esc=$'\033Ptmux;'"${esc//$'\033'/$'\033\033'}"$'\033\\'
83
+ fi
84
+
85
+ # The controlling terminal, not stdout. Callers are usually TUIs that have
86
+ # redirected both streams, and OSC 52 has to reach the terminal itself.
87
+ if { printf '%s' "$esc" > /dev/tty; } 2>/dev/null; then exit 0; fi
88
+ printf '%s' "$esc" >&2
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # gh-dash with a section for the repo you are standing in. Installed by acstack.
3
+ set -uo pipefail
4
+ # Every action gh-dash takes shells out to gh and discards its stderr, so a
5
+ # refusal from GitHub reaches you as "exit status 1" for two seconds. acstack-gh
6
+ # goes in front of the real gh under that name and speaks the refusal aloud.
7
+ # Its directory holds nothing else, so nothing else on PATH is shadowed.
8
+ SHIM="$(mktemp -d)"; GEN=""
9
+ trap 'rm -rf "$SHIM"; rm -f "$GEN"' EXIT
10
+ ln -s "$(command -v acstack-gh)" "$SHIM/gh" 2>/dev/null && export PATH="$SHIM:$PATH"
11
+ BASE="$HOME/.config/gh-dash/config.yml"
12
+ REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)"
13
+ # Not exec, so the trap above still gets to clean up after the dashboard.
14
+ [ -z "$REPO" ] && { gh dash "$@"; exit $?; }
15
+ GEN="$(mktemp)"
16
+ REPO="$REPO" python3 - "$BASE" "$GEN" <<'PY'
17
+ import os, sys, yaml
18
+ cfg = yaml.safe_load(open(sys.argv[1])) or {}
19
+ repo = os.environ["REPO"]; short = repo.split("/")[-1]
20
+ s = [x for x in cfg.get("prSections", []) if x.get("title") != short]
21
+ s.insert(0, {"title": short, "filters": f"repo:{repo} is:open"})
22
+ cfg["prSections"] = s
23
+ yaml.safe_dump(cfg, open(sys.argv[2], "w"), sort_keys=False)
24
+ PY
25
+ gh dash --config "$GEN" "$@"
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # Open harlequin, the SQL editor, on one of your connection profiles.
3
+ #
4
+ # acstack-db one profile launches straight in, more than one opens a menu
5
+ # acstack-db <profile> that profile, no menu
6
+ #
7
+ # Profiles live in a TOML file of your own. This writes a starter the first
8
+ # time and never touches it again. Installed by acstack.
9
+ set -uo pipefail
10
+ # apply rewrites this line from harlequin.config_path. What ships here is the
11
+ # default, so the helper is right even on a machine nothing rewrote it on.
12
+ DB_CFG_DEFAULT="~/.config/acstack/harlequin.toml" # acstack:config_path
13
+ CFG="${ACSTACK_DB_CONFIG:-$DB_CFG_DEFAULT}"
14
+ # A human writes ~ for home, and a human writes this config. Expanding it here
15
+ # rather than at write time keeps the helper right on a machine whose HOME is
16
+ # not the one apply ran under.
17
+ case "$CFG" in
18
+ "~") CFG="$HOME" ;;
19
+ "~/"*) CFG="$HOME/${CFG#\~/}" ;;
20
+ esac
21
+
22
+ command -v harlequin >/dev/null 2>&1 || {
23
+ echo "harlequin is not installed. Run: setup.sh apply"; exit 1; }
24
+
25
+ if [ ! -f "$CFG" ]; then
26
+ mkdir -p "$(dirname "$CFG")"
27
+ cat > "$CFG" <<'T'
28
+ # harlequin connection profiles. acstack wrote this once and will not write it
29
+ # again. Edit it freely.
30
+ default_profile = "local"
31
+
32
+ [profiles.local]
33
+ adapter = "postgres"
34
+ conn_str = "postgres://localhost:5432/postgres"
35
+
36
+ # Snowflake. Take the # marks off and fill in your own account.
37
+ #
38
+ # Put the credentials in ~/.snowflake/connections.toml and name the connection
39
+ # here, rather than writing them into this file.
40
+ #
41
+ # externalbrowser authentication needs a browser on this machine. A dev machine
42
+ # reached over SSH has none, so use a key pair or a password instead.
43
+ #
44
+ # [profiles.snowflake]
45
+ # adapter = "snowflake"
46
+ # connection_name = "default"
47
+ T
48
+ echo "wrote a starter config at $CFG - edit it to reach your own database"
49
+ fi
50
+
51
+ launch() { exec harlequin --config-path "$CFG" -P "$1"; }
52
+ [ $# -gt 0 ] && launch "$1"
53
+
54
+ # Profile names, read with a regex rather than tomllib: tomllib needs python
55
+ # 3.11 and nothing else this script writes has a floor that high. Names are all
56
+ # that is wanted here, and a table header is the one line a regex reads safely.
57
+ rows=$(python3 - "$CFG" <<'PY'
58
+ import re, sys
59
+ text = open(sys.argv[1]).read()
60
+ d = re.search(r'(?m)^\s*default_profile\s*=\s*"([^"]+)"', text)
61
+ default = d.group(1) if d else ""
62
+ for m in re.finditer(r'(?m)^\s*\[profiles\.("?)([^\]"]+)\1\]\s*$', text):
63
+ name = m.group(2)
64
+ body = text[m.end():].split("\n[", 1)[0]
65
+ a = re.search(r'(?m)^\s*adapter\s*=\s*"([^"]+)"', body)
66
+ mark = " (default)" if name == default else ""
67
+ print("%s\t%-24s %-12s%s" % (name, name, a.group(1) if a else "?", mark))
68
+ PY
69
+ )
70
+ count=$(printf '%s\n' "$rows" | grep -c .)
71
+ case "$count" in
72
+ 0) echo "no profiles in $CFG"; exit 1 ;;
73
+ 1) launch "$(printf '%s' "$rows" | cut -f1)" ;;
74
+ esac
75
+
76
+ command -v fzf >/dev/null 2>&1 || {
77
+ echo "fzf is not installed, so there is no menu. Naming a profile still works:"
78
+ printf '%s\n' "$rows" | cut -f1 | sed 's/.*/ acstack-db "&"/'
79
+ exit 1; }
80
+
81
+ choice=$(printf '%s\n' "$rows" \
82
+ | fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
83
+ --prompt="db > " --header="harlequin · profile adapter" \
84
+ | cut -f1)
85
+ [ -n "$choice" ] || exit 0
86
+ launch "$choice"
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env bash
2
+ # Show a git diff in diffnav, with a banner naming what is being compared.
3
+ #
4
+ # acstack-diff this branch against dev, committed work only (alt+f)
5
+ # acstack-diff pick choose the comparison from a menu (alt+x)
6
+ # acstack-diff auto first non-empty of working tree, staged, last commit
7
+ #
8
+ # Every launch prepends a COMPARING block to the diff. diffnav renders whatever
9
+ # precedes the first "diff --git" line, which is how git show's commit header
10
+ # reaches the screen, so the block lands at the top of the content pane.
11
+ #
12
+ # diffnav's "o" key opens the file under the cursor with $EDITOR, in diffnav's
13
+ # own pane. That pane is a herdr popup for alt+f and alt+x, and a popup cannot
14
+ # host a second full-screen app, so the editor never draws there. acstack-edit
15
+ # gives the file its own herdr tab instead, and both diff keys then match.
16
+ set -uo pipefail
17
+ if command -v acstack-edit >/dev/null 2>&1; then
18
+ case "${EDITOR:-}" in *acstack-edit) EDITOR=nvim ;; esac # never nest
19
+ export ACSTACK_EDIT_EDITOR="${EDITOR:-nvim}"
20
+ export EDITOR=acstack-edit
21
+ fi
22
+ pick_checkout() {
23
+ # herdr groups worktrees as <root>/<repo>/<branch-slug>, so a pane often
24
+ # sits in the parent of several checkouts rather than in one.
25
+ local -a repos=()
26
+ while IFS= read -r d; do repos+=("$d"); done < <(
27
+ find . -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/.git' \; -print 2>/dev/null | sort)
28
+ case ${#repos[@]} in
29
+ 0) return 1 ;;
30
+ 1) cd "${repos[0]}" && return 0 ;;
31
+ *) echo "Not a repo, but it holds ${#repos[@]} checkouts:"; echo
32
+ local i=1
33
+ for d in "${repos[@]}"; do
34
+ printf ' %d) %-34s %s\n' "$i" "${d#./}" "$(git -C "$d" branch --show-current 2>/dev/null)"
35
+ i=$((i+1))
36
+ done
37
+ echo; read -rp "Which one? [1-${#repos[@]}, q to quit] " choice
38
+ [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#repos[@]} ] \
39
+ && cd "${repos[$((choice-1))]}" && return 0
40
+ return 1 ;;
41
+ esac
42
+ }
43
+ if ! git rev-parse --git-dir >/dev/null 2>&1; then
44
+ pick_checkout || { echo "No git checkout here: $PWD"; echo "Press any key..."; read -rsn1; exit 0; }
45
+ fi
46
+
47
+ BRANCH=$(git branch --show-current 2>/dev/null) || BRANCH=""
48
+ [ -n "$BRANCH" ] || BRANCH="HEAD (detached)"
49
+
50
+ # ---- refs -----------------------------------------------------------------
51
+ ref_exists() { git rev-parse --verify --quiet "$1" >/dev/null 2>&1; }
52
+ first_ref() { local r; for r in "$@"; do ref_exists "$r" && { echo "$r"; return 0; }; done; return 1; }
53
+ dev_ref() { first_ref origin/dev dev; } # the server's dev wins over a stale local one
54
+ # default_compare falls back to the default branch only where no dev branch exists
55
+ default_ref() {
56
+ local d; d=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
57
+ [ -n "$d" ] && { echo "$d"; return 0; }
58
+ first_ref origin/main main origin/master master
59
+ }
60
+ tip() { # tip <ref> -> "1b298bd . 4 hours ago . Merge pull request ..."
61
+ # kept short: diffnav's content pane is narrower than the popup, so a long
62
+ # line wraps and the banner stops looking like a banner.
63
+ git log -1 --format='%h %ar %s' "$1" 2>/dev/null | cut -c1-70
64
+ }
65
+
66
+ # ---- what diffnav cannot draw ---------------------------------------------
67
+ # diffnav hands each file to delta, and delta dies on SIGABRT when one line
68
+ # runs to hundreds of kilobytes. diffnav reports the child's death as
69
+ # "FATA signal: aborted (core dumped)" and quits, so one such file takes the
70
+ # whole diff down with it. A committed build artifact is what does it: this
71
+ # project's own cli/dist/index.js.map holds a single 982,428-character line,
72
+ # and the merge commit touching it aborted diffnav in 1.7 seconds.
73
+ #
74
+ # So the file goes, not the diff. Any file whose patch holds a line over the
75
+ # limit is dropped and named in the banner, and the rest is drawn as usual.
76
+ # 20000 is far above any line a person writes and far below where delta gives
77
+ # out. Raise or lower it with ACSTACK_DIFF_LINE_LIMIT.
78
+ LINE_LIMIT_DEFAULT=20000
79
+ LINE_LIMIT="${ACSTACK_DIFF_LINE_LIMIT:-$LINE_LIMIT_DEFAULT}"
80
+ # awk compares a length against whatever this holds. Hand it "abc" and every
81
+ # comparison is a string comparison that is never true, so the guard turns
82
+ # itself off and the abort comes back with nothing on screen to explain it.
83
+ # Hand it 0 and every file is dropped instead. Refuse both, out loud.
84
+ case "$LINE_LIMIT" in
85
+ ''|0|*[!0-9]*)
86
+ echo "acstack-diff: ACSTACK_DIFF_LINE_LIMIT is \"$LINE_LIMIT\", which is not a" >&2
87
+ echo " positive whole number. Using $LINE_LIMIT_DEFAULT." >&2
88
+ LINE_LIMIT=$LINE_LIMIT_DEFAULT ;;
89
+ esac
90
+ drop_unrenderable() { # drop_unrenderable <skipped-file> ; diff in, diff out
91
+ # One file at a time: a patch has to be held whole before its longest line
92
+ # is known. Everything before the first "diff --git" is a header, so it goes
93
+ # straight through. Written for gawk, mawk and busybox awk alike.
94
+ awk -v lim="$LINE_LIMIT" -v skip="$1" '
95
+ function flush() {
96
+ if (path == "") return
97
+ # A tab between the two fields. banner reads this file back and splits
98
+ # on the same tab, so the two have to move together.
99
+ if (long) printf "%s\t%d\n", path, maxlen > skip
100
+ else for (i = 1; i <= n; i++) print buf[i]
101
+ }
102
+ /^diff --git / {
103
+ flush()
104
+ split("", buf); n = 0; long = 0; maxlen = 0
105
+ path = $0; sub(/^diff --git a\//, "", path); sub(/ b\/.*$/, "", path)
106
+ if (path == "") path = "?"
107
+ }
108
+ {
109
+ if (path == "") { print; next }
110
+ if (length($0) > maxlen) maxlen = length($0)
111
+ if (length($0) > lim) long = 1
112
+ buf[++n] = $0
113
+ }
114
+ END { flush() }'
115
+ }
116
+
117
+ # ---- banner ---------------------------------------------------------------
118
+ banner() { # banner <source-label> <change-label> <what> <command>
119
+ # No line may start with a space: diffnav strips leading whitespace, so any
120
+ # indentation collapses and columns stop lining up.
121
+ printf 'COMPARING %s ==> %s\n' "$1" "$2"
122
+ # base and compare repeat their ref so each row ties back to the first line.
123
+ [ -n "${SRC_TIP:-}" ] && printf 'base %s\n' "$(printf '%s %s' "$1" "$SRC_TIP" | cut -c1-70)"
124
+ [ -n "${CHG_TIP:-}" ] && printf 'compare %s\n' "$(printf '%s %s' "$2" "$CHG_TIP" | cut -c1-70)"
125
+ printf 'showing %s\n' "$3"
126
+ printf 'command %s\n' "$4"
127
+ # A dropped file is named here rather than left to be noticed as missing.
128
+ # The tab is what drop_unrenderable writes between the two fields.
129
+ [ -s "${SKIP:-}" ] && while IFS="$(printf '\t')" read -r p len; do
130
+ printf 'skipped %s (one line is %s chars, and diffnav aborts on it)\n' "$p" "$len"
131
+ done < "$SKIP"
132
+ return 0
133
+ }
134
+ launch() { # launch <source> <change> <what> <git-args...>
135
+ local src="$1" chg="$2" what="$3"; shift 3
136
+ local cmd="git $*"
137
+ local tmp; tmp=$(mktemp); SKIP=$(mktemp)
138
+ git "$@" 2>/dev/null | drop_unrenderable "$SKIP" > "$tmp"
139
+ if grep -q '^diff --git' "$tmp"; then
140
+ # diffnav puts the line after "commit <sha>" in its status bar, and the bar
141
+ # stays put while you walk the file tree. A git show stream carries its own
142
+ # header, so only a plain diff needs one synthesised.
143
+ { [ "${1:-}" = diff ] && printf 'commit %s\n' "$(git rev-parse HEAD 2>/dev/null)"
144
+ banner "$src" "$chg" "$what" "$cmd"; echo; cat "$tmp"; } | diffnav
145
+ else
146
+ # Nothing for diffnav to draw, for one of two different reasons. The
147
+ # banner and the wait for a keypress are the same either way, so only the
148
+ # explanation sits inside the branch.
149
+ banner "$src" "$chg" "$what" "$cmd"
150
+ echo
151
+ if [ -s "$SKIP" ]; then
152
+ echo " Every file that changed carries a line too long for diffnav."
153
+ echo " They are named above, and nothing else differs between these two."
154
+ else
155
+ echo " No difference between these two. Nothing to show."
156
+ local dirty; dirty=$(git status --porcelain 2>/dev/null | grep -cv '^??')
157
+ if [ "${dirty:-0}" -gt 0 ]; then
158
+ echo " You do have $dirty file(s) changed but not committed."
159
+ echo " Press alt+x and pick \"my uncommitted changes\" to see them."
160
+ else
161
+ echo " Press alt+x to compare something else."
162
+ fi
163
+ fi
164
+ echo; echo "Press any key..."; read -rsn1
165
+ fi
166
+ rm -f "$tmp" "$SKIP"
167
+ }
168
+
169
+ # ---- comparisons ----------------------------------------------------------
170
+ against() { # against <base-ref> <committed|everything>
171
+ local base="$1" mode="$2" mb
172
+ SRC_TIP=$(tip "$base")
173
+ mb=$(git merge-base "$base" HEAD 2>/dev/null); mb=${mb:0:9}
174
+ [ -n "$mb" ] || { echo "No common history between $base and $BRANCH."
175
+ echo; echo "Press any key..."; read -rsn1; return 1; }
176
+ if [ "$mode" = committed ]; then
177
+ CHG_TIP=$(tip HEAD)
178
+ launch "$base" "$BRANCH" \
179
+ "commits on $BRANCH that $base does not have" \
180
+ diff "$base...HEAD"
181
+ else
182
+ CHG_TIP="the files as they sit on disk right now"
183
+ launch "$base" "$BRANCH plus my uncommitted changes" \
184
+ "everything $BRANCH has since it left $base, committed or not" \
185
+ diff "$mb"
186
+ fi
187
+ }
188
+ local_only() {
189
+ SRC_TIP=$(tip HEAD); CHG_TIP="the files as they sit on disk right now"
190
+ launch "the newest commit on $BRANCH" "my uncommitted changes" \
191
+ "edits I have made but not committed, staged or not" diff HEAD
192
+ }
193
+ one_commit() { # one_commit <sha>
194
+ SRC_TIP=$(tip "$1^" 2>/dev/null); CHG_TIP=$(tip "$1")
195
+ launch "the commit just before $1" "commit $1" "what this one commit changed" \
196
+ show "$1" -m --first-parent
197
+ }
198
+ auto() { # fallback when there is no branch comparison to make; $1 says why
199
+ local why="${1:-nothing new to compare against another branch}"
200
+ if ! git diff --quiet || ! git diff --cached --quiet; then local_only
201
+ else SRC_TIP=$(tip HEAD^ 2>/dev/null); CHG_TIP=$(tip HEAD)
202
+ launch "$BRANCH one commit back" "$BRANCH at its newest commit" \
203
+ "$why, and nothing is uncommitted, so here is that commit alone" \
204
+ show HEAD -m --first-parent; fi
205
+ }
206
+
207
+ # ---- picker ---------------------------------------------------------------
208
+ strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; }
209
+ pick_ref() { # pick_ref <prompt> -> a branch name
210
+ git for-each-ref --sort=-committerdate --format='%(refname:short)' \
211
+ refs/heads refs/remotes 2>/dev/null | grep -v '^origin/HEAD$' \
212
+ | fzf --prompt="$1 > " --no-multi --cycle --preview-window='right,60%' \
213
+ --preview='git log --oneline --decorate --color=always -20 {}'
214
+ }
215
+ pick_commit() { # pick_commit -> a short sha
216
+ git log --oneline --decorate --color=always -300 2>/dev/null \
217
+ | fzf --ansi --prompt="commit > " --no-multi --cycle --preview-window='right,60%' \
218
+ --preview='git show --stat --color=always {1}' \
219
+ | strip_ansi | awk '{print $1}'
220
+ }
221
+ pick() {
222
+ command -v fzf >/dev/null 2>&1 || { echo "fzf is not installed."; echo; auto; return; }
223
+ local dev def choice a b
224
+ dev=$(dev_ref); def=$(default_ref)
225
+ local -a rows=()
226
+ row() { rows+=("$(printf '%s\t%-24s ==> %-30s %s' "$1" "$2" "$3" "$4")"); }
227
+ [ -n "$dev" ] && [ "$dev" != "$BRANCH" ] && {
228
+ row dev-committed "$dev" "$BRANCH" "committed work only"
229
+ row dev-everything "$dev" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
230
+ [ -n "$def" ] && [ "$def" != "$BRANCH" ] && [ "$def" != "$dev" ] && {
231
+ row def-committed "$def" "$BRANCH" "committed work only"
232
+ row def-everything "$def" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
233
+ row local "HEAD" "my uncommitted work" "staged and unstaged changes only"
234
+ row commit "its parent" "one commit" "pick a commit from this branch's history"
235
+ row branch "any branch" "any branch" "pick the source, then the branch to compare"
236
+ choice=$(printf '%s\n' "${rows[@]}" \
237
+ | fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
238
+ --prompt="diff > " --header="on $BRANCH · source ==> change" \
239
+ | cut -f1)
240
+ case "$choice" in
241
+ dev-committed) against "$dev" committed ;;
242
+ dev-everything) against "$dev" everything ;;
243
+ def-committed) against "$def" committed ;;
244
+ def-everything) against "$def" everything ;;
245
+ local) local_only ;;
246
+ commit) a=$(pick_commit); [ -n "$a" ] && one_commit "$a" ;;
247
+ branch) a=$(pick_ref "source (compare against)") ; [ -z "$a" ] && exit 0
248
+ b=$(pick_ref "change (the new work)") ; [ -z "$b" ] && exit 0
249
+ SRC_TIP=$(tip "$a"); CHG_TIP=$(tip "$b")
250
+ launch "$a" "$b" "commits on $b that $a does not have" diff "$a...$b" ;;
251
+ *) exit 0 ;;
252
+ esac
253
+ }
254
+
255
+ # ---- entry ----------------------------------------------------------------
256
+ default_compare() { # alt+f: the newest commit on this branch, against origin/dev
257
+ local base; base=$(dev_ref) || base=$(default_ref)
258
+ # Only when this checkout IS the base branch is there nothing to compare.
259
+ if [ -z "$base" ] || [ "$base" = "$BRANCH" ] || [ "origin/$BRANCH" = "$base" ]; then
260
+ auto "$BRANCH is the branch everything else compares against"; return; fi
261
+ against "$base" committed
262
+ }
263
+ case "${1:-}" in
264
+ "") default_compare ;;
265
+ pick) pick ;;
266
+ auto) auto ;;
267
+ *) echo "usage: acstack-diff [pick|auto]"; exit 2 ;;
268
+ esac