diff --git a/doc/knowledge_base/claude-driven-development.md b/doc/knowledge_base/claude-driven-development.md index 8b882d0..d329c74 100644 --- a/doc/knowledge_base/claude-driven-development.md +++ b/doc/knowledge_base/claude-driven-development.md @@ -134,7 +134,7 @@ A single, project-independent bash helper provides four commands. It is the same - `cdd-worktree `, creates a worktree for `` and launches Claude Code in plan mode in it with the suggested first prompt already submitted. Requires a handoff file to exist. - `cdd-worktree-done`, run from a feature worktree once the PR has landed or the branch is being abandoned. Returns to the default branch, pulls, removes the worktree, resolves the branch (safe-delete if merged, force-delete if squash-merged, prompt otherwise), and deletes the handoff — and its sibling state record (§2.13) — iff the branch was deleted. - `cdd-worktree-list`, lists active handoffs with worktree/branch/PR status. Highlights stale entries. -- `cdd-worktree-resume []`, picks up a task started on another machine that has only a clone of the repo (no local branch, worktree, or handoff). It fetches `origin` and recreates a worktree tracking an **existing remote branch**, then `cd`s into it, ready for you to run a resume-side command (`/cdd-process-pr`, `/cdd-merge-base`, `/cdd-pre-pr`). With no argument it lists remote feature branches not already checked out and prompts for one. Its scope is deliberately **worktree and branch recreation only**: the handoff (§2.6) and the state record (§2.13) stay local per machine and are *not* transferred — sound, because the resume-side commands read PR/branch state from git and `gh`, not the handoff, and `cdd-state set` no-ops when the record is absent. Cross-machine *sync* of those two artifacts is separate, still-future work (issue #22); the direction is sketched in the roadmap. +- `cdd-worktree-resume []`, picks up a task started on another machine that has only a clone of the repo (no local branch, worktree, or handoff). It fetches `origin` and recreates a worktree tracking an **existing remote branch**, then `cd`s into it, ready for you to run a resume-side command (`/cdd-process-pr`, `/cdd-merge-base`, `/cdd-pre-pr`). With no argument it lists remote feature branches not already checked out and prompts for one; the fetch it runs first uses `--prune`, so branches deleted on the remote (as GitHub does when a PR merges) drop out of the list, leaving just the live branches — the default plus whatever feature branches still exist on the remote, whether or not they yet have a PR. Its scope is deliberately **worktree and branch recreation only**: the handoff (§2.6) and the state record (§2.13) stay local per machine and are *not* transferred — sound, because the resume-side commands read PR/branch state from git and `gh`, not the handoff, and `cdd-state set` no-ops when the record is absent. Cross-machine *sync* of those two artifacts is separate, still-future work (issue #22); the direction is sketched in the roadmap. The helper installs itself to a stable home that does not depend on a live CDD checkout. Run `tools/cdd-worktree.sh install` once (the script is dual-mode: sourced it defines the functions; run directly with `install` it sets itself up): this copies the script to `~/.cdd/tools/cdd-worktree.sh`, appends a marker-guarded source line to `~/.bashrc` and `~/.zshrc` (idempotent), drops thin PATH shims into `~/.local/bin` (one per command), and migrates any handoffs from the legacy `~/.claude-handoffs/` location. The PATH shims matter because the rc `source` line only reaches **interactive** shells — a stock `~/.bashrc` returns early for non-interactive shells via its `case $- in *i*` guard — so a command invoked from a non-interactive shell (notably Claude Code's Bash tool) would otherwise be "command not found"; the shim sources the helper and dispatches, while interactive shells still prefer the sourced function (functions shadow PATH, which is what lets `cdd-worktree` `cd` the caller into the new worktree). After installing, the commands work in every CDD project — including ones bootstrapped later — without any further per-project setup. diff --git a/scripts/worktree-resume-assert.sh b/scripts/worktree-resume-assert.sh index 028f659..d6b01ba 100755 --- a/scripts/worktree-resume-assert.sh +++ b/scripts/worktree-resume-assert.sh @@ -2,7 +2,7 @@ # End-to-end smoke for `cdd-worktree-resume` (issue #22) against a local bare repo. # # The real cross-machine flow is hard to exercise in CI, so this stands in a local -# `git init --bare` for `origin`: it pushes a default branch plus two feature +# `git init --bare` for `origin`: it pushes a default branch plus three feature # branches, clones a fresh "machine B" working copy that has NO local feature # branch / worktree, then sources the helper and asserts: # - `cdd-worktree-resume ` creates a sibling worktree tracking @@ -11,6 +11,10 @@ # returns 0 # - `cdd-worktree-resume` with no argument lists resumable remote branches and # creates the selected one (fed a numbered choice on stdin) +# - discovery's `git fetch --prune` drops a branch deleted on the remote (as +# GitHub does on merge), so it does not appear in the list +# - explicit `cdd-worktree-resume ` for a remote-deleted branch is +# refused and creates no worktree # # Usage: scripts/worktree-resume-assert.sh # Takes no arguments; it provisions and tears down its own temp tree. A stubbed @@ -46,6 +50,10 @@ EOF DEFAULT_BRANCH="main" FEATURE_A="gh_issue_99_demo" FEATURE_B="gh_issue_100_other" +# A branch that gets deleted on the remote (as GitHub does when a PR merges). +# Discovery's `git fetch --prune` must drop it, and explicit resume must refuse +# it. Sorts before A/B so it would be candidate 1 if pruning were missing. +FEATURE_C="gh_issue_50_gone" # Stub `claude` on PATH as a negative guard: the helper must never invoke it, so # any output here (a non-empty log) is a regression. @@ -73,6 +81,9 @@ git clone -q "$WORK/origin.git" "$WORK/seed" 2>/dev/null # empty-repo warning i git switch -q -c "$FEATURE_B" "$DEFAULT_BRANCH" echo "b" > b.txt; git add b.txt; git commit -q -m "feature b" git push -q -u origin "$FEATURE_B" + git switch -q -c "$FEATURE_C" "$DEFAULT_BRANCH" + echo "c" > c.txt; git add c.txt; git commit -q -m "feature c" + git push -q -u origin "$FEATURE_C" ) # Run the helper in a subshell so its `cd` and `set` don't leak into the test. @@ -122,7 +133,8 @@ pass "already-exists resume returns 0 without launching claude" # 4. Discovery mode (no argument): pick the first listed branch via stdin. # for-each-ref sorts refnames, so candidate 1 is the lexicographically first -# feature branch ($FEATURE_B = gh_issue_100_other sorts before $FEATURE_A). +# feature branch ($FEATURE_B = gh_issue_100_other sorts before $FEATURE_A; +# $FEATURE_C = gh_issue_50_gone sorts first but is not selected here). git clone -q "$WORK/origin.git" "$WORK/repoB" : > "$CLAUDE_STUB_LOG" set +e @@ -136,4 +148,34 @@ head="$(git -C "$WT_B" rev-parse --abbrev-ref HEAD)" [[ "$head" == "$FEATURE_B" ]] || fail "discovery worktree HEAD is '$head', expected '$FEATURE_B'" pass "discovery mode resumed the selected remote branch" +# 5. Discovery prunes branches deleted on the remote. Clone first (so the stale +# origin/$FEATURE_C ref exists locally), then delete the branch on origin, as +# GitHub does when a PR merges. Feed an invalid choice (0) so the helper prints +# the candidate list then bails without creating a worktree; inspect it. +git clone -q "$WORK/origin.git" "$WORK/repoC" +git -C "$WORK/seed" push -q origin --delete "$FEATURE_C" +set +e +list_out="$(run_resume "$WORK/repoC" "" "0" 2>/dev/null)" +set -e +grep -q "$FEATURE_A" <<<"$list_out" || fail "discovery should list live $FEATURE_A" +grep -q "$FEATURE_B" <<<"$list_out" || fail "discovery should list live $FEATURE_B" +if grep -q "$FEATURE_C" <<<"$list_out"; then + fail "discovery must prune $FEATURE_C after it was deleted on the remote" +fi +pass "discovery prunes branches deleted on the remote" + +# 6. Explicit resume of a remote-deleted branch is refused, and no worktree is +# created for it. repoC still carries the stale ref; the fetch --prune inside +# resume drops it, so show-ref then fails. +: > "$CLAUDE_STUB_LOG" +set +e +run_resume "$WORK/repoC" "$FEATURE_C" "" >/dev/null 2>&1 +rc=$? +set -e +[[ "$rc" -ne 0 ]] || fail "explicit resume of remote-deleted $FEATURE_C should exit non-zero" +[[ ! -d "$WORK/repoC-$FEATURE_C" ]] \ + || fail "explicit resume of remote-deleted $FEATURE_C must not create a worktree" +[[ ! -s "$CLAUDE_STUB_LOG" ]] || fail "refused resume must not launch claude" +pass "explicit resume of a remote-deleted branch is refused without a worktree" + echo "all worktree-resume smoke checks passed" diff --git a/tools/cdd-worktree.sh b/tools/cdd-worktree.sh index f31d18a..ae6f477 100755 --- a/tools/cdd-worktree.sh +++ b/tools/cdd-worktree.sh @@ -312,8 +312,11 @@ cdd-worktree-resume() { return 1 fi - if ! git fetch origin; then - echo "git fetch origin failed, aborting." >&2 + # --prune drops stale remote-tracking refs for branches deleted on the remote + # (GitHub deletes the head branch when a PR merges), so discovery lists exactly + # what still exists on origin — the default branch plus live feature branches. + if ! git fetch --prune origin; then + echo "git fetch --prune origin failed, aborting." >&2 return 1 fi @@ -324,7 +327,8 @@ cdd-worktree-resume() { if [[ -z "$branch" ]]; then # Discovery: remote feature branches (exclude default + HEAD) not already - # checked out as a local worktree. + # checked out as a local worktree. The fetch above pruned merged-and-deleted + # branches, so what remains is the set of live branches shown on GitHub. local have_gh=0 if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then have_gh=1 @@ -368,7 +372,8 @@ cdd-worktree-resume() { branch="${candidates[$(( choice - 1 ))]}" else if ! git show-ref --verify --quiet "refs/remotes/origin/$branch"; then - echo "No remote branch origin/$branch (after fetch)." >&2 + echo "No remote branch origin/$branch (after fetch --prune)." >&2 + echo "It may have been merged and deleted on the remote, or never pushed." >&2 echo "Use 'cdd-worktree-resume' with no argument to list resumable branches." >&2 return 1 fi