Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to herdr-automatic-rename are documented here. The format fo

## [Unreleased]

### Added

- Directory-derived workspace labels can now be rewritten with ordered `WORKSPACE_SUBSTITUTE_SETS` rules. The rewrite changes only the name displayed by herdr and leaves the worktree directory unchanged. A hand-entered name that differs from the directory-derived name is not altered; herdr does not expose whether a matching name was entered by hand.

### Fixed

- A numbered workspace goes on following its directory ([#13](https://github.com/qu8n/herdr-automatic-rename/issues/13)). herdr names a workspace after `identity_cwd`, its own tracked directory, and the first `workspace rename` freezes that name for good: herdr keeps the directory current and never labels from it again. Numbering a workspace pinned it to whatever it was called when it opened, and no smaller rename would have helped, because there is no rename that leaves the derivation alive.
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,17 @@ cp "$(dirname "$(herdr plugin list --json | jq -r '.result.plugins[]|select(.plu
~/.config/herdr-automatic-rename/config.sh
```

`HERDR_AUTOMATIC_RENAME_CONFIG` overrides that path. [config.example.sh](config.example.sh) documents every knob: numbering per row kind, agent titles, label length, the program lists (shells, ignored commands, custom labels), and Nerd Font icons.
`HERDR_AUTOMATIC_RENAME_CONFIG` overrides that path. [config.example.sh](config.example.sh) documents every knob: numbering per row kind, workspace display rewrites, agent titles, label length, the program lists (shells, ignored commands, custom labels), and Nerd Font icons.

For example, this shortens a displayed workspace name from `worktree-feature` to `wt-feature` without renaming its directory or Git worktree:

```bash
WORKSPACE_SUBSTITUTE_SETS=(
's|^worktree-|wt-|'
)
```

A workspace name entered by hand is unchanged when it differs from the directory-derived name. Herdr does not expose whether a matching name was entered by hand.

## Actions

Expand Down
90 changes: 71 additions & 19 deletions automatic-rename.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ LOCK_DIR="$STATE_DIR/lock"
RERUN_FLAG="$STATE_DIR/rerun"
CONFIG_FILE="${HERDR_AUTOMATIC_RENAME_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr-automatic-rename/config.sh}"

# Ordered `sed -E` rewrites for directory-derived workspace labels. Config loads
# later and may replace this empty default.
declare -p WORKSPACE_SUBSTITUTE_SETS >/dev/null 2>&1 || WORKSPACE_SUBSTITUTE_SETS=()

# `task` is the shape a TITLE arrives in: control characters gone, the leading run
# of non-alphanumerics gone (an agent parks a spinner glyph there), the agent's own
# brand gone with it, no trailing space, and inner runs of it collapsed. One
Expand Down Expand Up @@ -493,15 +497,16 @@ ar_state_get() { # <tab_id> <field>
jq -r --arg t "$1" --arg f "$2" '.[$t][$f] as $v | if $v == null then empty else $v end' \
"$STATE_FILE" 2>/dev/null
}
ar_state_set() { # <tab_id> <auto-name> <enabled true|false>
local base tmp
ar_state_set() { # <tab_id> <auto-name> <enabled true|false> [source-name]
local base tmp source=${4:-} filter=".[\$t] = {auto: \$a, enabled: \$e}"
base=$(ar_state_read) || return 1 # unreadable: leave the file alone
# A write that did not land reports it. Ownership IS this file, so swallowing a
# full disk or an unwritable state directory told the reset action a tab was
# re-adopted while the next pass, finding no entry, opted it straight back out.
tmp=$(mktemp "$STATE_DIR/.state.XXXXXX") || return 1
if printf '%s' "$base" | jq --arg t "$1" --arg a "$2" --argjson e "$3" \
'.[$t] = {auto: $a, enabled: $e}' > "$tmp" 2>/dev/null; then
[ "$#" -lt 4 ] || filter=".[\$t] = {auto: \$a, enabled: \$e, source: \$s}"
if printf '%s' "$base" | jq --arg t "$1" --arg a "$2" --argjson e "$3" --arg s "$source" \
"$filter" > "$tmp" 2>/dev/null; then
mv "$tmp" "$STATE_FILE" || return 1
else
rm -f "$tmp"
Expand Down Expand Up @@ -939,6 +944,16 @@ ar_project_base() {
printf '%s' "${dir##*/}"
}

# ar_workspace_subst <base> -> a directory-derived workspace label with each
# configured rewrite applied in order.
ar_workspace_subst() {
local s=$1 expr
for expr in "${WORKSPACE_SUBSTITUTE_SETS[@]}"; do
s=$(printf '%s' "$s" | sed -E "$expr")
done
printf '%s' "$s"
}

# ar_workspace_pane_dirs <workspace-list-json> -> one "<workspace_id><SEP><dir>"
# row per workspace, from the panes the pass already holds: its focused pane, or
# a pane of the tab it has active, or any pane of it.
Expand Down Expand Up @@ -1007,11 +1022,13 @@ ar_identity_base() { # <workspace_id>
# moved on and ours has not, and only the record tells that apart from a name
# somebody typed. Anything else is somebody's name and is left alone for good.
ar_ws_track_eligible() {
local key="ws:$1" slabel=$2 ibase=$3 enabled auto
local key="ws:$1" slabel=$2 ibase=$3 enabled auto source
enabled=$(ar_state_get "$key" enabled)
auto=$(ar_state_get "$key" auto)
source=$(ar_state_get "$key" source)
AR_WS_STATE_ENABLED=$enabled
AR_WS_STATE_AUTO=$auto
AR_WS_STATE_SOURCE=$source
if [ "$slabel" = "$ibase" ]; then
return 0
elif [ "$enabled" = "true" ] && [ "$slabel" = "$auto" ]; then
Expand All @@ -1021,17 +1038,32 @@ ar_ws_track_eligible() {
return 1
}

# ar_ws_claim <workspace_id> <base> - record that we own this workspace's base,
# ar_ws_claim <workspace_id> <base> <source> - record that we own this workspace's base,
# unless state already says exactly that (the steady state, every pass, for every
# tracked workspace: ar_state_set rewrites the whole file). Reads what
# ar_ws_track_eligible published for this same workspace. Only ever called for a
# base the workspace CARRIES -- a base recorded for a rename that never landed
# reads as a hand-typed name one pass later, and opts the workspace out.
ar_ws_claim() {
if [ "${AR_WS_STATE_ENABLED:-}" = "true" ] && [ "${AR_WS_STATE_AUTO:-}" = "$2" ]; then
if [ "${AR_WS_STATE_ENABLED:-}" = "true" ] && [ "${AR_WS_STATE_AUTO:-}" = "$2" ] &&
[ "${AR_WS_STATE_SOURCE:-}" = "$3" ]; then
return 0
fi
ar_state_set "ws:$1" "$2" true
ar_state_set "ws:$1" "$2" true "$3"
}

# ar_ws_subst_pending -> 0 when a prior substitution still differs from its
# directory-derived source and needs one pass after the rules are removed.
ar_ws_subst_pending() {
local base
base=$(ar_state_read) || return 1
printf '%s' "$base" | jq -e '
any(to_entries[]?;
(.key | startswith("ws:"))
and .value.enabled == true
and (.value.source | type) == "string"
and .value.auto != .value.source)
' >/dev/null 2>&1
}

# ar_state_prune_ws <keep workspace_ids...> - drop the "ws:" records of
Expand Down Expand Up @@ -1062,10 +1094,14 @@ ar_state_prune_ws() {
# and --clear skips it entirely: the uninstall path strips prefixes and retitles
# nothing.
ar_renumber_workspaces() {
local json=$1 rows wid label pos base want ibase track seen=""
local json=$1 rows wid label pos base want ibase track prefix index_pass=0 seen=""
[ -n "$json" ] || return 0
ar_index_pass workspaces && index_pass=1
rows=$(ar_workspace_positions "$json" "$(ar_collapsed_spaces)")
[ -n "$rows" ] || return 0
if [ -z "$rows" ]; then
[ "$CLEAR" = "1" ] || ar_state_prune_ws
Comment thread
qu8n marked this conversation as resolved.
Outdated
return 0
fi
AR_WS_IDENTITY=""
AR_WS_PANEDIR=""
if [ "$CLEAR" != "1" ]; then
Comment thread
qu8n marked this conversation as resolved.
Outdated
Expand All @@ -1078,15 +1114,26 @@ ar_renumber_workspaces() {
base=$(ar_strip_prefix "$label")
track=0
if ibase=$(ar_identity_base "$wid") && ar_ws_track_eligible "$wid" "$base" "$ibase"; then
base=$ibase
base=$(ar_workspace_subst "$ibase")
track=1
fi
[ -n "$base" ] || continue # empty label: nothing to number, leave it
want=$(ar_desired workspaces "$pos" "$base") # position 0 (hidden) -> bare, like 10+
if [ "$index_pass" = "1" ]; then
want=$(ar_desired workspaces "$pos" "$base") # position 0 (hidden) -> bare, like 10+
elif [ "$track" = "1" ]; then
prefix=$(ar_index_prefix "$label")
want="${prefix}${base}"
else
want=$label
fi
if [ "$want" != "$label" ]; then
"$HERDR" workspace rename "$wid" "$want" >/dev/null 2>&1 || continue
fi
[ "$track" = "1" ] && ar_ws_claim "$wid" "$base"
if [ "$track" = "1" ] &&
{ [ "$index_pass" = "1" ] || [ "$base" != "$ibase" ] ||
[ "${AR_WS_STATE_ENABLED:-}" = "true" ]; }; then
ar_ws_claim "$wid" "$base" "$ibase"
fi
done <<< "$rows"
# A workspace id carries no whitespace (both go through `clean`), so the
# space-joined list splits into one argument per workspace.
Expand Down Expand Up @@ -1426,7 +1473,11 @@ ar_notify() {
# whether to number or to strip; --clear ignores the toggles and strips
# everything (the uninstall path).
ar_reconcile() {
local wsjson snap
local wsjson snap workspace_pass=0
if ar_index_pass workspaces || [ "${#WORKSPACE_SUBSTITUTE_SETS[@]}" -gt 0 ] ||
ar_ws_subst_pending; then
workspace_pass=1
fi
# A reset deletes the target tab's state once (under the lock) so it re-adopts.
# Whether there was anything to re-adopt is read BEFORE the delete, because that
# is what the action reports back and `del` on a key that was never there
Expand Down Expand Up @@ -1511,14 +1562,15 @@ ar_reconcile() {
fi
else
wsjson=$("$HERDR" workspace list 2>/dev/null) || wsjson=""
if [ "$CLEAR" != "1" ] && [ "$NAME_TABS" = "1" ]; then
if [ "$CLEAR" != "1" ] &&
{ [ "$NAME_TABS" = "1" ] || [ "$workspace_pass" = "1" ]; }; then
AR_PANES_JSON=$("$HERDR" pane list 2>/dev/null) || AR_PANES_JSON='{"result":{"panes":[]}}'
fi
fi
# ar_index_pass decides which of these have work to do (numbering, or the
# strip a named-and-off kind asks for). Tabs carry an extra arm because they
# are the only kind we NAME, so that pass runs whatever the numbering says.
if ar_index_pass workspaces; then
# ar_index_pass decides which kinds have numbering work to do, or a prefix to
# strip. Workspace substitutions and tab naming also run their pass without
# numbering.
if [ "$workspace_pass" = "1" ]; then
ar_renumber_workspaces "$wsjson"
fi
if ar_index_pass tabs || [ "$NAME_TABS" = "1" ]; then
Expand Down
10 changes: 10 additions & 0 deletions config.example.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
# Anything else in brackets ("[wip] foo") is left alone, digits are the only
# trigger.

# Ordered `sed -E` rewrites for directory-derived workspace names. These change
# only the label shown by herdr; they do not rename the directory or Git
# worktree. A hand-entered name that differs from the derived directory name is
# left unchanged. Herdr exposes no way to distinguish a hand-entered name that
# exactly matches the derived name. For example, shorten "worktree-feature" to
# "wt-feature":
# WORKSPACE_SUBSTITUTE_SETS=(
# 's|^worktree-|wt-|'
# )

# ---- naming knobs (only used when NAME_TABS=1) ----

# 1 = a regular program shows its full command line ("psql -h db"); 0 = just its
Expand Down
126 changes: 126 additions & 0 deletions tests/test_ws_cwd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ check "outside any repo" "sub" "$(ar_project_base "$PB/plain/sub")"
check "trailing slash ignored" "sub" "$(ar_project_base "$PB/plain/sub/")"
check "relative path: basename" "notes" "$(ar_project_base "some/notes")"
check "empty path: empty base" "" "$(ar_project_base "")"

WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')
check "workspace display rewrite" "wt-feature" "$(ar_workspace_subst "worktree-feature")"
check "workspace rewrite nonmatch" "project-a" "$(ar_workspace_subst "project-a")"
WORKSPACE_SUBSTITUTE_SETS=()
rm -rf "$PB"

# Numbering only (NAME_TABS off, no tab/pane fixtures), so the rename log holds
Expand Down Expand Up @@ -389,4 +394,125 @@ check_contains "identity_cwd wins" "$(log)" "workspace rename w1 [1] from-identi
check_absent "pane dir ignored" "$(log)" "from-pane"
teardown

# ======================================================================
# Scenario 15: workspace substitutions change only the displayed, derived name
# and compose with numbering.
# ======================================================================
setup
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 worktree-feature)"
session "w1=/home/u/worktree-feature"
run_event workspace.created
check_contains "numbered workspace display rewritten" "$(log)" "workspace rename w1 [1] wt-feature"

clear_log
workspaces "$(ws w1 '[1] wt-feature')"
run_event pane.focused
check "rewritten workspace settles" "" "$(log)"
teardown

# ======================================================================
# Scenario 16: substitutions run without numbering when AUTO_INDEX is off.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 worktree-feature)"
session "w1=/home/u/worktree-feature"
run_event workspace.created
check_contains "unnumbered workspace display rewritten" "$(log)" "workspace rename w1 wt-feature"
teardown

# ======================================================================
# Scenario 17: the list-command fallback derives a new workspace from its pane
# before session.json has persisted it.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
mkdir -p "$SB/home/worktree-feature"
workspaces "$(ws w1 worktree-feature)"
session "w9=/home/other"
fixture panes.json <<JSON
{"result":{"panes":[
{"pane_id":"w1:p1","workspace_id":"w1","tab_id":"w1:t1","focused":true,
"foreground_cwd":"$SB/home/worktree-feature"}
]}}
JSON
run_event workspace.created
check_contains "fallback workspace display rewritten" "$(log)" "workspace rename w1 wt-feature"
teardown

# ======================================================================
# Scenario 18: a matching pattern does not rewrite a name typed by the user.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 '[1] worktree-incident')"
session "w1=/home/u/project-a"
run_event workspace.created
check "manual workspace name not rewritten" "" "$(log)"
teardown

# ======================================================================
# Scenario 19: removing the substitution restores the derived workspace label
# even when global numbering is off and would not otherwise run this pass.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 worktree-feature)"
session "w1=/home/u/worktree-feature"
run_event workspace.created

clear_log
: >"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 wt-feature)"
run_event pane.focused
check_contains "removed rewrite restores derived name" "$(log)" \
"workspace rename w1 worktree-feature"
teardown

# ======================================================================
# Scenario 20: a substitution-only pass does not claim a workspace when no rule
# changes its name, so removing the rules cannot leave stale ownership records.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 project-a)"
session "w1=/home/u/project-a"
run_event workspace.created
check "nonmatching rewrite does not rename" "" "$(log)"
check "nonmatching rewrite claims no state" "" \
"$(jq -r '."ws:w1".auto // ""' "$XDG_STATE_HOME/herdr-automatic-rename/state.json" 2>/dev/null)"
teardown

# ======================================================================
# Scenario 21: removing the last rewritten workspace also removes its pending
# restoration state instead of scheduling an empty workspace pass forever.
# ======================================================================
setup
export AUTO_INDEX=0
printf '%s\n' "WORKSPACE_SUBSTITUTE_SETS=('s|^worktree-|wt-|')" \
>"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces "$(ws w1 worktree-feature)"
session "w1=/home/u/worktree-feature"
run_event workspace.created

: >"$HERDR_AUTOMATIC_RENAME_CONFIG"
workspaces
session "w9=/home/other"
run_event workspace.closed
check "closed rewritten workspace state pruned" "null" \
"$(jq -r '."ws:w1" | tostring' "$XDG_STATE_HOME/herdr-automatic-rename/state.json" 2>/dev/null)"
teardown

t_summary
Loading