Implementation Plan: Fix Merge Target Branch Resolution

Branch: 049-fix-merge-target-resolution | Date: 2026-03-10 | Spec: spec.md Input: Feature specification from kitty-specs/049-fix-merge-target-resolution/spec.md

Summary

Top-level spec-kitty merge ignores feature meta.json target_branch when --feature is provided, defaulting to resolve_primary_branch() instead. This hotfix ports the correct resolution logic (get_feature_target_branch()) to the top-level merge command, establishing spec-kitty merge --feature <slug> as the single canonical merge API. The merge command template is aligned so both frontmatter and body reference only this canonical path. spec-kitty agent feature merge remains as an undocumented compatibility shim.

Technical Context

Language/Version: Python 3.11+ (existing spec-kitty codebase) Primary Dependencies: typer, pathlib, json (all already imported in merge.py) Storage: Filesystem only (meta.json in kitty-specs/) Testing: pytest (existing test infrastructure) Target Platform: CLI tool (macOS, Linux) Project Type: Single project (Python CLI) Performance Goals: <100ms added to merge startup (NFR-001) Constraints: Reuse get_feature_target_branch() from feature_detection.py (C-004); no new dependencies (C-003) Scale/Scope: 3 files changed, ~15 lines of runtime code, ~80 lines of tests

Constitution Check

Skipped — governance assets not available.

Project Structure

Documentation (this feature)

kitty-specs/049-fix-merge-target-resolution/
├── spec.md              # Feature specification
├── plan.md              # This file
├── data-model.md        # Not needed (no new entities)
├── quickstart.md        # Developer quickstart
├── checklists/
│   └── requirements.md  # Spec quality checklist
└── tasks.md             # Generated by /spec-kitty.tasks

Source Code (repository root)

src/specify_cli/
├── cli/commands/
│   └── merge.py                          # FIX: lines 721-724, target resolution
├── core/
│   ├── feature_detection.py              # REUSE: get_feature_target_branch() (lines 645-696)
│   └── git_ops.py                        # UNCHANGED: resolve_primary_branch() (lines 262-319)
└── missions/software-dev/
    └── command-templates/
        └── merge.md                      # FIX: align frontmatter + body to canonical spec-kitty merge

tests/specify_cli/cli/commands/
└── test_merge_target_resolution.py       # NEW: regression tests

Structure Decision: All changes are in existing source tree. One new test file. No structural changes.

Detailed Design

Change 1: Fix top-level merge target resolution (merge.py)

Current code (lines 721-724):

# Resolve target branch dynamically if not specified
if target_branch is None:
    from specify_cli.core.git_ops import resolve_primary_branch
    target_branch = resolve_primary_branch(repo_root)

Fixed code:

# Resolve target branch dynamically if not specified
if target_branch is None:
    if feature:
        from specify_cli.core.feature_detection import get_feature_target_branch
        target_branch = get_feature_target_branch(repo_root, feature)
    else:
        from specify_cli.core.git_ops import resolve_primary_branch
        target_branch = resolve_primary_branch(repo_root)

This is a direct port of the pattern from feature.py lines 1337-1343. When --feature is provided and --target is not, resolve from meta.json. When neither is provided, fall back to resolve_primary_branch().

Branch existence validation (after resolution):

# After target_branch is resolved, validate it exists
if feature and target_branch:
    result = subprocess.run(
        ["git", "rev-parse", "--verify", f"refs/heads/{target_branch}"],
        capture_output=True, cwd=repo_root,
    )
    if result.returncode != 0:
        # Also check remote
        result_remote = subprocess.run(
            ["git", "rev-parse", "--verify", f"refs/remotes/origin/{target_branch}"],
            capture_output=True, cwd=repo_root,
        )
        if result_remote.returncode != 0:
            error_msg = (
                f"Target branch '{target_branch}' from meta.json does not exist "
                f"locally or on origin. Check kitty-specs/{feature}/meta.json."
            )
            # Use existing error handling pattern in merge.py

This implements FR-006 (hard error for nonexistent target branch, no silent fallback).

Change 2: Align merge command template (merge.md)

Architecture decision: spec-kitty merge --feature <slug> is the single canonical merge API. One documented path prevents prompt drift and nondeterministic LLM tool choice. spec-kitty agent feature merge remains as an undocumented compatibility shim but is never referenced in templates or prompts.

Current template has split guidance: frontmatter references agent feature merge, body references spec-kitty merge. After this fix, both frontmatter and body reference only the canonical path.

Fixed template:

---
description: Merge a completed feature into the target branch and clean up worktree
---

# /spec-kitty.merge - Deterministic Merge

## Required Execution Sequence

1. Generate deterministic merge plan first:

\```bash
spec-kitty merge --feature <feature-slug> --dry-run --json
\```

2. Confirm effective merge tips from JSON (`effective_wp_branches`).

3. Execute the actual merge once:

\```bash
spec-kitty merge --feature <feature-slug>
\```

Both frontmatter and body now reference the same canonical command. No mention of agent feature merge anywhere in the template.

Change 3: Regression tests

Test file: tests/specify_cli/cli/commands/test_merge_target_resolution.py

Test cases (NFR-002: 4+ test cases required):

#TestSetupExpected
1Feature targets 2.xmeta.json with target_branch: "2.x", --feature provided, no --targetResolves to "2.x"
2Feature targets mainmeta.json with target_branch: "main", --feature provided, no --targetResolves to "main"
3Missing meta.jsonNo meta.json, --feature provided, no --targetFalls back to resolve_primary_branch()
4Explicit --target overridesmeta.json with target_branch: "2.x", --feature + --target mainResolves to "main" (explicit wins)
5Nonexistent target branchmeta.json with target_branch: "nonexistent", --feature providedHard error with clear message
6No --feature flagNo --feature providedUses resolve_primary_branch() (backward compat)
7Malformed meta.jsonInvalid JSON in meta.json, --feature providedFalls back to resolve_primary_branch()

Test strategy: Unit tests with mocked filesystem (tmp_path) and mocked git commands. Tests exercise the target resolution logic directly, not the full merge flow.

Risk Assessment

RiskLikelihoodImpactMitigation
Regression in no-feature merge pathLowHighTest case #6 explicitly covers backward compat
get_feature_target_branch import adds latencyVery lowLowFunction is a single file read; NFR-001 <100ms easily met
Template change breaks non-Claude agentsLowMediumAll 12 agents get the same template source; test with Claude + Codex + OpenCode

Complexity Tracking

No constitution violations. No complexity justifications needed.

Dependencies

  • get_feature_target_branch() in feature_detection.py (lines 645-696): Already correct, battle-tested via agent merge path. No changes needed.
  • resolve_primary_branch() in git_ops.py (lines 262-319): Unchanged, used as fallback.
  • Merge template source at src/specify_cli/missions/software-dev/command-templates/merge.md: Single source, propagated to all 12 agents via migrations.