I was mid-deploy when my terminal froze for three seconds.
It continued on the wrong branch.
A scheduled automation had spotted a new PR comment, run git checkout in my working directory, and switched my branch while the deploy was still in flight. Silently. I watched files land on the device from the wrong build and sat there for a moment not understanding what had just happened.
That incident made me rebuild everything. While I was rebuilding, I wrote down every automation I run. This post is that document.
Short version: the fix was
git worktree. Every automation now gets its own folder, so nothing can ever touch the directory I am typing in. The 10 workflows below all sit on that one idea.
How to read this post
- The bug and the fix. Why shared directories break, and the one git command that solves it.
- The shared pattern. All 10 workflows have the same 5-step skeleton.
- The 10 workflows. What each one does, when it fires, and what surprised me.
- Build it yourself. A working starter kit you can clone and run today.
One note before the code. The snippets in section 3 are simplified sketches. Names like send-notification and fetch-unprocessed-comments stand in for glue I am not going to make you read twice. They show the shape of each workflow, not something you can paste into a terminal. Section 4 has the real, runnable versions. If you just want working code, skip to the end.
Part 1: Why it broke, and the one git command that fixed it
Every automation I had built started the same way:
cd ~/projects/my-repo
git fetch origin
git checkout <some-branch>
# do the work
Enter fullscreen mode Exit fullscreen mode
That cd was the problem. Every script operated in the same folder I was sitting in. Two firing at once meant a collision. One firing while I was deploying meant chaos.
I had already added a mutex so two automations could not run at the same time. That stopped them fighting each other. It never stopped one fighting me, because the lock only asked “is another automation running?” It never asked “is the human using this folder right now?”
Detection cannot reliably answer that question. Separation makes it unnecessary.
The problem: one folder, multiple writers
All three bots converge on ~/projects/my-repo. When one runs git checkout while you are mid-deploy, the deploy picks up the wrong branch’s files. No error, no warning.
The fix: give every bot its own folder
git worktree add creates a second folder attached to the same repository but checked out to its own branch:
git worktree add ~/projects/my-repo-automation feature/pr-comment-fix
Enter fullscreen mode Exit fullscreen mode
No copying. No cloning. Both folders share one .git object store, so this is close to instant even on a large repo.
The important property: checking out a branch in one worktree has no effect on any other. Each automation gets a disposable folder, does its work, and the folder is deleted. My directory is never involved.
Requires git 2.5 or newer, which in practice means any git you have.
Part 2: The pattern every workflow follows
All 10 use the same 5-step skeleton.
Step What happens 1. Trigger A schedule or a webhook fires 2. Isolate Create a fresh worktree 3. Work Do the actual task in there 4. Verify Run the build or tests 5. Notify and stop Tell me. Do not commit. Clean up.In code:
TICKET_ID="$1"
BRANCH="$2"
WORKTREE="$HOME/.automation/worktrees/$TICKET_ID"
# 2. Isolate
git -C ~/projects/my-repo worktree add "$WORKTREE" "$BRANCH"
cd "$WORKTREE"
# 3 and 4: work and verify happen here
# 5. Notify, then stop
git worktree remove "$WORKTREE"
Enter fullscreen mode Exit fullscreen mode
Step 5 is the one I never bend. Automation does the assembly. I keep the judgment.
Part 3: The 10 workflows
Reminder: these are sketches. Runnable code is in Part 4.
1. PR Comment Resolver
Does: Watches your open PRs for new review comments and attempts the clear ones in an isolated worktree, leaving a diff for you.
Fires: Every 5 minutes, only on PRs you authored.
For each comment it has not seen before, it reads the comment plus the surrounding file context, decides whether the request is unambiguous, implements it if so, flags it if not, then runs the build.
UNPROCESSED=$(fetch-unprocessed-comments "$PR_ID")
for COMMENT in $UNPROCESSED; do
CONTEXT=$(get-file-context-for-comment "$COMMENT")
claude "
Comment: $COMMENT
File context: $CONTEXT
If this is a clear, unambiguous change request: implement it.
If it is unclear or subjective: flag it for human review.
Run the build. Do not commit.
"
mark-processed "$COMMENT"
done
Enter fullscreen mode Exit fullscreen mode
What surprised me: roughly 70% of review comments resolve with no input from me. The other 30% are the ones I actually need to think about. Sorting comments into those two piles turned out to be worth more than the fixes.
2. Ticket Implementer
Does: When a ticket is assigned to you, creates a branch, reads the spec, and starts implementing.
Fires: Every 5 minutes, for tickets assigned in the last 2 hours.
The 2-hour window matters. Polling for “ever assigned to me” re-triggers on old work forever.
JQL="assignee = currentUser() AND updated >= -2h AND status = 'In Progress'"
NEW_TICKETS=$(fetch-jql "$JQL" | filter-unprocessed)
for TICKET in $NEW_TICKETS; do
BRANCH=$(create-branch-for-ticket "$TICKET")
WORKTREE="$HOME/.automation/worktrees/$TICKET"
git -C ~/projects/my-repo worktree add "$WORKTREE" "$BRANCH"
cd "$WORKTREE"
SPEC=$(get-ticket-body "$TICKET")
claude "Implement this: $SPEC. Run the build. Do not commit."
mark-processed "$TICKET"
done
Enter fullscreen mode Exit fullscreen mode
What surprised me: output quality tracks ticket quality almost exactly. This has quietly made me write better tickets, because I know something is going to try to implement them literally.
3. PR Description Generator
Does: On a new branch push, reads the diff and writes the PR description.
Fires: Webhook on branch push.
DIFF=$(git diff "origin/$BASE_BRANCH"...HEAD)
COMMITS=$(git log "origin/$BASE_BRANCH"...HEAD --oneline)
claude "
Diff: $DIFF
Commits: $COMMITS
Write a PR description:
- What changed (plain language, name real files and functions)
- Why this change was needed
- How to test it, step by step
- What reviewers should look at carefully
Be specific. No generic filler.
" > .pr-description.md
Enter fullscreen mode Exit fullscreen mode
Where it earns its keep: the “what reviewers should look at” section. It flags things I would not have thought to call out, and has caught real issues before a reviewer had to.
4. QA Handoff Note Generator
Does: When a ticket hits “Ready for Testing”, writes a QA note from the actual diff rather than the ticket text.
Fires: Every 5 minutes, on tickets that just transitioned.
DIFF=$(git diff origin/main...HEAD)
SPEC=$(get-ticket-body "$TICKET_ID")
DRAFT=$(claude "
Ticket: $SPEC
Diff: $DIFF
Write a QA handoff note:
- Environment setup steps
- Happy path test cases
- Edge cases (derive these from the diff, not the spec)
- Out of scope
")
post-staging-comment "$TICKET_ID" "$DRAFT"
send-notification "QA note drafted, approve before it goes visible"
Enter fullscreen mode Exit fullscreen mode
The guard: it posts to a staging comment and waits for me. A wrong test case is worse than no test case, because someone will trust it.
5. Best Practices Auto-Learner
Does: On PR merge, reads the resolved review comments and proposes additions to the team guidelines file.
Fires: Webhook on merge.
A slow burn. Does nothing visible on day one.
COMMENTS=$(get-resolved-comments-for-pr "$PR_ID")
CURRENT_GUIDELINES=$(cat GUIDELINES.md)
PROPOSED=$(claude "
Resolved review comments: $COMMENTS
Current guidelines: $CURRENT_GUIDELINES
Identify any new convention these comments suggest.
Only propose an addition if the pattern is clear and specific.
Do not repeat anything already covered.
Output the addition only, or nothing.
")
if [ -n "$PROPOSED" ]; then
echo "$PROPOSED" >> GUIDELINES.md
fi
Enter fullscreen mode Exit fullscreen mode
What surprised me: after three months this file was better than anything I would have written deliberately, because every rule came from a real argument about real code.
6. Work State Tracker
Does: Logs your branch, working tree state, and active tickets to a JSON file.
Fires: Every 30 minutes.
Read-only by design. It never checks anything out.
BRANCH=$(git -C ~/projects/my-repo rev-parse --abbrev-ref HEAD)
STATUS=$(git -C ~/projects/my-repo status --short)
ACTIVE=$(get-my-active-tickets)
jq -n \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg branch "$BRANCH" \
--arg status "$STATUS" \
--argjson tickets "$ACTIVE" \
'{timestamp: $ts, branch: $branch, dirty: ($status | length > 0), active_tickets: $tickets}' \
>> ~/.automation/work-log.json
Enter fullscreen mode Exit fullscreen mode
Why I trust this one most: it cannot break anything. It answers “what was I doing before that meeting?” without me having to remember.
7. Build Failure Notifier
Does: On a failed build, finds the first real error rather than the cascade it triggered, and sends you the file, line, and message.
Fires: Polls CI every 2 minutes while a build is running.
Most CI alerts say “build failed”. This one says TypeScript error: `src/auth.ts` line 42, type `string` is not assignable to `number`.
BUILD_LOG=$(fetch-build-log "$BUILD_ID")
FIRST_ERROR=$(echo "$BUILD_LOG" | claude "
Read this CI log.
Find the first real error that caused the failure.
Ignore downstream failures it triggered.
Output: filename, line number, message. One line.
")
send-notification "Build failed: $FIRST_ERROR"
Enter fullscreen mode Exit fullscreen mode
The insight: CI logs are mostly cascade noise. Digging out the real error costs 30 seconds every single time, which is exactly the kind of tax worth automating away.
8. Daily Standup Draft
Does: Reads yesterday’s commits and ticket activity and drafts your standup.
Fires: 08:45 on weekdays.
YESTERDAY=$(date -d "yesterday" +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d)
COMMITS=$(git -C ~/projects/my-repo log \
--author="$(git config user.name)" \
--after="$YESTERDAY 00:00" --before="$YESTERDAY 23:59" --oneline)
claude "
Commits yesterday: $COMMITS
Open PRs: $(fetch-my-open-prs)
Active tickets: $(get-my-active-tickets)
Write a three-sentence standup: what I did, what I am on today, blockers.
Name real tickets and code areas. No filler.
"
Enter fullscreen mode Exit fullscreen mode
How I use it: read, edit for 30 seconds, paste. The assembly is automated. The editing is the point.
9. Stale Work Detector
Does: Finds branches and PRs with no activity in 5+ days.
Fires: Mondays at 09:00.
CUTOFF=$(date -d '5 days ago' +%Y-%m-%d 2>/dev/null || date -v-5d +%Y-%m-%d)
STALE_BRANCHES=$(git -C ~/projects/my-repo for-each-ref \
--sort=committerdate refs/remotes/origin \
--format='%(committerdate:short) %(refname:short)' |
awk -v cutoff="$CUTOFF" '$1 < cutoff {print $2}')
send-notification "Stale work: $(claude "Summarise what needs attention:
$STALE_BRANCHES $(fetch-prs-with-no-activity-in-days 5)")"
Enter fullscreen mode Exit fullscreen mode
Why it matters: stale work is silent. It never comes up in standup. A scheduled nudge makes it visible without anyone needing to remember to look.
10. Relevant Merge Notifier
Does: When a teammate merges, tells you only if their changes touch files you are currently working on.
Fires: Webhook on every merge.
MERGED_FILES=$(get-changed-files-in-pr "$PR_ID")
MY_FILES=$(git -C ~/projects/my-repo diff --name-only "origin/$BASE_BRANCH"...HEAD)
OVERLAP=$(comm -12 <(echo "$MERGED_FILES" | sort) <(echo "$MY_FILES" | sort))
if [ -n "$OVERLAP" ]; then
send-notification "PR merged, overlaps your branch: $(echo $OVERLAP | tr '\n' ', ')"
fi
Enter fullscreen mode Exit fullscreen mode
The filter is the feature: without the overlap check this is noise on every merge. With it, every notification means “you are going to have to rebase, and here is exactly what changed.”
Part 4: Build it yourself
Everything above rests on about 80 lines of shared glue. Here it is for real, with nothing invented.
Clone it:
git clone https://github.com/kbhatnagar97/dev-workflow-automations.git
cd dev-workflow-automations
cp .env.example .env # fill in three values
chmod +x workflows/*.sh
./workflows/standup-draft.sh
Enter fullscreen mode Exit fullscreen mode
👉 github.com/kbhatnagar97/dev-workflow-automations (MIT)
What you need
-
git2.5+,jq, andgh(rungh auth loginonce) - Any LLM CLI. The scripts call
claude, but anything taking a prompt on-pworks. Change the binary name. - ntfy on your phone for alerts. Free, no account.
The glue layer
This is common.sh. Every workflow sources it.
#!/usr/bin/env bash
set -euo pipefail
[ -f "$(dirname "${BASH_SOURCE[0]}")/.env" ] && . "$(dirname "${BASH_SOURCE[0]}")/.env"
: "${REPO_DIR:?not set}"; : "${GH_REPO:?not set}"; : "${NTFY_TOPIC:?not set}"
STATE_DIR="${STATE_DIR:-$HOME/.automation/state}"
WORKTREE_DIR="${WORKTREE_DIR:-$HOME/.automation/worktrees}"
mkdir -p "$STATE_DIR" "$WORKTREE_DIR"
# Notifications, no account needed.
notify() { curl -fsS -d "$*" "https://ntfy.sh/$NTFY_TOPIC" >/dev/null || true; }
# Idempotency. Without this a 5-minute poll redoes the same work forever.
seen() { [ -e "$STATE_DIR/$1" ]; }
mark_seen() { : > "$STATE_DIR/$1"; }
# One automation at a time. mkdir is atomic everywhere, so no flock needed.
lock() {
local dir="$STATE_DIR/.lock"
mkdir "$dir" 2>/dev/null || { echo "another automation is running"; exit 0; }
# Expand $dir now: it is local and gone by the time the trap fires.
trap "rmdir '$dir' 2>/dev/null || true" EXIT
}
# The whole point: run something in a throwaway worktree, always clean up.
in_worktree() {
local name="$1" branch="$2"; shift 2
local wt="$WORKTREE_DIR/$name" rc=0
git -C "$REPO_DIR" fetch --quiet origin 2>/dev/null || true
git -C "$REPO_DIR" worktree add --force "$wt" "$branch" >/dev/null
( cd "$wt" && "$@" ) || rc=$?
git -C "$REPO_DIR" worktree remove --force "$wt" >/dev/null 2>&1 || true
return "$rc"
}
Enter fullscreen mode Exit fullscreen mode
Four things worth pointing out, because each one is a bug I actually hit:
-
fetchmust not be fatal. Withset -e, a failed fetch (offline, or no remote) kills the whole run before your work starts. Hence|| true. -
The trap uses double quotes.
$diris alocal, so it no longer exists when the trap fires at exit. Expand it at definition time or the lock never releases. -
rcis captured, not inherited.set -ewould abort before the cleanup line and leak worktrees. -
seen/mark_seenare not optional. They are the difference between a useful poll and an infinite loop.
A complete workflow
Workflow 1 from above, for real this time:
#!/usr/bin/env bash
set -euo pipefail
. "$(dirname "$0")/../common.sh"
lock
gh pr list --repo "$GH_REPO" --author "@me" --state open \
--json number,headRefName --jq '.[] | @json' |
while read -r pr; do
num=$(jq -r '.number' <<<"$pr")
branch=$(jq -r '.headRefName' <<<"$pr")
gh api "repos/$GH_REPO/pulls/$num/comments" \
--jq '.[] | {id, path, body} | @json' |
while read -r comment; do
id=$(jq -r '.id' <<<"$comment")
if seen "comment-$id"; then continue; fi
path=$(jq -r '.path' <<<"$comment")
body=$(jq -r '.body' <<<"$comment")
in_worktree "pr-$num" "$branch" \
claude -p "A reviewer left this on $path:
$body
Implement it only if it is a clear, unambiguous change request.
If it is subjective or ambiguous, change nothing and say why.
Do not commit anything."
mark_seen "comment-$id"
notify "PR #$num: looked at a comment on $path. Review the diff."
done
done
Enter fullscreen mode Exit fullscreen mode
Scheduling
*/5 * * * * cd /path/to/dev-workflow-automations && ./workflows/pr-comment-resolver.sh
Enter fullscreen mode Exit fullscreen mode
cron runs with a minimal PATH. If a script cannot find gh or jq, set PATH explicitly at the top of your crontab. This is the single most common reason a script that works in your terminal does nothing on a schedule.
Before you turn any of this on
-
Start with
standup-draft.sh. It is read-only. It cannot damage anything. Add the writing ones once you have watched the isolation work. - Your ntfy topic is a password. Anyone who guesses it reads your alerts. Make it random.
- Treat PR comments as untrusted input. You are feeding text written by other people straight into a model that can edit your code. A comment can contain instructions aimed at the model rather than at you. This is exactly why nothing commits automatically.
- Watch it for a week before trusting it. Every one of these started as a script that just printed what it would have done.
The one rule across all 10
Every automation here does three things:
- Works in an isolated worktree, never in your directory
- Runs in the background without blocking you
- Stops before committing anything that matters
The third is the one I never break. The machine does the assembly. I keep the judgment.
What are you still doing by hand that a script could pick up? Tell me below, I am genuinely curious what I have not thought of.


