Git branching in the build phase¶
Branching is where /zsl:tdd and
/zsl:tdd-parallel diverge most. Standalone
/zsl:tdd works on whatever branch you hand it. /zsl:tdd-parallel stages
an entire multi-branch dance and consolidates it into one PR. Both reach
back to docs/agents/ship-style.md to decide whether the slice gets a PR
or a direct push.
This page is the single source of truth for "what branches exist, where, and why." If you're trying to plan a fanout — or recover from one that halted — start here.
Standalone /zsl:tdd¶
/zsl:tdd doesn't create branches — it inherits the caller's current
branch. Typically you create that branch yourself with
/zsl:git-branch first
(feature/<name>, fix/<name>, etc.), then call /zsl:tdd against a
single sub-task.
gitGraph
commit id: "main tip"
branch feature/auth-oauth
checkout feature/auth-oauth
commit id: "RED: write failing test"
commit id: "GREEN: minimal impl"
commit id: "RED: next behavior"
commit id: "GREEN: pass it"
commit id: "REFACTOR: extract"
checkout main
merge feature/auth-oauth tag: "PR or direct-push"
At step 6 (Ship it — step 5 is Review), /zsl:tdd reads
docs/agents/ship-style.md and branches:
| Ship style | What /zsl:tdd does |
How the sub-task closes |
|---|---|---|
PR-style |
Pushes the branch, opens a PR via /zsl:commit |
Closes #<sub-task> in the PR body |
direct-push |
Pushes the branch; you merge by hand | Closes #<sub-task> in the commit body (replaces the Sub-task: line) |
| Local-markdown tracker | Flips Status: to shipped + git mv the issue file into issues/_done/ — in the same commit as the slice's code |
The folder move is the close. Prompts to archive the whole feature if it's now empty. |
/zsl:tdd-parallel orchestration¶
The orchestrator manages three layers of branches at once. The trick
is that the parent PRD branch doubles as the integration branch:
slice work doesn't merge to main directly — it merges to the PRD
branch, and the PRD branch is pushed exactly once at the end as a single
integration PR.
gitGraph
commit id: "main tip"
branch feature/123-auth-redesign
checkout feature/123-auth-redesign
commit id: "PRD branch created" type: HIGHLIGHT
branch tdd/124-add-oauth
checkout tdd/124-add-oauth
commit id: "RED 124"
commit id: "GREEN 124"
commit id: "REFACTOR 124"
checkout feature/123-auth-redesign
merge tdd/124-add-oauth tag: "wave 1 · --no-ff"
branch tdd/125-session-store
checkout tdd/125-session-store
commit id: "RED 125"
commit id: "GREEN 125"
checkout feature/123-auth-redesign
branch tdd/126-token-rotation
checkout tdd/126-token-rotation
commit id: "RED 126"
commit id: "GREEN 126"
checkout feature/123-auth-redesign
merge tdd/125-session-store tag: "wave 2a · --no-ff"
merge tdd/126-token-rotation tag: "wave 2b · --no-ff"
checkout main
merge feature/123-auth-redesign tag: "ONE integration PR"
The same picture as a layered flowchart, with the worktree filesystem layout alongside the branch model:
flowchart TB
main[("main")]
prd["feature/123-auth-redesign<br/>(PRD = integration branch)<br/>📁 repo root checkout"]
s1["tdd/124-add-oauth<br/>📁 .worktrees/124-add-oauth/"]
s2a["tdd/125-session-store<br/>📁 .worktrees/125-session-store/"]
s2b["tdd/126-token-rotation<br/>📁 .worktrees/126-token-rotation/"]
pr{{"ONE integration PR<br/>title: 'Auth redesign (#123)'<br/>body: Closes #123 #124 #125 #126"}}
main -->|"pre-flight 1c<br/>git checkout -b"| prd
prd -->|"git worktree add -b<br/>HEAD"| s1
prd -->|"git worktree add -b<br/>HEAD (after wave 1 merge)"| s2a
prd -->|"git worktree add -b<br/>HEAD (after wave 1 merge)"| s2b
s1 -.->|"merge --no-ff<br/>after wave 1"| prd
s2a -.->|"merge --no-ff<br/>after wave 2"| prd
s2b -.->|"merge --no-ff<br/>after wave 2"| prd
prd ==>|"git push -u origin<br/>gh pr create --base main"| pr
classDef root fill:#e0e7ff,stroke:#3f51b5,color:#1e293b;
classDef slice fill:#fef3c7,stroke:#d97706,color:#1e293b;
classDef ship fill:#dcfce7,stroke:#16a34a,color:#1e293b;
class prd root
class s1,s2a,s2b slice
class pr ship
Naming convention¶
| Object | Pattern | Created by | Lifespan |
|---|---|---|---|
| PRD branch | feature/<parent-num>-<slug> (slug ≤ 40 chars, kebab-case of parent title) |
Pre-flight 1c — only if orchestrator is on main; otherwise the current branch is adopted |
Until integration PR merges |
| Slice branch | tdd/<num>-<slug> |
Step 3c, git worktree add -b … HEAD |
Until manual git branch -d or pre-flight 1b sweep on next run |
| Worktree dir | .worktrees/<num>-<slug>/ |
Step 3c, same command. .worktrees/ is added to .gitignore if missing. |
Same as slice branch |
Why --no-ff and why one PR¶
Two interlocking reasons
Each slice merge is --no-ff so the integration history preserves a
clear "this is where wave N landed" merge commit. You can read the wave
shape off git log --first-parent feature/<parent> later.
One PR, not N PRs, because every push triggers CI. N slices × M
CI workflows over the life of a fanout = wasted minutes. Staging slice
work locally and pushing one consolidated branch costs one CI run. The
trade-off is no per-slice review granularity; this is acceptable because
/zsl:to-issues's wave model already asserts
same-wave slices are disjoint.
Inheritance: how later waves see earlier work¶
When wave 2 starts, the orchestrator runs git worktree add … HEAD from
the current PRD tip, which already contains wave 1's merges. So slice
tdd/125-session-store can safely import code from tdd/124-add-oauth —
it's just sitting in the parent. This is why same-wave slices must be
disjoint: they can't see each other yet.
Halt and inspect: where things end up if it stops mid-flight¶
| Halt cause | Where the orchestrator leaves you | What survives |
|---|---|---|
| Agent failure (step 3d) | On the PRD branch, the failing slice's worktree intact at .worktrees/<num>-<slug>/ |
Partial slice commits on tdd/<num>-<slug>; agent's heartbeat file is final-state evidence |
| Unresolvable merge conflict (step 3e) | On the PRD branch, mid-merge — no git merge --abort. Resolve in place. |
Conflict markers in place; the source slice branch unchanged; git diff --check shows conflicted files |
| Zero-progress halt (step 3a) | On the PRD branch with whatever has merged so far | Un-attempted slices' Blocked by sections reveal cycles, external refs, or non-existent issue refs |
The orchestrator never --forces anything
git branch -d refuses on unmerged branches and the pre-flight skips
them (logged as skipped — unmerged branch). git worktree remove
runs without --force so uncommitted changes block removal. Resume is
the user's job — the orchestrator does not auto-retry.
Running multiple /zsl:tdds in series by hand¶
A natural question: "Do I even need /zsl:tdd-parallel? Can't I just
branch the PRD, then run /zsl:tdd twice in a row?" Short answer:
/zsl:tdd was not designed for this — it doesn't create per-slice
branches, it commits on whatever branch you hand it, and it tries to ship
at step 6. Three patterns are possible; only one and a half work cleanly.
Pattern A — separate branches per /zsl:tdd (the well-defined path)¶
You branch off main for each slice. The "PRD feature branch" is
conceptual, not a real branch.
gitGraph
commit id: "main"
branch feature/124-foo
checkout feature/124-foo
commit id: "RED 124"
commit id: "GREEN 124"
commit id: "REFACTOR 124"
checkout main
merge feature/124-foo tag: "PR closes #124"
branch feature/125-bar
checkout feature/125-bar
commit id: "RED 125"
commit id: "GREEN 125"
checkout main
merge feature/125-bar tag: "PR closes #125"
Two PRs, merged independently. The PRD parent (#123) closes
automatically only when all its children close (GitHub) or when you run
the feature-level git mv (local-markdown).
Local-markdown closure inside Pattern A
On local-markdown trackers, each /zsl:tdd slice closes by flipping Status: to shipped and git mv-ing the issue file from .scratch/<NNN>-<feature-slug>/issues/<NN>-<slug>.md into issues/_done/ — atomic with the slice's code in the same commit. The folder move is the close. There's no separate "merge the PR" step; the slice branch merges to main and the issue file is already in issues/_done/. When the feature's issues/ is empty, /zsl:tdd prompts you to run the feature-level git mv to .scratch/_done/<YYYYMMDD>-<NNN>-<feature-slug>/. See The loop — Track & close for the full closure flow.
Pattern B — both /zsl:tdds on the same feature/123-prd branch (broken)¶
This is probably what you're picturing. It runs into a real edge case:
gitGraph
commit id: "main tip"
branch feature/123-prd
checkout feature/123-prd
commit id: "RED 124"
commit id: "GREEN 124"
commit id: "REFACTOR 124" tag: "push → PR opens"
commit id: "RED 125"
commit id: "GREEN 125" tag: "push → gh pr create FAILS"
What actually happens, step by step:
| Step | Result |
|---|---|
/zsl:tdd 124 commits |
2–3 commits on feature/123-prd |
/zsl:tdd 124 step 6 (PR-style) |
pushes branch, opens PR feature/123-prd → main with Closes #124 |
/zsl:tdd 125 commits |
2–3 more commits on the same branch |
/zsl:tdd 125 step 6 |
push fast-forwards into the existing PR; gh pr create refuses ("already exists for branch"); the skill stalls or asks you what to do |
Effect
Both slices end up in one PR that closes only #124. #125 closes
only if you manually edit the PR body to add Closes #125. Direct-push
mode is slightly cleaner — both commits land on main and each
Closes #<n> in the commit body works — but you've still pushed twice
(2× CI runs).
Pattern C — --no-ship twice, then ship manually¶
This is the only way to get /zsl:tdd-parallel-style behavior without
the orchestrator:
gitGraph
commit id: "main tip"
branch feature/123-prd
checkout feature/123-prd
commit id: "RED 124"
commit id: "GREEN 124"
commit id: "REFACTOR 124"
commit id: "RED 125"
commit id: "GREEN 125"
commit id: "REFACTOR 125" tag: "--no-ship: no push"
checkout main
merge feature/123-prd tag: "ONE PR · Closes #123 #124 #125"
You're recreating /zsl:tdd-parallel minus the worktrees and concurrency.
--no-ship is officially "used by /zsl:tdd-parallel" but nothing stops
you calling it directly. You'd then push manually and write the
integration PR body yourself.
Recommendation: decision tree¶
flowchart TB
q1{"Are the slices independent vertical<br/>increments you'd review separately?"}
q2{"PR-style repo?"}
pa["`**Pattern A**<br/>one branch + one PR per slice<br/>(/git-branch → /tdd → repeat)`"]:::good
tp["`**/tdd-parallel** <prd><br/>(--max 1 for strict serial)`"]:::good
pc["`**Pattern C**<br/>/tdd --no-ship twice<br/>then push manually`"]:::ok
q1 -->|Yes| pa
q1 -->|"No — want ONE consolidated PR"| q2
q2 -->|Yes| tp
q2 -->|"No (direct-push)"| pc
classDef good fill:#dcfce7,stroke:#16a34a,color:#1e293b;
classDef ok fill:#fef3c7,stroke:#d97706,color:#1e293b;
In priority order:
-
Pattern A is the default. Each slice is a vertical tracer bullet that earns its own PR. Zero ambiguity, no shared-branch interleaving, independent rollback, matches the
[AFK|HITL] <wave><letter>slice format/zsl:to-issuesproduces. The "cost" is N CI runs for N slices, but you were going to queue them anyway. -
If you specifically want one consolidated PR — just use
/zsl:tdd-parallel. Even with two slices. The--max Nflag caps within-wave concurrency, so set--max 1for strict serial execution while keeping the consolidation machinery. -
Manual bundle (Pattern C) — only when
/zsl:tdd-parallelis off-limits. Direct-push repos can't run/zsl:tdd-parallel. If you're in one and want bundling, this is the one case where Pattern C is the right answer.
See also¶
- Parallel TDD deep-dive — design rationale and the wave model
/zsl:tdd-parallelspec/zsl:tddspec/zsl:git-branch— branch-name conventions