From f7e36466e649320ac85bd6c057d076d5d40d6f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jorge=20Lopes?= Date: Tue, 28 Jul 2026 20:22:56 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(hooks):=20branch-guard=20=E2=80=94=20k?= =?UTF-8?q?eep=20worktree-flow=20primary=20clones=20on=20a=20base=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PreToolUse(Bash) hook (lib/hooks/branch-guard.{sh,py}) that blocks a git checkout/switch which would move a worktree-flow repo's PRIMARY clone onto a non-base feature branch, steering to `devflow worktree`. Stops the churn where an agent parks the main clone on a feature branch, silently swapping whatever is symlinked to that clone (e.g. a local-dev plugin install) to the branch's code. Fail-open: base branches, any checkout inside a linked worktree, path restores, and non-worktree-flow repos are always allowed. A repo is worktree-flow if it has .worktrunk.toml, has >=1 linked worktree, or lives under a configured enforce-root. Personal config (optional, not shipped): ~/.config/devflow/branch-guard.json; env overrides DEVFLOW_BRANCH_GUARD_*. Registered globally by `devflow init`. Also hardens `devflow worktree`: after `wt switch --create`, read worktrunk's configured root and, if the worktree landed elsewhere (template ignored), move it under //. Co-Authored-By: Claude Opus 4.8 --- README.md | 24 ++++ lib/hooks/branch-guard.py | 247 ++++++++++++++++++++++++++++++++++++++ lib/hooks/branch-guard.sh | 12 ++ lib/init.sh | 11 ++ lib/worktree.sh | 54 +++++++++ 5 files changed, 348 insertions(+) create mode 100755 lib/hooks/branch-guard.py create mode 100755 lib/hooks/branch-guard.sh diff --git a/README.md b/README.md index 991dae1..78ad590 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,30 @@ devflow init --dev # explicit opt-in to local dev mode (symlinks) | Default (all users + maintainers) | `devflow init` | GitHub (`AndreJorgeLopes/devflow`) | Plugin cache | Yes (session-start pull) | | Local dev (opt-in) | `devflow init --dev` / `make plugin-dev` | Local directory | Symlinks | No (mirrors working tree; `git pull` to update) | +### Branch guard (keep the primary clone on a base branch) + +`devflow init` registers a `PreToolUse` (Bash) hook, `branch-guard`, that **blocks a `git checkout` / `git switch` which would move a repo's PRIMARY clone onto a non-base (feature) branch**, and steers you to `devflow worktree` instead. Feature work belongs in an isolated worktree, so the primary clone — and anything symlinked to it (e.g. a local-dev plugin install) — never silently serves in-progress branch code. + +It only governs the agent's git commands (a Claude Code hook cannot intercept your own terminal git; git has no blocking pre-checkout hook). It is **fail-open** and never blocks: base branches, any checkout inside a linked worktree, path restores (`git checkout -- file`), and repos not using the worktree flow are always allowed. + +A repo counts as "worktree-flow" (and is therefore guarded) when it has a `.worktrunk.toml`, OR already has ≥1 linked worktree, OR lives under a configured enforce-root. + +**Configuration** is optional and personal — put it in `~/.config/devflow/branch-guard.json` (keep it in your own dotfiles / yadm, not in the repo): + +```json +{ + "off": false, + "base_branches": ["release/*-lts"], + "enforce_roots": ["~/dev"] +} +``` + +- `base_branches` — extra branch names to treat as base (added to the built-in set: `main`, `master`, `develop`, `dev`, `development`, `stage`, `staging`, `sandbox`, `release`, `trunk`, `prod`, `production`, `next`, `canary`, `hotfix`). +- `enforce_roots` — extra roots under which EVERY repo is guarded, even before it has a worktree. +- `off` — set `true` to disable entirely. + +Env overrides (per-session): `DEVFLOW_BRANCH_GUARD_OFF=1`, `DEVFLOW_BRANCH_GUARD_BASE_BRANCHES=a,b`, `DEVFLOW_BRANCH_GUARD_ROOTS=/p1:/p2`. + --- ## Hindsight (Memory) diff --git a/lib/hooks/branch-guard.py b/lib/hooks/branch-guard.py new file mode 100755 index 0000000..4afaaa8 --- /dev/null +++ b/lib/hooks/branch-guard.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""devflow branch-guard — Claude Code PreToolUse(Bash) hook logic. + +Blocks a `git checkout` / `git switch` that would move a repo's PRIMARY clone +onto a non-base (feature) branch, in repos that use the worktree flow. Feature +work belongs in an isolated worktree, so the primary clone — and anything +symlinked to it (e.g. a devflow plugin install) — never silently serves +in-progress branch code. + +ALWAYS allowed (fail-open — never block by guessing): + - base branches (main/develop/staging/…, configurable) + - ANY checkout inside a linked worktree + - path restores (`git checkout -- `, `git checkout .`, `-p`) + - `git checkout -` / `git switch -` (previous branch — cannot classify) + - repos NOT using the worktree flow (no .worktrunk.toml, no linked worktrees, + not under a configured enforce-root) + - anything that can't be parsed / classified confidently + +PreToolUse protocol: exit 0 = allow; exit 2 + stderr = BLOCK (stderr shown to +the agent). Reads the hook JSON payload on stdin. + +Config is OPTIONAL and PERSONAL (never shipped in the repo): + ~/.config/devflow/branch-guard.json + { "off": false, "base_branches": ["release/*"], "enforce_roots": ["~/dev"] } + Env overrides: DEVFLOW_BRANCH_GUARD_OFF=1, + DEVFLOW_BRANCH_GUARD_BASE_BRANCHES=a,b, DEVFLOW_BRANCH_GUARD_ROOTS=/p1:/p2 +""" +import sys +import os +import json +import shlex +import subprocess + +BASE_DEFAULT = { + "main", "master", "develop", "dev", "development", + "stage", "staging", "sandbox", "release", "trunk", + "prod", "production", "next", "canary", "hotfix", +} +OPS = {"&&", "||", ";", "|", "&", "\n"} + + +def allow(): + sys.exit(0) + + +def block(msg): + sys.stderr.write(msg) + sys.exit(2) + + +def load_config(): + base = set(BASE_DEFAULT) + roots = [] + path = os.path.expanduser("~/.config/devflow/branch-guard.json") + try: + with open(path) as f: + cfg = json.load(f) + if cfg.get("off"): + allow() + base |= {str(b) for b in cfg.get("base_branches", [])} + roots += [os.path.abspath(os.path.expanduser(r)) + for r in cfg.get("enforce_roots", [])] + except FileNotFoundError: + pass + except Exception: + pass # malformed config must never block work + if os.environ.get("DEVFLOW_BRANCH_GUARD_OFF") == "1": + allow() + env_base = os.environ.get("DEVFLOW_BRANCH_GUARD_BASE_BRANCHES") + if env_base: + base |= {b.strip() for b in env_base.split(",") if b.strip()} + env_roots = os.environ.get("DEVFLOW_BRANCH_GUARD_ROOTS") + if env_roots: + roots += [os.path.abspath(os.path.expanduser(r)) + for r in env_roots.split(":") if r] + return base, roots + + +def git(dirpath, *args): + try: + r = subprocess.run(["git", "-C", dirpath, *args], + capture_output=True, text=True, timeout=5) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except Exception: + return 1, "", "" + + +def resolve_dir(base_cwd, gitargs): + """Honor `git -C ` (relative dirs resolved against the session cwd).""" + d = base_cwd + i = 0 + while i < len(gitargs): + if gitargs[i] == "-C" and i + 1 < len(gitargs): + nd = gitargs[i + 1] + d = nd if os.path.isabs(nd) else os.path.join(d, nd) + i += 2 + continue + i += 1 + return d + + +def find_subcommand(gitargs): + """First non-option token = the git subcommand. Skip global opts + their values.""" + skip_val = {"-C", "-c", "--namespace", "--git-dir", "--work-tree", "--exec-path"} + i = 0 + while i < len(gitargs): + a = gitargs[i] + if a in skip_val: + i += 2 + continue + if a.startswith("-"): + i += 1 + continue + return a, gitargs[i + 1:] + return None, [] + + +def parse_target(sub, rest): + """Return (branch, is_create) for a branch MOVE, else (None, False). + + Returns None for path restores, `-`, patch mode, or when no branch operand + is present — i.e. anything that is not a clear move onto a named branch. + """ + create_takes_value = {"-b", "-B", "-c", "-C", "--orphan"} + i = 0 + while i < len(rest): + a = rest[i] + if a == "--": + return None, False # path restore + if a in ("-", "."): + return None, False # previous-branch / path + if a in ("-p", "--patch"): + return None, False # interactive patch, not a switch + if a in create_takes_value: + return (rest[i + 1], True) if i + 1 < len(rest) else (None, False) + if a.startswith("-"): + # skip flags that consume a value so we don't mistake it for the branch + if a in ("--start-point", "-t", "--track"): + i += 2 + continue + i += 1 + continue + return a, False # first positional operand = branch/sha/path + return None, False + + +def build_message(branch, top): + repo = os.path.basename(top.rstrip("/")) or "repo" + slug = branch.replace("/", "-") + return ( + "\U0001F6AB devflow branch-guard blocked this command.\n\n" + f"Refusing to move the PRIMARY clone of '{repo}' onto feature branch " + f"'{branch}'.\n" + "This repo uses the worktree flow, so the primary clone must stay on a " + "base branch (main / develop / staging / ...). Feature work goes in an " + "isolated worktree, so the clone (and anything symlinked to it, e.g. a " + "devflow plugin install) never silently serves in-progress branch code.\n\n" + "Do this instead:\n" + f" devflow worktree {branch}\n" + f" -> creates ~/dev/.worktrees/{repo}/{slug} and moves you there\n" + " # fallback if worktrunk misplaces the path:\n" + f" git worktree add ~/dev/.worktrees/{repo}/{slug} -b {branch}\n\n" + "Then do the work inside that worktree. Base branches are always allowed.\n" + "Escape hatches: export DEVFLOW_BRANCH_GUARD_OFF=1, or set \"off\": true / add " + "the branch to \"base_branches\" in ~/.config/devflow/branch-guard.json." + ) + + +def main(): + try: + payload = json.load(sys.stdin) + except Exception: + allow() + + cmd = ((payload.get("tool_input") or {}).get("command") or "") + cwd = payload.get("cwd") or os.getcwd() + + # Fast pre-filter: skip the vast majority of commands with no git work. + if "git" not in cmd or ("checkout" not in cmd and "switch" not in cmd): + allow() + + base, roots = load_config() + base_lower = {b.lower() for b in base} + + try: + toks = shlex.split(cmd, posix=True) + except Exception: + allow() # unparseable command -> never block + + n = len(toks) + i = 0 + cmd_head = True # True at the start of a simple command (after a shell operator) + while i < n: + t = toks[i] + if t in OPS: + cmd_head = True + i += 1 + continue + if cmd_head and t == "git": + j = i + 1 + seg = [] + while j < n and toks[j] not in OPS: + seg.append(toks[j]) + j += 1 + d = resolve_dir(cwd, seg) + sub, rest = find_subcommand(seg) + if sub in ("checkout", "switch"): + branch, is_create = parse_target(sub, rest) + if branch and branch.lower() not in base_lower: + _guard_one(d, branch, is_create, roots) + i = j + continue + cmd_head = False + i += 1 + allow() + + +def _guard_one(d, branch, is_create, roots): + rc, top, _ = git(d, "rev-parse", "--show-toplevel") + if rc != 0 or not top: + return # not a git repo we can resolve -> allow + _, gitdir, _ = git(d, "rev-parse", "--absolute-git-dir") + if "/worktrees/" in gitdir: + return # inside a linked worktree -> feature branches are fine here + + # Is this a worktree-flow repo? + flow = os.path.exists(os.path.join(top, ".worktrunk.toml")) + if not flow: + _, wl, _ = git(d, "worktree", "list", "--porcelain") + flow = wl.count("worktree ") > 1 # has >=1 linked worktree + if not flow and roots: + ap = os.path.abspath(top) + flow = any(ap == r or ap.startswith(r + os.sep) for r in roots) + if not flow: + return # normal repo, not the worktree flow -> allow + + # Confirm it is really a branch move: a create, or an existing local branch. + guard = is_create + if not guard: + rc2, _, _ = git(d, "show-ref", "--verify", "--quiet", "refs/heads/" + branch) + guard = (rc2 == 0) + if guard: + block(build_message(branch, top)) + + +if __name__ == "__main__": + main() diff --git a/lib/hooks/branch-guard.sh b/lib/hooks/branch-guard.sh new file mode 100755 index 0000000..8488052 --- /dev/null +++ b/lib/hooks/branch-guard.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# devflow/lib/hooks/branch-guard.sh +# Claude Code PreToolUse(Bash) hook — thin wrapper around branch-guard.py. +# +# Blocks a git checkout/switch that would move a repo's PRIMARY clone onto a +# non-base feature branch (worktree-flow repos only). See branch-guard.py. +# +# Fail-open: if python3 is unavailable we must NOT block the agent's command. +# The JSON payload on stdin passes straight through to python (we don't read it +# here), so `exec` hands it off intact. +command -v python3 >/dev/null 2>&1 || exit 0 +exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/branch-guard.py" diff --git a/lib/init.sh b/lib/init.sh index 40cdb04..0116aa4 100644 --- a/lib/init.sh +++ b/lib/init.sh @@ -155,6 +155,17 @@ elif mode == 'hooks': else: print('Skip: skill-activation-log hook already registered') + # PreToolUse hook (matcher: Bash) — branch-guard. Blocks a git checkout/switch that + # would move a worktree-flow repo's PRIMARY clone onto a non-base feature branch, + # steering to `devflow worktree`. Fail-open; base branches + worktrees always allowed. + bg_cmd = hook_root + '/lib/hooks/branch-guard.sh' + if not any('branch-guard' in str(entry) for entry in pre_hooks): + pre_hooks.append({'matcher': 'Bash', 'hooks': [{'type': 'command', 'command': bg_cmd}]}) + changed = True + print('Added PreToolUse hook: branch-guard') + else: + print('Skip: branch-guard hook already registered') + # SessionStart hook — version-drift check. Warns (once/day, fail-silent) when the # installed devflow is behind the latest origin release, so a stale local install is # visible at session start instead of silently serving old skills/commands. diff --git a/lib/worktree.sh b/lib/worktree.sh index ea29612..556f585 100644 --- a/lib/worktree.sh +++ b/lib/worktree.sh @@ -50,6 +50,56 @@ _normalize_branch_name() { fi } +# _expected_worktree_root — the configured worktree root (the literal prefix of +# worktrunk's worktree-path template), so devflow never hardcodes a personal path. +# Prints the absolute root, or nothing if it can't be determined. +_expected_worktree_root() { + local cfg="${HOME}/.config/worktrunk/config.toml" + [[ -f "$cfg" ]] || return 0 + local tmpl prefix + tmpl="$(grep -E '^[[:space:]]*worktree-path[[:space:]]*=' "$cfg" 2>/dev/null | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^"//; s/"[[:space:]]*$//')" + [[ -n "$tmpl" ]] || return 0 + prefix="${tmpl%%\{\{*}" # everything before the first {{ placeholder + prefix="${prefix%/}" # drop a trailing slash + prefix="${prefix/#\~/$HOME}" # expand a leading ~ + [[ -n "$prefix" ]] && echo "$prefix" +} + +# _fix_worktree_location — worktrunk sometimes silently ignores the configured +# worktree-path template and drops the new worktree as a sibling of the repo. Detect +# that and move it under the configured root as //. +_fix_worktree_location() { + local branch="$1" + local root + root="$(_expected_worktree_root)" + if [[ -z "$root" ]]; then + warn "Could not read worktrunk's worktree-path; skipping location check." + return 0 + fi + local actual + actual="$(git worktree list --porcelain 2>/dev/null | awk -v b="refs/heads/${branch}" ' + /^worktree /{ $1=""; sub(/^ /,""); w=$0 } /^branch /{ if ($2==b) print w }')" + [[ -n "$actual" ]] || return 0 + case "$actual" in + "$root"/*) return 0 ;; # already under the configured root — nothing to do + esac + local common repo slug want + common="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd)" + repo="$(basename "$(dirname "$common")")" + slug="$(printf '%s' "$branch" | tr '/' '-')" + want="${root}/${repo}/${slug}" + [[ "$actual" == "$want" ]] && return 0 + warn "worktrunk placed the worktree outside ${root}:" + warn " ${actual}" + mkdir -p "$(dirname "$want")" 2>/dev/null + if git worktree move "$actual" "$want" 2>/dev/null; then + ok "Corrected worktree location -> ${want}" + else + warn "Auto-correct failed. Move it manually:" + warn " git worktree move '${actual}' '${want}'" + fi +} + devflow_worktree() { local name="" @@ -98,5 +148,9 @@ devflow_worktree() { die "Failed to create worktree '${name}' (wt exit code: ${wt_exit})" fi + # worktrunk occasionally ignores the configured worktree-path and drops the worktree + # as a sibling of the repo — detect that and correct it under the configured root. + _fix_worktree_location "$branch" + ok "Worktree '${name}' ready" } From 7c41efc31d791d0ef5b87a628386c3b51e64b654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jorge=20Lopes?= Date: Tue, 28 Jul 2026 20:29:12 +0200 Subject: [PATCH 2/2] feat(hooks): add branch-guard --cli mode for the git PATH shim Lets a `git` wrapper reuse the exact same guard decision (parse + clone-vs-worktree detection + flow-repo check) instead of duplicating it. `branch-guard.py --cli ` exits 2 + a stderr message when the checkout/switch should be blocked, else 0. Fail-open on anything unparseable so a shim can never wedge git. Co-Authored-By: Claude Opus 4.8 --- lib/hooks/branch-guard.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/hooks/branch-guard.py b/lib/hooks/branch-guard.py index 4afaaa8..c89f395 100755 --- a/lib/hooks/branch-guard.py +++ b/lib/hooks/branch-guard.py @@ -243,5 +243,39 @@ def _guard_one(d, branch, is_create, roots): block(build_message(branch, top)) +def cli_mode(argv): + """Decision entry point for the `git` PATH shim (terminal + non-Claude agents). + + Usage: branch-guard.py --cli + The shim passes the working directory and the raw git argv. We run the same + guard as the hook: exit 2 + a stderr message if the checkout/switch should be + blocked, else exit 0 (the shim then exec's the real git). Fail-open on anything + unparseable — a shim must never wedge git. + """ + if not argv: + sys.exit(0) + cwd = argv[0] + gitargs = list(argv[1:]) + if gitargs and gitargs[0] == "git": + gitargs = gitargs[1:] + try: + base, roots = load_config() + base_lower = {b.lower() for b in base} + sub, rest = find_subcommand(gitargs) + if sub in ("checkout", "switch"): + d = resolve_dir(cwd, gitargs) + branch, is_create = parse_target(sub, rest) + if branch and branch.lower() not in base_lower: + _guard_one(d, branch, is_create, roots) # exits 2 + message if blocked + except SystemExit: + raise + except Exception: + pass # never wedge git + sys.exit(0) + + if __name__ == "__main__": - main() + if len(sys.argv) > 1 and sys.argv[1] == "--cli": + cli_mode(sys.argv[2:]) + else: + main()