mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
Compare commits
11 Commits
benbtg/add
...
v0.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9405c845ee | ||
|
|
a72ba95460 | ||
|
|
fa93572e27 | ||
|
|
0b82a1ddf1 | ||
|
|
d3f872f484 | ||
|
|
8373a60107 | ||
|
|
9c4fa31cec | ||
|
|
de88c23bb6 | ||
|
|
f65d9f9382 | ||
|
|
ad9f047aaa | ||
|
|
927f54feea |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,34 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.10.1] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Update DocGuard — CDD Enforcement extension to v0.25.1 (#2909)
|
||||
- Update a11y-governance preset to v0.3.0 (#2867)
|
||||
- docs: document spec persistence models (#2856)
|
||||
- chore(catalog): bump Linear Integration to v0.3.0 (repo renamed to spec-kit-linear-sync) (#2893)
|
||||
- chore: update DocGuard extension to v0.25.0 (#2707)
|
||||
- chore: remove unused open_github_url/_StripAuthOnRedirect from _github_http.py (#2883)
|
||||
- fix(catalogs): validate extension and preset catalog payload shape (#2621)
|
||||
- feat(integration): add status reporting (#2674)
|
||||
- chore: release 0.10.0, begin 0.10.1.dev0 development (#2904)
|
||||
|
||||
## [0.10.0] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
|
||||
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
|
||||
- test(workflows): cover executable override fallback preflight (#2843)
|
||||
- Add GitHub Copilot CLI guidance to readme (#2891)
|
||||
- Update Security Review extension to v1.5.3 (#2898)
|
||||
- Update Architecture Guard extension to v1.8.17 (#2897)
|
||||
- feat(extensions): per-event hook lists with priority ordering (#2798)
|
||||
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
|
||||
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
|
||||
|
||||
## [0.9.5] - 2026-06-05
|
||||
|
||||
### Changed
|
||||
|
||||
10
README.md
10
README.md
@@ -584,16 +584,6 @@ Once the implementation is complete, test the application and resolve any runtim
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=github%2Fspec-kit&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=github/spec-kit&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=github/spec-kit&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=github/spec-kit&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 💬 Support
|
||||
|
||||
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.
|
||||
|
||||
@@ -44,7 +44,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
@@ -56,7 +56,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||
|
||||
@@ -7,7 +7,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
|
||||
@@ -11,6 +11,11 @@ Spec-Driven Development is a structured process that emphasizes:
|
||||
- **Multi-step refinement** rather than one-shot code generation from prompts
|
||||
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
|
||||
|
||||
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
|
||||
and `tasks.md` after requirements change. See
|
||||
[Spec Persistence Models](spec-persistence.md) for three common ways to manage
|
||||
those artifacts over time.
|
||||
|
||||
## Development Phases
|
||||
|
||||
| Phase | Focus | Key Activities |
|
||||
|
||||
107
docs/concepts/spec-persistence.md
Normal file
107
docs/concepts/spec-persistence.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Spec Persistence Models
|
||||
|
||||
Spec Kit intentionally leaves teams in control of what happens to `spec.md`,
|
||||
`plan.md`, and `tasks.md` after requirements change. The toolkit gives you a
|
||||
repeatable workflow, but it does not force one artifact maintenance strategy.
|
||||
|
||||
This page names three common models so teams can make that choice explicit.
|
||||
None is the default, and none is required by Spec Kit.
|
||||
|
||||
## Two Separate Questions
|
||||
|
||||
Spec-driven development has a temporal question: how long should the
|
||||
specification matter? One
|
||||
[overview of SDD tooling](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
frames that lifecycle in three levels:
|
||||
|
||||
- **Spec-first**: write a spec before coding, then allow it to be discarded.
|
||||
- **Spec-anchored**: keep the spec after implementation and use it for future
|
||||
changes.
|
||||
- **Spec-as-source**: treat the spec as the only human-edited source and
|
||||
regenerate implementation artifacts from it.
|
||||
|
||||
Spec Kit also exposes a second question: what happens to the artifact set when
|
||||
requirements change? The models below describe that mutation strategy.
|
||||
|
||||
## Flow-Back Spec
|
||||
|
||||
Use flow-back when `spec.md`, `plan.md`, `tasks.md`, and the implementation are
|
||||
all allowed to inform each other.
|
||||
|
||||
In this model, edits can begin in any artifact. A developer might update
|
||||
`tasks.md` during implementation, revise `plan.md` after a technical discovery,
|
||||
or adjust `spec.md` after a product clarification. The team then reconciles the
|
||||
artifact set manually so the final project history still makes sense.
|
||||
|
||||
Flow-back works well when:
|
||||
|
||||
- the team is small enough to notice and reconcile drift quickly
|
||||
- implementation discoveries are expected to reshape the original plan
|
||||
- speed matters more than preserving each intermediate decision as immutable
|
||||
history
|
||||
|
||||
The main risk is silent divergence. If the team changes lower-level artifacts
|
||||
without reflecting the decision back into `spec.md`, future contributors may
|
||||
not know which artifact to trust.
|
||||
|
||||
## Flow-Forward Spec
|
||||
|
||||
Use flow-forward when each feature directory should remain a historical record.
|
||||
|
||||
In this model, completed artifacts are treated as immutable. When requirements
|
||||
change, the team creates a new feature directory instead of mutating the
|
||||
existing `spec.md`, `plan.md`, or `tasks.md`. The older directory remains useful
|
||||
for audit, comparison, or explaining how the project reached its current state.
|
||||
|
||||
Flow-forward works well when:
|
||||
|
||||
- auditability and traceability matter
|
||||
- features are well-scoped and rarely revisited in place
|
||||
- the team wants a clear sequence of requirement changes over time
|
||||
|
||||
The main tradeoff is duplication. Related decisions can be spread across
|
||||
multiple feature directories, so teams need naming, linking, or review habits
|
||||
that make the lineage easy to follow.
|
||||
|
||||
## Living Spec
|
||||
|
||||
Use living spec when `spec.md` is the contract and the other artifacts are
|
||||
derived from it.
|
||||
|
||||
In this model, teams update `spec.md` first and then regenerate or revise
|
||||
`plan.md` and `tasks.md` from that source. The plan and task list are still
|
||||
valuable, but they are treated as disposable derivations rather than permanent
|
||||
sources of truth.
|
||||
|
||||
Living spec works well when:
|
||||
|
||||
- the product contract is stable enough to own the workflow
|
||||
- the team is comfortable regenerating derived artifacts after spec changes
|
||||
- consistency between requirements and implementation matters more than keeping
|
||||
every intermediate plan intact
|
||||
|
||||
The main risk is losing useful implementation rationale if derived artifacts are
|
||||
discarded without preserving important decisions elsewhere.
|
||||
|
||||
## Choosing a Model
|
||||
|
||||
The model is a team convention, not a CLI setting. A project can even use
|
||||
different models in different areas, as long as contributors know which one
|
||||
applies.
|
||||
|
||||
| Model | Mutation rule | Best fit | Watch out for |
|
||||
|---|---|---|---|
|
||||
| Flow-back spec | Edit any artifact, then reconcile | Fast iteration and close collaboration | Silent drift between artifacts |
|
||||
| Flow-forward spec | Create a new feature directory for new requirements | Audit trails and historical clarity | Duplicate or fragmented context |
|
||||
| Living spec | Edit `spec.md`; regenerate derived artifacts | Spec as contract | Lost rationale in regenerated files |
|
||||
|
||||
If your team has not chosen a model yet, start by answering two questions:
|
||||
|
||||
1. Should completed feature directories be historical records or editable work
|
||||
areas?
|
||||
2. Is `spec.md` the single source of truth, or are `plan.md` and `tasks.md`
|
||||
allowed to become co-equal sources?
|
||||
|
||||
Once those answers are clear, document the convention in your project
|
||||
constitution or team onboarding notes so future contributors know how to handle
|
||||
changes.
|
||||
@@ -6,7 +6,7 @@
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ rm -rf .venv dist build *.egg-info
|
||||
|---------|-----|
|
||||
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
||||
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
||||
| Git step skipped | You passed `--no-git` or Git not installed |
|
||||
| Git commands unavailable | Install the git extension with `specify extension add git` |
|
||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
|
||||
@@ -15,16 +15,13 @@ specify init [<project_name>]
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--here` | Initialize in the current directory instead of creating a new one |
|
||||
| `--force` | Force merge/overwrite when initializing in an existing directory |
|
||||
| `--no-git` | Skip git repository initialization |
|
||||
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
|
||||
| `--preset <id>` | Install a preset during initialization |
|
||||
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
|
||||
|
||||
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
|
||||
|
||||
> [!NOTE]
|
||||
> The git extension is currently enabled by default during `specify init`.
|
||||
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
|
||||
> Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
@@ -45,14 +42,8 @@ specify init --here --force --integration copilot
|
||||
# Use PowerShell scripts (Windows/cross-platform)
|
||||
specify init my-project --integration copilot --script ps
|
||||
|
||||
# Skip git initialization
|
||||
specify init my-project --integration copilot --no-git
|
||||
|
||||
# Install a preset during initialization
|
||||
specify init my-project --integration copilot --preset compliance
|
||||
|
||||
# Use timestamp-based branch numbering (useful for distributed teams)
|
||||
specify init my-project --integration copilot --branch-numbering timestamp
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -67,7 +58,7 @@ specify init my-project --integration copilot --branch-numbering timestamp
|
||||
specify check
|
||||
```
|
||||
|
||||
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
|
||||
Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
|
||||
|
||||
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
|
||||
|
||||
|
||||
@@ -126,6 +126,27 @@ specify integration upgrade [<key>]
|
||||
|
||||
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
|
||||
|
||||
## Report Integration Status
|
||||
|
||||
```bash
|
||||
specify integration status
|
||||
specify integration status --json
|
||||
```
|
||||
|
||||
Reports the current project's integration status without changing files. The
|
||||
status report includes the default integration, installed integrations,
|
||||
multi-install safety, missing managed files, modified managed files, invalid
|
||||
manifest paths, shared Spec Kit infrastructure health, unchecked manifests, and
|
||||
the target integration for default-sensitive shared templates. The JSON form is
|
||||
intended for CI and coding agents that need stable machine-readable status data;
|
||||
it also reports the raw recorded integrations and the integration manifests that
|
||||
were checked when state repair heuristics differ from the recorded file.
|
||||
The command exits 0 when the report status is `ok` or `warning`; it exits 1
|
||||
only when the report status is `error`. In JSON output, `multi_install_safe`
|
||||
is `null` when no installed integration set can be evaluated, such as when the
|
||||
integration state is missing, unreadable, lacks a valid recorded integration
|
||||
list, or records no installed integrations.
|
||||
|
||||
## Integration-Specific Options
|
||||
|
||||
Some integrations accept additional options via `--integration-options`:
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
items:
|
||||
- name: What is SDD?
|
||||
href: concepts/sdd.md
|
||||
- name: Spec Persistence Models
|
||||
href: concepts/spec-persistence.md
|
||||
|
||||
# Development workflows
|
||||
- name: Development
|
||||
|
||||
@@ -257,70 +257,38 @@ rm speckit.old-command-name.md
|
||||
# Restart your IDE
|
||||
```
|
||||
|
||||
### Scenario 4: "I'm working on a project without Git"
|
||||
### Scenario 4: "I don't want the git extension"
|
||||
|
||||
If you initialized your project with `--no-git`, you can still upgrade:
|
||||
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
|
||||
|
||||
```bash
|
||||
# Manually back up files you customized
|
||||
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
|
||||
|
||||
# Run upgrade
|
||||
specify init --here --force --integration copilot --no-git
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
The `--no-git` flag skips git initialization but doesn't affect file updates.
|
||||
|
||||
---
|
||||
|
||||
## Using `--no-git` Flag
|
||||
|
||||
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
|
||||
|
||||
- You manage version control differently (Mercurial, SVN, etc.)
|
||||
- Your project is part of a larger monorepo with existing git setup
|
||||
- You're experimenting and don't want version control yet
|
||||
|
||||
**During initial setup:**
|
||||
If you later decide you want the git extension's commands and hooks, install it explicitly:
|
||||
|
||||
```bash
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify extension add git
|
||||
```
|
||||
|
||||
**During upgrade:**
|
||||
|
||||
```bash
|
||||
specify init --here --force --integration copilot --no-git
|
||||
```
|
||||
|
||||
### What `--no-git` does NOT do
|
||||
|
||||
❌ Does NOT prevent file updates
|
||||
❌ Does NOT skip slash command installation
|
||||
❌ Does NOT affect template merging
|
||||
|
||||
It **only** skips running `git init` and creating the initial commit.
|
||||
|
||||
### Working without Git
|
||||
|
||||
If you use `--no-git`, you'll need to manage feature directories manually:
|
||||
|
||||
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
|
||||
Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
|
||||
|
||||
```bash
|
||||
# Bash/Zsh
|
||||
export SPECIFY_FEATURE="001-my-feature"
|
||||
export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
|
||||
|
||||
# PowerShell
|
||||
$env:SPECIFY_FEATURE = "001-my-feature"
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
|
||||
```
|
||||
|
||||
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks.
|
||||
|
||||
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
|
||||
Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-08T00:00:00Z",
|
||||
"updated_at": "2026-06-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -438,7 +438,7 @@
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
},
|
||||
"brownkit": {
|
||||
"name": "BrownKit \u2014 Brownfield Discovery for Spec-Kit",
|
||||
"name": "BrownKit — Brownfield Discovery for Spec-Kit",
|
||||
"id": "brownkit",
|
||||
"description": "Evidence-driven capability discovery, security and QA risk assessment for existing codebases.",
|
||||
"author": "Maksim Shautsou",
|
||||
@@ -849,10 +849,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
|
||||
"author": "raccioly",
|
||||
"version": "0.9.11",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip",
|
||||
"version": "0.25.1",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.25.1/spec-kit-docguard-v0.25.1.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -886,7 +886,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-18T18:53:31Z"
|
||||
"updated_at": "2026-06-09T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1251,12 +1251,12 @@
|
||||
"id": "linear",
|
||||
"description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
|
||||
"author": "Ash Brener",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-linear/archive/refs/tags/v0.2.0.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-linear",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-linear",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-linear/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-linear/releases",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-linear-sync",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-linear-sync",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-linear-sync/releases",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
@@ -1277,7 +1277,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-01T00:00:00Z"
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"m365": {
|
||||
"name": "Microsoft 365 Integration",
|
||||
@@ -3607,4 +3607,4 @@
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
|
||||
|
||||
The extension bundles cross-platform scripts:
|
||||
|
||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||
- `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
|
||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||
- `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
|
||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||
|
||||
@@ -31,8 +31,9 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
|
||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
||||
4. Default to `sequential` if none of the above exist
|
||||
|
||||
## Execution
|
||||
|
||||
@@ -43,10 +44,10 @@ Generate a concise short name (2-4 words) for the branch:
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: create-new-feature.sh
|
||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||
# Git extension: create-new-feature-branch.sh
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.sh script.
|
||||
# Sources common.sh from the project's installed scripts, falling back to
|
||||
# git-common.sh for minimal git helpers.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: create-new-feature.ps1
|
||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||
# Git extension: create-new-feature-branch.ps1
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.ps1 script.
|
||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||
# git-common.ps1 for minimal git helpers.
|
||||
[CmdletBinding()]
|
||||
@@ -19,7 +20,7 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
@@ -37,7 +38,7 @@ if ($Help) {
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-03T00:00:00Z",
|
||||
"updated_at": "2026-06-05T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, and didactic inline-code-comment review to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -18,7 +18,7 @@
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"templates": 10,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -29,7 +29,7 @@
|
||||
"inclusion"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-05T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.6.dev0"
|
||||
version = "0.10.1"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -111,9 +111,6 @@ if $PATHS_ONLY; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate branch name
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
|
||||
@@ -24,8 +24,8 @@ find_specify_root() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
@@ -34,123 +34,24 @@ get_repo_root() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return
|
||||
fi
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
# Get current feature name from explicit state only.
|
||||
# Returns the feature identifier or empty string if none is set.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
get_current_branch() {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
local repo_root=$(get_repo_root)
|
||||
if has_git; then
|
||||
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
local latest_timestamp=""
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
local ts="${BASH_REMATCH[1]}"
|
||||
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||
latest_timestamp="$ts"
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
# Only update if no timestamp branch found yet
|
||||
if [[ -z "$latest_timestamp" ]]; then
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$latest_feature" ]]; then
|
||||
echo "$latest_feature"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
has_git() {
|
||||
# First check if git command is available (before calling get_repo_root which may use git)
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
local repo_root=$(get_repo_root)
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
[ -e "$repo_root/.git" ] || return 1
|
||||
# Verify it's actually a valid git work tree
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
spec_kit_effective_branch_name() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
# No explicit feature set — caller must handle this via feature.json
|
||||
# in get_feature_paths(). Return empty to signal "unknown".
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Safely read .specify/feature.json's "feature_directory" value.
|
||||
@@ -185,105 +86,66 @@ read_feature_json_feature_directory() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||
feature_json_matches_feature_dir() {
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
# Accepts the raw (possibly relative) path — callers should pass the original
|
||||
# user-supplied value, not the normalized absolute path.
|
||||
_persist_feature_json() {
|
||||
local repo_root="$1"
|
||||
local active_feature_dir="$2"
|
||||
local feature_dir_value="$2"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
|
||||
[[ -n "$_fd" ]] || return 1
|
||||
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||
[[ -d "$_fd" ]] || return 1
|
||||
|
||||
local norm_json norm_active
|
||||
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||
|
||||
[[ "$norm_json" == "$norm_active" ]]
|
||||
}
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
local repo_root="$1"
|
||||
local branch_name
|
||||
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||
local prefix=""
|
||||
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
# Strip repo_root prefix if the value is absolute and under repo_root
|
||||
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
||||
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
||||
fi
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
# Read current value (if any) and skip write when unchanged
|
||||
local current_val
|
||||
current_val=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# No match found - return the branch name path (will fail later with clear error)
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
# Ensure .specify/ directory exists
|
||||
mkdir -p "$repo_root/.specify"
|
||||
|
||||
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||
return 1
|
||||
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
local has_git_repo="false"
|
||||
|
||||
if has_git; then
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error — no feature context available
|
||||
local feature_dir
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -291,7 +153,6 @@ get_feature_paths() {
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||
|
||||
@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
@@ -113,94 +113,18 @@ get_highest_from_specs() {
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||
_extract_highest_number() {
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number.
|
||||
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
if has_git; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
@@ -276,23 +200,10 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
# Dry-run without git: local spec dirs only
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
@@ -326,43 +237,13 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
else
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
@@ -377,8 +258,12 @@ if [ "$DRY_RUN" != true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Inform the user how to persist the feature variable in their own shell
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -409,5 +294,6 @@ else
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -32,11 +32,6 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
@@ -75,17 +70,15 @@ if $JSON_MODE; then
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg specs_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg has_git "$HAS_GIT" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
|
||||
|
||||
@@ -27,12 +27,7 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate branch
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Validate required files
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
|
||||
@@ -81,11 +81,6 @@ if ($PathsOnly) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate branch name
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
|
||||
@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
|
||||
}
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
@@ -33,263 +33,81 @@ function Get-RepoRoot {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
try {
|
||||
$result = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
# Return feature name from explicit state only.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
$repoRoot = Get-RepoRoot
|
||||
if (Test-HasGit) {
|
||||
# No explicit feature set - return empty to signal "unknown".
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
function Save-FeatureJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
||||
)
|
||||
|
||||
# Strip repo root prefix if the value is absolute and under repo root.
|
||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
||||
if ($onWin) {
|
||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$cmp = [System.StringComparison]::Ordinal
|
||||
}
|
||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
||||
}
|
||||
|
||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
|
||||
# Read current value and skip write when unchanged
|
||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
||||
try {
|
||||
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
# File is corrupt or unreadable - overwrite it
|
||||
}
|
||||
}
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
$specsDir = Join-Path $repoRoot "specs"
|
||||
|
||||
if (Test-Path $specsDir) {
|
||||
$latestFeature = ""
|
||||
$highest = 0
|
||||
$latestTimestamp = ""
|
||||
|
||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
$ts = $matches[1]
|
||||
if ($ts -gt $latestTimestamp) {
|
||||
$latestTimestamp = $ts
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
} elseif ($_.Name -match '^(\d{3,})-') {
|
||||
$num = [long]$matches[1]
|
||||
if ($num -gt $highest) {
|
||||
$highest = $num
|
||||
# Only update if no timestamp branch found yet
|
||||
if (-not $latestTimestamp) {
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($latestFeature) {
|
||||
return $latestFeature
|
||||
}
|
||||
}
|
||||
|
||||
# Final fallback
|
||||
return "main"
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
function Test-HasGit {
|
||||
# First check if git command is available (before calling Get-RepoRoot which may use git)
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
return $false
|
||||
}
|
||||
$repoRoot = Get-RepoRoot
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
|
||||
return $false
|
||||
}
|
||||
# Verify it's actually a valid git work tree
|
||||
try {
|
||||
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
# Ensure .specify/ directory exists
|
||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
function Test-FeatureJsonMatchesFeatureDir {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
|
||||
)
|
||||
|
||||
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $featureJson -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
$fd = $cfg.feature_directory
|
||||
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($fd)) {
|
||||
$fd = Join-Path $RepoRoot $fd
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
|
||||
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
|
||||
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
|
||||
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
|
||||
if ($resolvedJson) {
|
||||
$normJson = $resolvedJson.Path
|
||||
} else {
|
||||
$normJson = [System.IO.Path]::GetFullPath($fd)
|
||||
}
|
||||
|
||||
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
|
||||
if ($resolvedActive) {
|
||||
$normActive = $resolvedActive.Path
|
||||
} else {
|
||||
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
|
||||
}
|
||||
|
||||
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
|
||||
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
|
||||
# absence as "we're on Windows".
|
||||
if ($null -ne $IsWindows) {
|
||||
$onWindows = $IsWindows
|
||||
} else {
|
||||
$onWindows = $true
|
||||
}
|
||||
|
||||
if ($onWindows) {
|
||||
$comparison = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$comparison = [System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
return [string]::Equals($normJson, $normActive, $comparison)
|
||||
}
|
||||
|
||||
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||
function Find-FeatureDirByPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$Branch
|
||||
)
|
||||
$specsDir = Join-Path $RepoRoot 'specs'
|
||||
$branchName = Get-SpecKitEffectiveBranchName $Branch
|
||||
|
||||
$prefix = $null
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$prefix = $Matches[1]
|
||||
} elseif ($branchName -match '^(\d{3,})-') {
|
||||
$prefix = $Matches[1]
|
||||
} else {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
|
||||
$dirMatches = @()
|
||||
if (Test-Path -LiteralPath $specsDir -PathType Container) {
|
||||
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
if ($dirMatches.Count -eq 0) {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
if ($dirMatches.Count -eq 1) {
|
||||
return $dirMatches[0].FullName
|
||||
}
|
||||
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
|
||||
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
|
||||
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
|
||||
return $null
|
||||
}
|
||||
|
||||
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
|
||||
function Get-FeatureDirFromBranchPrefixOrExit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$CurrentBranch
|
||||
)
|
||||
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
|
||||
if ($null -eq $resolved) {
|
||||
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
|
||||
exit 1
|
||||
}
|
||||
return $resolved
|
||||
# Write feature.json
|
||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error - no feature context available
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
@@ -297,6 +115,8 @@ function Get-FeaturePathsEnv {
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
@@ -312,16 +132,17 @@ function Get-FeaturePathsEnv {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
HAS_GIT = $hasGit
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
|
||||
@@ -21,9 +21,9 @@ if ($Help) {
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
@@ -67,111 +67,17 @@ function Get-HighestNumberFromSpecs {
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of branch/ref names.
|
||||
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
if ($remotes) {
|
||||
foreach ($remote in $remotes) {
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
$refs = git ls-remote --heads $remote 2>$null
|
||||
$env:GIT_TERMINAL_PROMPT = $null
|
||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Return next available branch number. When SkipFetch is true, queries remotes
|
||||
# via ls-remote (read-only) instead of fetching.
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch {
|
||||
# Ignore fetch errors
|
||||
}
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
|
||||
# Take the maximum of both
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
|
||||
# Return next number
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify over git
|
||||
# Use common.ps1 functions which prioritize .specify
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
@@ -244,21 +150,9 @@ if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
# Dry-run without git: local spec dirs only
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
# Check existing branches on remotes
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
# Fall back to local directory check
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
@@ -291,58 +185,13 @@ $featureDir = Join-Path $specsDir $branchName
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
$branchCreateError = $_.Exception.Message
|
||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
||||
if ($Timestamp) {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
} else {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch -- nothing to do
|
||||
} else {
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
@@ -359,8 +208,12 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
||||
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
@@ -368,7 +221,6 @@ if ($Json) {
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
@@ -378,8 +230,8 @@ if ($Json) {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ if ($Help) {
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -61,7 +54,6 @@ if ($Json) {
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
HAS_GIT = $paths.HAS_GIT
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
@@ -69,5 +61,4 @@ if ($Json) {
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
|
||||
}
|
||||
|
||||
@@ -16,16 +16,9 @@ if ($Help) {
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
|
||||
@@ -69,8 +69,6 @@ from ._utils import (
|
||||
_display_project_path,
|
||||
check_tool as check_tool,
|
||||
handle_vscode_settings as handle_vscode_settings,
|
||||
init_git_repo as init_git_repo,
|
||||
is_git_repo as is_git_repo,
|
||||
merge_json_files as merge_json_files,
|
||||
run_command as run_command,
|
||||
)
|
||||
@@ -453,9 +451,6 @@ def check():
|
||||
|
||||
tracker = StepTracker("Check Available Tools")
|
||||
|
||||
tracker.add("git", "Git version control")
|
||||
git_ok = check_tool("git", tracker=tracker)
|
||||
|
||||
agent_results = {}
|
||||
for agent_key, agent_config in AGENT_CONFIG.items():
|
||||
if agent_key == "generic":
|
||||
@@ -483,9 +478,6 @@ def check():
|
||||
|
||||
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
|
||||
|
||||
if not git_ok:
|
||||
console.print("[dim]Tip: Install git for repository management[/dim]")
|
||||
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Shared GitHub-authenticated HTTP helpers.
|
||||
"""Shared GitHub HTTP request helpers.
|
||||
|
||||
Used by both ExtensionCatalog and PresetCatalog to attach
|
||||
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
|
||||
GitHub-hosted domains, while preventing token leakage to
|
||||
third-party hosts on redirects.
|
||||
Provides ``build_github_request()`` for attaching GITHUB_TOKEN / GH_TOKEN
|
||||
credentials to requests targeting GitHub-hosted domains, and
|
||||
``resolve_github_release_asset_api_url()`` — used by extensions, presets,
|
||||
and workflow URL resolution — to translate browser release-download URLs
|
||||
into GitHub REST API asset URLs. Authenticated downloads themselves go
|
||||
through the config-driven helpers in :mod:`specify_cli.authentication.http`.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -54,28 +56,6 @@ def build_github_request(url: str) -> urllib.request.Request:
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that drops the Authorization header when leaving GitHub.
|
||||
|
||||
Prevents token leakage to CDNs or other third-party hosts that GitHub
|
||||
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
|
||||
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
|
||||
"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
original_auth = req.get_header("Authorization")
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if hostname in GITHUB_HOSTS:
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
new_req.headers.pop("Authorization", None)
|
||||
new_req.unredirected_hdrs.pop("Authorization", None)
|
||||
return new_req
|
||||
|
||||
|
||||
def resolve_github_release_asset_api_url(
|
||||
download_url: str,
|
||||
open_url_fn: Callable,
|
||||
@@ -147,20 +127,3 @@ def resolve_github_release_asset_api_url(
|
||||
return str(asset["url"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def open_github_url(url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
|
||||
When the request carries an Authorization header, a custom redirect
|
||||
handler drops that header if the redirect target is not a GitHub-owned
|
||||
domain, preventing token leakage to CDNs or other third-party hosts
|
||||
that GitHub may redirect to (e.g. S3 for release asset downloads).
|
||||
"""
|
||||
req = build_github_request(url)
|
||||
|
||||
if not req.get_header("Authorization"):
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect)
|
||||
return opener.open(req, timeout=timeout)
|
||||
|
||||
@@ -77,51 +77,6 @@ def check_tool(tool: str, tracker=None) -> bool:
|
||||
return found
|
||||
|
||||
|
||||
def is_git_repo(path: Path | None = None) -> bool:
|
||||
"""Check if the specified path is inside a git repository."""
|
||||
if path is None:
|
||||
path = Path.cwd()
|
||||
|
||||
if not path.is_dir():
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
cwd=path,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
|
||||
"""Initialize a git repository in the specified path."""
|
||||
try:
|
||||
original_cwd = Path.cwd()
|
||||
os.chdir(project_path)
|
||||
if not quiet:
|
||||
console.print("[cyan]Initializing git repository...[/cyan]")
|
||||
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
|
||||
if not quiet:
|
||||
console.print("[green]✓[/green] Git repository initialized")
|
||||
return True, None
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
|
||||
if e.stderr:
|
||||
error_msg += f"\nError: {e.stderr.strip()}"
|
||||
elif e.stdout:
|
||||
error_msg += f"\nOutput: {e.stdout.strip()}"
|
||||
if not quiet:
|
||||
console.print(f"[red]Error initializing git repository:[/red] {e}")
|
||||
return False, error_msg
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||
"""Handle merging or copying of .vscode/settings.json files.
|
||||
|
||||
@@ -23,7 +23,7 @@ from .._assets import (
|
||||
get_speckit_version,
|
||||
)
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
from .._utils import check_tool
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
@@ -71,7 +71,6 @@ def register(app: typer.Typer) -> None:
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
@@ -79,7 +78,6 @@ def register(app: typer.Typer) -> None:
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
@@ -91,18 +89,16 @@ def register(app: typer.Typer) -> None:
|
||||
match the installed CLI version.
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
1. Check that required tools are installed
|
||||
2. Let you choose your coding agent integration, or default to Copilot
|
||||
in non-interactive sessions
|
||||
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||
project infrastructure
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Set up coding agent integration commands and optional presets
|
||||
4. Set up coding agent integration commands and optional presets
|
||||
|
||||
Examples:
|
||||
specify init my-project
|
||||
specify init my-project --integration claude
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify init --ignore-agent-tools my-project
|
||||
specify init . --integration claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive integration selection)
|
||||
@@ -142,13 +138,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if no_git:
|
||||
console.print(
|
||||
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
||||
"[yellow]The git extension will no longer be enabled by default "
|
||||
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
||||
)
|
||||
|
||||
if project_name == ".":
|
||||
here = True
|
||||
project_name = None
|
||||
@@ -161,10 +150,7 @@ def register(app: typer.Typer) -> None:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
dir_existed_before = False
|
||||
if here:
|
||||
@@ -253,12 +239,6 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||
|
||||
should_init_git = False
|
||||
if not no_git:
|
||||
should_init_git = check_tool("git")
|
||||
if not should_init_git:
|
||||
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
@@ -308,15 +288,12 @@ def register(app: typer.Typer) -> None:
|
||||
for key, label in [
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("agent-context", "Install agent-context extension"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
git_default_notice = False
|
||||
|
||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
@@ -369,55 +346,6 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
git_messages = []
|
||||
git_has_error = False
|
||||
if is_git_repo(project_path):
|
||||
git_messages.append("existing repo detected")
|
||||
elif should_init_git:
|
||||
success, error_msg = init_git_repo(project_path, quiet=True)
|
||||
if success:
|
||||
git_messages.append("initialized")
|
||||
else:
|
||||
git_has_error = True
|
||||
if error_msg:
|
||||
sanitized = error_msg.replace('\n', ' ').strip()
|
||||
git_messages.append(f"init failed: {sanitized[:120]}")
|
||||
else:
|
||||
git_messages.append("init failed")
|
||||
else:
|
||||
git_messages.append("git not available")
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
bundled_path = _locate_bundled_extension("git")
|
||||
if bundled_path:
|
||||
manager = ExtensionManager(project_path)
|
||||
if manager.registry.is_installed("git"):
|
||||
git_messages.append("extension already installed")
|
||||
else:
|
||||
manager.install_from_directory(
|
||||
bundled_path, get_speckit_version()
|
||||
)
|
||||
git_default_notice = True
|
||||
git_messages.append("extension installed")
|
||||
else:
|
||||
git_has_error = True
|
||||
git_messages.append("bundled extension not found")
|
||||
except Exception as ext_err:
|
||||
git_has_error = True
|
||||
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||
git_messages.append(
|
||||
f"extension install failed: {sanitized_ext[:120]}"
|
||||
)
|
||||
summary = "; ".join(git_messages)
|
||||
if git_has_error:
|
||||
tracker.error("git", summary)
|
||||
else:
|
||||
tracker.complete("git", summary)
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
@@ -451,9 +379,9 @@ def register(app: typer.Typer) -> None:
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"feature_numbering": "sequential",
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
@@ -596,18 +524,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
if git_default_notice:
|
||||
default_change_notice = Panel(
|
||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
||||
"Use [bold]specify extension add git[/bold] after init when needed.",
|
||||
title="[yellow]Notice: Git Default Changing[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(default_change_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||
|
||||
@@ -1905,6 +1905,44 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
"""Validate a parsed catalog payload's shape.
|
||||
|
||||
Applied to both network-fetched and cache-loaded payloads so a
|
||||
once-poisoned cache (older spec-kit version, manual edit, upstream
|
||||
served a bad payload before the network-side guards were added)
|
||||
cannot re-crash ``_get_merged_extensions`` on subsequent calls.
|
||||
|
||||
Checking only key presence would let a payload like
|
||||
``{"extensions": []}`` or ``{"extensions": null}`` slip through
|
||||
here and then crash with ``AttributeError: 'list' object has no
|
||||
attribute 'items'`` deep inside ``_get_merged_extensions``. The
|
||||
sibling integration catalog reader already guards both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``);
|
||||
the extension catalog must stay consistent so a malformed payload
|
||||
surfaces as the user-facing ``Invalid catalog format`` error
|
||||
instead of a raw Python traceback.
|
||||
|
||||
Args:
|
||||
catalog_data: Parsed JSON payload from the catalog source.
|
||||
url: Source URL — used in the error message so the user can
|
||||
tell which catalog in a multi-catalog stack is malformed.
|
||||
|
||||
Raises:
|
||||
ExtensionError: If the payload's shape is invalid.
|
||||
"""
|
||||
if not isinstance(catalog_data, dict):
|
||||
raise ExtensionError(
|
||||
f"Invalid catalog format from {url}: expected a JSON object"
|
||||
)
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {url}")
|
||||
if not isinstance(catalog_data.get("extensions"), dict):
|
||||
raise ExtensionError(
|
||||
f"Invalid catalog format from {url}: "
|
||||
"'extensions' must be a JSON object"
|
||||
)
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -2020,21 +2058,51 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
is_valid = False
|
||||
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
|
||||
try:
|
||||
metadata = json.loads(cache_meta_file.read_text())
|
||||
metadata = json.loads(
|
||||
cache_meta_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
is_valid = age < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
# If metadata is invalid or missing expected fields, treat cache as invalid
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# Cache validity is best-effort: invalid/missing metadata
|
||||
# fields, an unreadable metadata file (permissions / disk),
|
||||
# a wrongly-encoded metadata file (written by a tool using
|
||||
# the system locale codec), or a metadata payload that
|
||||
# parses to a non-mapping like ``[]`` or ``"oops"`` (so
|
||||
# ``metadata.get(...)`` raises ``AttributeError``) all
|
||||
# degrade to "cache invalid" so the caller falls through
|
||||
# to a network refetch instead of crashing.
|
||||
pass
|
||||
|
||||
# Use cache if valid
|
||||
# Use cache if valid. A previously-cached payload must clear the
|
||||
# same shape checks as a freshly-fetched one — otherwise a once-
|
||||
# poisoned cache (older spec-kit version, manual edit, upstream
|
||||
# served a bad payload before the network-side guards were added)
|
||||
# would re-crash on every invocation despite the cache being
|
||||
# "valid" by age. If validation fails on the cached read, fall
|
||||
# through to the network fetch path so the cache gets refreshed.
|
||||
if is_valid:
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, entry.url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
|
||||
# Cache is best-effort: a JSON-decode failure, an OS-level
|
||||
# read failure (permissions / disk / handle limit), or a
|
||||
# text-encoding failure on a cache file written by an older
|
||||
# client all fall through to the network fetch path. Only
|
||||
# the network failure is surfaced to the caller.
|
||||
pass
|
||||
|
||||
# Fetch from network
|
||||
@@ -2042,16 +2110,32 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {entry.url}")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
cache_meta_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2))
|
||||
# Save to cache. Both files are explicitly UTF-8 to match the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent (see the cache write
|
||||
# helpers in ``CatalogCache`` there). Without this, platforms
|
||||
# whose default encoding isn't UTF-8 would write locale-encoded
|
||||
# bytes that the read path can't decode, forcing an unnecessary
|
||||
# network refetch on every invocation. The write itself is
|
||||
# best-effort, matching the read side: an unwritable cache dir
|
||||
# (read-only checkout, permissions) must not fail a fetch whose
|
||||
# payload was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
cache_meta_file.write_text(
|
||||
json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2098,6 +2182,16 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
continue
|
||||
|
||||
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
|
||||
# Per-entry guard: ``_fetch_single_catalog`` already validates
|
||||
# that ``catalog_data["extensions"]`` is a mapping, but it
|
||||
# does not (and should not) validate every entry shape there
|
||||
# — one malformed entry shouldn't poison an otherwise valid
|
||||
# catalog. Skip non-mapping entries here so a payload like
|
||||
# ``{"extensions": {"foo": [], "bar": {...}}}`` still merges
|
||||
# the valid entries without crashing on ``**ext_data``.
|
||||
# Mirrors ``integrations/catalog.py:245``.
|
||||
if not isinstance(ext_data, dict):
|
||||
continue
|
||||
if ext_id not in merged: # Higher-priority catalog wins
|
||||
merged[ext_id] = {
|
||||
**ext_data,
|
||||
@@ -2114,6 +2208,12 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns ``False`` for any read/decoding failure on the metadata
|
||||
file (missing fields, malformed JSON, permissions / disk errors,
|
||||
wrong text encoding) so callers fall through to a network refetch
|
||||
instead of crashing. Treating cache validity as best-effort
|
||||
matches the contract used by the per-URL cache check below.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
@@ -2121,13 +2221,28 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# ``AttributeError`` covers the case where the metadata file is
|
||||
# valid JSON but parses to a non-mapping (``[]``, ``"oops"``,
|
||||
# ``42``) so ``metadata.get(...)`` would otherwise crash. All
|
||||
# decode/shape failures degrade to "cache invalid" so the
|
||||
# caller falls through to a network refetch.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2142,36 +2257,62 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
Raises:
|
||||
ExtensionError: If catalog cannot be fetched
|
||||
"""
|
||||
# Check cache first unless force refresh
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
# Check the cache first unless ``force_refresh`` was requested,
|
||||
# then fall through to a network fetch. Match the
|
||||
# ``_fetch_single_catalog`` cache contract: a poisoned or
|
||||
# unreadable cache silently falls through to a network refetch
|
||||
# rather than crashing the caller. ``_validate_catalog_payload``
|
||||
# is reused here so a cache written by an older client
|
||||
# (pre-validation) is rejected and refreshed instead of returning
|
||||
# the stale malformed payload. ``is_cache_valid`` itself swallows
|
||||
# OSError/UnicodeError on the metadata read, so a cache-validity
|
||||
# check can't crash this method before the read-side fallback
|
||||
# runs.
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(self.cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, catalog_url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
|
||||
pass # Fall through to network fetch
|
||||
|
||||
# Fetch from network
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError("Invalid catalog format")
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
# ``_fetch_single_catalog`` so all three branches (root type,
|
||||
# missing keys, nested-mapping type) stay consistent.
|
||||
self._validate_catalog_payload(catalog_data, catalog_url)
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# Save to cache. Explicit UTF-8 on both writes mirrors the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent — otherwise platforms
|
||||
# whose default encoding isn't UTF-8 would write locale-encoded
|
||||
# bytes the read path can't decode, forcing an unnecessary
|
||||
# refetch on every invocation. Like the read side, the write
|
||||
# is best-effort: an unwritable cache dir must not abort a
|
||||
# fetch whose payload was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Save cache metadata
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))
|
||||
# Save cache metadata
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
|
||||
@@ -25,17 +25,14 @@ class IntegrationReadError:
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
def _read_integration_json_data(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
"""Read raw integration state without normalizing or raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This is the single low-level reader; both the CLI's loud
|
||||
``_read_integration_json`` and the workflow engine's silent
|
||||
``_load_project_integration`` consume it so the schema guard and parse
|
||||
logic cannot drift between them.
|
||||
Returns ``(data, None)`` when the JSON object is readable and supported,
|
||||
``(None, None)`` when the file is absent, and ``(None, error)`` for parse,
|
||||
schema, encoding, or filesystem failures.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
@@ -70,9 +67,41 @@ def try_read_integration_json(
|
||||
and schema > INTEGRATION_STATE_SCHEMA
|
||||
):
|
||||
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
|
||||
return data, None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This helper delegates file I/O and raw JSON validation to
|
||||
``_read_integration_json_data`` so callers that need raw state can share
|
||||
the same low-level reader instead of duplicating parse logic.
|
||||
"""
|
||||
data, error = _read_integration_json_data(project_root)
|
||||
if data is None:
|
||||
return None, error
|
||||
return normalize_integration_state(data), None
|
||||
|
||||
|
||||
def try_read_integration_json_with_raw(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``integration.json`` and return normalized plus raw state.
|
||||
|
||||
Returns ``(normalized_state, raw_state, None)`` when the file is readable,
|
||||
``(None, None, None)`` when it is absent, and ``(None, None, error)`` for
|
||||
parse, schema, encoding, or filesystem failures.
|
||||
"""
|
||||
data, error = _read_integration_json_data(project_root)
|
||||
if data is None:
|
||||
return None, None, error
|
||||
return normalize_integration_state(data), data, None
|
||||
|
||||
|
||||
def clean_integration_key(key: Any) -> str | None:
|
||||
"""Return a stripped integration key, or None for empty/non-string values."""
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
|
||||
663
src/specify_cli/integration_status.py
Normal file
663
src/specify_cli/integration_status.py
Normal file
@@ -0,0 +1,663 @@
|
||||
"""Read-only status reporting for project integration state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .integration_state import (
|
||||
INTEGRATION_JSON,
|
||||
INTEGRATION_STATE_SCHEMA,
|
||||
IntegrationReadError,
|
||||
default_integration_key,
|
||||
installed_integration_keys,
|
||||
try_read_integration_json_with_raw,
|
||||
)
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
_MANIFEST_READ_ERRORS = (ValueError, OSError)
|
||||
_MANIFEST_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
_WINDOWS_RESERVED_MANIFEST_BASENAMES = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
*(f"COM{i}" for i in range(1, 10)),
|
||||
*(f"LPT{i}" for i in range(1, 10)),
|
||||
}
|
||||
_SHARED_MANIFEST_KEY = "speckit"
|
||||
|
||||
|
||||
def _finding(
|
||||
severity: str,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
integration: str | None = None,
|
||||
path: str | None = None,
|
||||
suggestion: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
item = {
|
||||
"severity": severity,
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
if integration:
|
||||
item["integration"] = integration
|
||||
if path:
|
||||
item["path"] = path
|
||||
if suggestion:
|
||||
item["suggestion"] = suggestion
|
||||
return item
|
||||
|
||||
|
||||
def _status(findings: list[dict[str, str]]) -> str:
|
||||
if any(item["severity"] == "error" for item in findings):
|
||||
return "error"
|
||||
if findings:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _with_error_detail(message: str, error: IntegrationReadError) -> str:
|
||||
if error.detail:
|
||||
return f"{message} Detail: {error.detail}"
|
||||
return message
|
||||
|
||||
|
||||
def _integration_state_error_message(error: IntegrationReadError) -> str:
|
||||
if error.kind == "decode":
|
||||
return _with_error_detail(
|
||||
f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8.",
|
||||
error,
|
||||
)
|
||||
if error.kind == "os":
|
||||
return _with_error_detail(f"Could not read {INTEGRATION_JSON}.", error)
|
||||
if error.kind == "not_object":
|
||||
return f"{INTEGRATION_JSON} must contain a JSON object, got {error.detail}."
|
||||
if error.kind == "schema_too_new":
|
||||
return (
|
||||
f"{INTEGRATION_JSON} uses integration state schema {error.schema}, "
|
||||
f"which is newer than this CLI supports; supported schema: {INTEGRATION_STATE_SCHEMA}."
|
||||
)
|
||||
return f"Could not inspect {INTEGRATION_JSON}."
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _strip_extended_length_prefix(path: Path) -> Path:
|
||||
"""Drop the Windows ``\\\\?\\`` extended-length prefix for path comparison.
|
||||
|
||||
``os.readlink`` and ``Path.resolve`` can return extended-length paths on
|
||||
Windows (e.g. ``\\\\?\\C:\\proj``). Comparing such a path against a plain
|
||||
``C:\\proj`` root via :meth:`Path.relative_to` would spuriously fail, so we
|
||||
normalise both sides through this helper before containment checks.
|
||||
"""
|
||||
raw = str(path)
|
||||
if raw.startswith("\\\\?\\UNC\\"):
|
||||
return Path("\\\\" + raw[len("\\\\?\\UNC\\"):])
|
||||
if raw.startswith("\\\\?\\"):
|
||||
return Path(raw[len("\\\\?\\"):])
|
||||
return path
|
||||
|
||||
|
||||
def _is_within_project(project_root_resolved: Path, candidate: Path) -> bool:
|
||||
"""Return ``True`` when *candidate* stays within *project_root_resolved*.
|
||||
|
||||
Both paths are stripped of any Windows extended-length prefix first so that
|
||||
a target produced by ``os.readlink`` (which may be ``\\\\?\\``-prefixed) is
|
||||
still recognised as living inside an unprefixed project root.
|
||||
"""
|
||||
try:
|
||||
_strip_extended_length_prefix(candidate).relative_to(
|
||||
_strip_extended_length_prefix(project_root_resolved)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _safe_manifest_file(
|
||||
project_root: Path,
|
||||
project_root_resolved: Path,
|
||||
rel: str,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> Path | None:
|
||||
rel_path = Path(rel)
|
||||
if rel_path.is_absolute() or ".." in rel_path.parts:
|
||||
return None
|
||||
candidate = project_root / rel_path
|
||||
if not project_root_is_resolved:
|
||||
walk = project_root
|
||||
for part in rel_path.parts[:-1]:
|
||||
walk = walk / part
|
||||
try:
|
||||
if walk.is_symlink():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
candidate_parent = (
|
||||
candidate.parent.resolve(strict=False)
|
||||
if project_root_is_resolved
|
||||
else candidate.parent.absolute()
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
if not _is_within_project(project_root_resolved, candidate_parent):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _tracked_symlink_manifest_status(
|
||||
path: Path,
|
||||
project_root_resolved: Path,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> str:
|
||||
"""Classify a tracked symlink without following it outside the project.
|
||||
|
||||
Manifests store content hashes for regular files, so an existing in-project
|
||||
symlink is still reported as modified. Escaping targets are invalid, and
|
||||
dangling in-project targets are missing.
|
||||
"""
|
||||
try:
|
||||
target = path.readlink()
|
||||
except OSError:
|
||||
return "modified"
|
||||
|
||||
target_path = target if target.is_absolute() else path.parent / target
|
||||
try:
|
||||
contained_parent = (
|
||||
target_path.parent.resolve(strict=False)
|
||||
if project_root_is_resolved
|
||||
else target_path.parent.absolute()
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return "invalid"
|
||||
if not _is_within_project(project_root_resolved, contained_parent):
|
||||
return "invalid"
|
||||
|
||||
try:
|
||||
target_path.lstat()
|
||||
except FileNotFoundError:
|
||||
return "missing"
|
||||
except OSError:
|
||||
return "modified"
|
||||
return "modified"
|
||||
|
||||
|
||||
def _resolve_project_root_for_status(
|
||||
project_root: Path,
|
||||
findings: list[dict[str, str]],
|
||||
) -> tuple[Path, bool]:
|
||||
try:
|
||||
return project_root.resolve(), True
|
||||
except (OSError, RuntimeError) as exc:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"project-root-unresolved",
|
||||
f"Could not fully resolve project root: {exc}",
|
||||
suggestion="Check project path permissions and symlinks before relying on manifest path checks.",
|
||||
)
|
||||
)
|
||||
return project_root.absolute(), False
|
||||
|
||||
|
||||
def _is_safe_manifest_key(key: str) -> bool:
|
||||
if key in {"", ".", ".."}:
|
||||
return False
|
||||
if key.endswith("."):
|
||||
return False
|
||||
if _MANIFEST_KEY_RE.fullmatch(key) is None:
|
||||
return False
|
||||
if key.split(".", 1)[0].upper() in _WINDOWS_RESERVED_MANIFEST_BASENAMES:
|
||||
return False
|
||||
if "/" in key or "\\" in key:
|
||||
return False
|
||||
key_path = Path(key)
|
||||
return not key_path.is_absolute() and key_path.name == key
|
||||
|
||||
|
||||
def _manifest_file_status(
|
||||
manifest: IntegrationManifest,
|
||||
project_root_resolved: Path,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> tuple[list[str], list[str], list[str], list[str]]:
|
||||
missing: list[str] = []
|
||||
modified: list[str] = []
|
||||
invalid: list[str] = []
|
||||
valid: list[str] = []
|
||||
|
||||
for rel, expected_hash in manifest.files.items():
|
||||
path = _safe_manifest_file(
|
||||
manifest.project_root,
|
||||
project_root_resolved,
|
||||
rel,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
if path is None:
|
||||
invalid.append(rel)
|
||||
continue
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except FileNotFoundError:
|
||||
valid.append(rel)
|
||||
missing.append(rel)
|
||||
continue
|
||||
except OSError:
|
||||
valid.append(rel)
|
||||
modified.append(rel)
|
||||
continue
|
||||
is_symlink = stat.S_ISLNK(path_stat.st_mode)
|
||||
if not is_symlink:
|
||||
try:
|
||||
is_symlink = path.is_symlink()
|
||||
except OSError:
|
||||
is_symlink = False
|
||||
if is_symlink:
|
||||
symlink_status = _tracked_symlink_manifest_status(
|
||||
path,
|
||||
project_root_resolved,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
if symlink_status == "invalid":
|
||||
invalid.append(rel)
|
||||
continue
|
||||
valid.append(rel)
|
||||
if symlink_status == "missing":
|
||||
missing.append(rel)
|
||||
continue
|
||||
modified.append(rel)
|
||||
continue
|
||||
valid.append(rel)
|
||||
if not stat.S_ISREG(path_stat.st_mode):
|
||||
modified.append(rel)
|
||||
continue
|
||||
try:
|
||||
if _sha256_file(path) != expected_hash:
|
||||
modified.append(rel)
|
||||
except OSError:
|
||||
modified.append(rel)
|
||||
|
||||
return missing, modified, invalid, valid
|
||||
|
||||
|
||||
def _default_not_installed_from_raw_state(raw_state: dict[str, Any]) -> str | None:
|
||||
if not isinstance(raw_state.get("installed_integrations"), list):
|
||||
return None
|
||||
|
||||
raw_default = default_integration_key(raw_state)
|
||||
raw_installed = installed_integration_keys(raw_state)
|
||||
if raw_default and raw_default not in raw_installed:
|
||||
return raw_default
|
||||
return None
|
||||
|
||||
|
||||
def _manifest_summary(
|
||||
manifest_path: Path,
|
||||
project_root: Path,
|
||||
*,
|
||||
readable: bool,
|
||||
tracked_files: int = 0,
|
||||
missing_files: list[str] | None = None,
|
||||
modified_files: list[str] | None = None,
|
||||
invalid_files: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"manifest": manifest_path.relative_to(project_root).as_posix(),
|
||||
"readable": readable,
|
||||
"tracked_files": tracked_files,
|
||||
"missing_files": missing_files or [],
|
||||
"modified_files": modified_files or [],
|
||||
"invalid_files": invalid_files or [],
|
||||
}
|
||||
|
||||
|
||||
def _manifest_owner(key: str) -> str:
|
||||
if key == _SHARED_MANIFEST_KEY:
|
||||
return "shared Spec Kit infrastructure"
|
||||
return f"integration '{key}'"
|
||||
|
||||
|
||||
def _manifest_suggestion(key: str, default_key: str | None) -> str:
|
||||
if key == _SHARED_MANIFEST_KEY:
|
||||
if default_key and default_key in INTEGRATION_REGISTRY:
|
||||
return f"Run `specify integration upgrade {default_key}` to regenerate shared managed files."
|
||||
return (
|
||||
"Run `specify init --here --force --integration <key>` to regenerate "
|
||||
"shared managed files."
|
||||
)
|
||||
if key not in INTEGRATION_REGISTRY:
|
||||
return (
|
||||
"Upgrade Spec Kit, reinstall with a supported CLI version, "
|
||||
f"or remove the stale integration entry from {INTEGRATION_JSON}."
|
||||
)
|
||||
return f"Run `specify integration upgrade {key}` or reinstall the integration."
|
||||
|
||||
|
||||
def build_integration_status_report(project_root: Path) -> dict[str, Any]:
|
||||
"""Return a machine-readable integration status report for *project_root*."""
|
||||
findings: list[dict[str, str]] = []
|
||||
project_root_resolved, project_root_is_resolved = _resolve_project_root_for_status(
|
||||
project_root,
|
||||
findings,
|
||||
)
|
||||
state, raw_state, error = try_read_integration_json_with_raw(project_root)
|
||||
if error is not None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-state-unreadable",
|
||||
_integration_state_error_message(error),
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix or delete {INTEGRATION_JSON}, then retry.",
|
||||
)
|
||||
)
|
||||
return _build_report(None, [], findings, {}, None)
|
||||
|
||||
if state is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-state-missing",
|
||||
f"{INTEGRATION_JSON} is missing.",
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion="Run `specify integration install <key>` to install an integration.",
|
||||
)
|
||||
)
|
||||
return _build_report(None, [], findings, {}, None)
|
||||
|
||||
assert raw_state is not None
|
||||
raw_default_key = default_integration_key(raw_state)
|
||||
raw_installed_value = raw_state.get("installed_integrations")
|
||||
raw_installed_is_list = isinstance(raw_installed_value, list)
|
||||
raw_installed_keys = (
|
||||
installed_integration_keys(raw_state)
|
||||
if raw_installed_is_list
|
||||
else []
|
||||
)
|
||||
default_key = raw_default_key or default_integration_key(state)
|
||||
installed_keys = installed_integration_keys(state)
|
||||
raw_default_not_installed = _default_not_installed_from_raw_state(raw_state)
|
||||
if raw_installed_is_list and raw_default_not_installed and raw_installed_keys:
|
||||
check_installed_keys = raw_installed_keys
|
||||
else:
|
||||
check_installed_keys = installed_keys
|
||||
recorded_installed_keys = raw_installed_keys
|
||||
if "installed_integrations" in raw_state and not raw_installed_is_list:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"installed-integrations-invalid",
|
||||
(
|
||||
"installed_integrations must be a list, "
|
||||
f"got {type(raw_installed_value).__name__}."
|
||||
),
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix {INTEGRATION_JSON}, then retry.",
|
||||
)
|
||||
)
|
||||
if not installed_keys:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"no-installed-integrations",
|
||||
"No installed integrations are recorded.",
|
||||
suggestion="Run `specify integration install <key>` to install one.",
|
||||
)
|
||||
)
|
||||
|
||||
if raw_installed_keys and raw_default_key is None:
|
||||
default_key = None
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"default-integration-missing",
|
||||
"No default integration is recorded.",
|
||||
suggestion="Run `specify integration use <key>` after choosing an installed integration.",
|
||||
)
|
||||
)
|
||||
|
||||
if raw_default_not_installed:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"default-integration-not-installed",
|
||||
(
|
||||
f"Default integration '{raw_default_not_installed}' is not listed "
|
||||
"in installed_integrations."
|
||||
),
|
||||
integration=raw_default_not_installed,
|
||||
suggestion="Run `specify integration use <key>` for an installed integration, or reinstall the default integration.",
|
||||
)
|
||||
)
|
||||
|
||||
known_installed = [key for key in check_installed_keys if key in INTEGRATION_REGISTRY]
|
||||
unknown_installed: list[str] = []
|
||||
for key in check_installed_keys:
|
||||
if key not in INTEGRATION_REGISTRY:
|
||||
unknown_installed.append(key)
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"unknown-integration",
|
||||
f"Integration '{key}' is installed but is not known to this CLI.",
|
||||
integration=key,
|
||||
suggestion=(
|
||||
"Upgrade Spec Kit, reinstall with a supported CLI version, "
|
||||
f"or remove the stale integration entry from {INTEGRATION_JSON}."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
unsafe = [
|
||||
key for key in known_installed
|
||||
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
|
||||
]
|
||||
if len(check_installed_keys) > 1:
|
||||
unsafe.extend(unknown_installed)
|
||||
|
||||
if len(check_installed_keys) > 1 and unsafe:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"unsafe-multi-install",
|
||||
(
|
||||
"Installed integrations are not all declared multi-install safe: "
|
||||
+ ", ".join(sorted(unsafe))
|
||||
),
|
||||
suggestion=(
|
||||
"Use `specify integration use <key>` to change defaults, "
|
||||
"or `specify integration switch <key>` only when replacing integrations."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
manifest_files_by_path: dict[str, list[str]] = {}
|
||||
manifest_summaries: dict[str, dict[str, Any]] = {}
|
||||
attempted_manifest_keys: list[str] = []
|
||||
manifest_keys = list(check_installed_keys)
|
||||
if _SHARED_MANIFEST_KEY not in manifest_keys:
|
||||
manifest_keys.append(_SHARED_MANIFEST_KEY)
|
||||
|
||||
for key in manifest_keys:
|
||||
owner = _manifest_owner(key)
|
||||
if not _is_safe_manifest_key(key):
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-key-invalid",
|
||||
f"Integration key {key!r} cannot be used as a manifest filename.",
|
||||
integration=key,
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix {INTEGRATION_JSON}, then reinstall the integration.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
attempted_manifest_keys.append(key)
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
try:
|
||||
manifest = IntegrationManifest.load(
|
||||
key,
|
||||
project_root_resolved,
|
||||
resolve_project_root=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-missing",
|
||||
f"Manifest for {owner} is missing.",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=False,
|
||||
)
|
||||
continue
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=False,
|
||||
)
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-unreadable",
|
||||
f"Manifest for {owner} is unreadable: {exc}",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
missing, modified, invalid, valid_files = _manifest_file_status(
|
||||
manifest,
|
||||
project_root_resolved,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=True,
|
||||
tracked_files=len(manifest.files),
|
||||
missing_files=missing,
|
||||
modified_files=modified,
|
||||
invalid_files=invalid,
|
||||
)
|
||||
|
||||
for rel in valid_files:
|
||||
manifest_files_by_path.setdefault(rel, []).append(key)
|
||||
if invalid:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-paths-invalid",
|
||||
f"{len(invalid)} unsafe manifest path(s) are recorded for {owner}.",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
if missing:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"managed-files-missing",
|
||||
f"{len(missing)} managed file(s) are missing for {owner}.",
|
||||
integration=key,
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
if modified:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"managed-files-modified",
|
||||
f"{len(modified)} managed file(s) were modified for {owner}.",
|
||||
integration=key,
|
||||
suggestion="Review the changes before running `specify integration upgrade --force`.",
|
||||
)
|
||||
)
|
||||
|
||||
for rel, keys in sorted(manifest_files_by_path.items()):
|
||||
if len(keys) > 1:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"managed-file-collision",
|
||||
f"Managed file '{rel}' is tracked by multiple integrations: {', '.join(sorted(keys))}.",
|
||||
path=rel,
|
||||
suggestion="Review the manifests before uninstalling or upgrading these integrations.",
|
||||
)
|
||||
)
|
||||
|
||||
if not raw_installed_is_list or not raw_installed_keys:
|
||||
multi_install_safe = None
|
||||
else:
|
||||
multi_install_safe = not (len(check_installed_keys) > 1 and unsafe)
|
||||
return _build_report(
|
||||
default_key,
|
||||
installed_keys,
|
||||
findings,
|
||||
manifest_summaries,
|
||||
multi_install_safe,
|
||||
manifest_checked_keys=attempted_manifest_keys,
|
||||
recorded_installed_keys=recorded_installed_keys,
|
||||
)
|
||||
|
||||
|
||||
def _build_report(
|
||||
default_key: str | None,
|
||||
installed_keys: list[str],
|
||||
findings: list[dict[str, str]],
|
||||
manifests: dict[str, dict[str, Any]],
|
||||
multi_install_safe: bool | None,
|
||||
*,
|
||||
manifest_checked_keys: list[str] | None = None,
|
||||
recorded_installed_keys: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
missing_count = sum(len(item.get("missing_files", [])) for item in manifests.values())
|
||||
modified_count = sum(len(item.get("modified_files", [])) for item in manifests.values())
|
||||
invalid_count = sum(len(item.get("invalid_files", [])) for item in manifests.values())
|
||||
unchecked_count = sum(1 for item in manifests.values() if not item.get("readable", True))
|
||||
return {
|
||||
"status": _status(findings),
|
||||
"default_integration": default_key,
|
||||
"installed_integrations": installed_keys,
|
||||
"recorded_installed_integrations": (
|
||||
installed_keys if recorded_installed_keys is None else recorded_installed_keys
|
||||
),
|
||||
"manifest_checked_integrations": (
|
||||
installed_keys if manifest_checked_keys is None else manifest_checked_keys
|
||||
),
|
||||
"multi_install_safe": multi_install_safe,
|
||||
"shared_templates_target_alignment": default_key,
|
||||
"missing_managed_files": missing_count,
|
||||
"modified_managed_files": modified_count,
|
||||
"invalid_manifest_paths": invalid_count,
|
||||
"unchecked_manifests": unchecked_count,
|
||||
"manifests": manifests,
|
||||
"findings": findings,
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
||||
"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.markup import escape as _rich_escape
|
||||
from rich.table import Table
|
||||
|
||||
from .._console import console
|
||||
@@ -120,6 +122,86 @@ def integration_list(
|
||||
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||
|
||||
|
||||
def _print_integration_status_report(report: dict[str, Any]) -> None:
|
||||
status = report["status"]
|
||||
status_label = {
|
||||
"ok": "[green]OK[/green]",
|
||||
"warning": "[yellow]WARNING[/yellow]",
|
||||
"error": "[red]ERROR[/red]",
|
||||
}.get(str(status), str(status).upper())
|
||||
installed = report.get("installed_integrations") or []
|
||||
installed_display = ", ".join(_rich_escape(str(item)) for item in installed)
|
||||
|
||||
console.print(f"Integration status: {status_label}")
|
||||
console.print(
|
||||
f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}"
|
||||
)
|
||||
console.print(f"Installed integrations: {installed_display if installed else 'none'}")
|
||||
multi_install_safe = report.get("multi_install_safe")
|
||||
if multi_install_safe is None:
|
||||
multi_install_safe_display = "unknown"
|
||||
else:
|
||||
multi_install_safe_display = "yes" if multi_install_safe else "no"
|
||||
console.print(f"Multi-install safe: {multi_install_safe_display}")
|
||||
console.print(
|
||||
f"Shared templates target alignment: "
|
||||
f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}"
|
||||
)
|
||||
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
|
||||
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")
|
||||
console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}")
|
||||
console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}")
|
||||
|
||||
findings = report.get("findings") or []
|
||||
if not findings:
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Findings:[/bold]")
|
||||
for item in findings:
|
||||
severity = item.get("severity", "")
|
||||
severity_label = {
|
||||
"error": "[red]error[/red]",
|
||||
"warning": "[yellow]warning[/yellow]",
|
||||
}.get(severity, severity)
|
||||
prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}"
|
||||
if item.get("integration"):
|
||||
prefix += f" ({_rich_escape(str(item['integration']))})"
|
||||
console.print(
|
||||
f"{prefix}: {_rich_escape(str(item.get('message', '')))}",
|
||||
soft_wrap=True,
|
||||
)
|
||||
if item.get("suggestion"):
|
||||
console.print(
|
||||
f" Suggestion: {_rich_escape(str(item['suggestion']))}",
|
||||
soft_wrap=True,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("status")
|
||||
def integration_status(
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit machine-readable integration status.",
|
||||
),
|
||||
):
|
||||
"""Report the current project's integration status without changing files."""
|
||||
from .. import _require_specify_project
|
||||
from ..integration_status import build_integration_status_report
|
||||
|
||||
project_root = _require_specify_project()
|
||||
report = build_integration_status_report(project_root)
|
||||
|
||||
if json_output:
|
||||
typer.echo(json.dumps(report, indent=2))
|
||||
else:
|
||||
_print_integration_status_report(report)
|
||||
|
||||
if report["status"] == "error":
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@integration_app.command("use")
|
||||
def integration_use(
|
||||
key: str = typer.Argument(help="Installed integration key to make the default"),
|
||||
|
||||
@@ -108,11 +108,23 @@ class IntegrationManifest:
|
||||
key: Integration identifier (e.g. ``"copilot"``).
|
||||
project_root: Absolute path to the project directory.
|
||||
version: CLI version string recorded in the manifest.
|
||||
resolve_project_root: Resolve ``project_root`` before using it.
|
||||
"""
|
||||
|
||||
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
version: str = "",
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.project_root = project_root.resolve()
|
||||
self.project_root = (
|
||||
project_root.resolve()
|
||||
if resolve_project_root
|
||||
else project_root.absolute()
|
||||
)
|
||||
self.version = version
|
||||
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
||||
self._recovered_files: set[str] = set()
|
||||
@@ -387,12 +399,18 @@ class IntegrationManifest:
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
|
||||
def load(
|
||||
cls,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> IntegrationManifest:
|
||||
"""Load an existing manifest from disk.
|
||||
|
||||
Raises ``FileNotFoundError`` if the manifest does not exist.
|
||||
"""
|
||||
inst = cls(key, project_root)
|
||||
inst = cls(key, project_root, resolve_project_root=resolve_project_root)
|
||||
path = inst.manifest_path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -1892,6 +1892,48 @@ class PresetCatalog:
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
"""Validate a parsed preset-catalog payload's shape.
|
||||
|
||||
Applied to both network-fetched and cache-loaded payloads so a
|
||||
once-poisoned cache (older spec-kit version, manual edit, upstream
|
||||
served a bad payload before the network-side guards were added)
|
||||
cannot re-crash ``_get_merged_packs`` on subsequent calls.
|
||||
|
||||
Checking only key presence would let a payload like
|
||||
``{"presets": []}`` or ``{"presets": null}`` slip through here and
|
||||
then crash with ``AttributeError: 'list' object has no attribute
|
||||
'items'`` deep inside ``_get_merged_packs``. The sibling
|
||||
integration catalog reader already guards both the root object and
|
||||
the nested mapping (see ``integrations/catalog.py``); the preset
|
||||
catalog must stay consistent so a malformed payload surfaces as
|
||||
the user-facing ``Invalid preset catalog format`` error instead of
|
||||
a raw Python traceback.
|
||||
|
||||
Args:
|
||||
catalog_data: Parsed JSON payload from the catalog source.
|
||||
url: Source URL — used in the error message so the user can
|
||||
tell which catalog in a multi-catalog stack is malformed.
|
||||
|
||||
Raises:
|
||||
PresetError: If the payload's shape is invalid.
|
||||
"""
|
||||
if not isinstance(catalog_data, dict):
|
||||
raise PresetError(
|
||||
f"Invalid preset catalog format from {url}: "
|
||||
"expected a JSON object"
|
||||
)
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError(f"Invalid preset catalog format from {url}")
|
||||
if not isinstance(catalog_data.get("presets"), dict):
|
||||
raise PresetError(
|
||||
f"Invalid preset catalog format from {url}: "
|
||||
"'presets' must be a JSON object"
|
||||
)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -2053,7 +2095,7 @@ class PresetCatalog:
|
||||
if not cache_file.exists() or not metadata_file.exists():
|
||||
return False
|
||||
try:
|
||||
metadata = json.loads(metadata_file.read_text())
|
||||
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2061,7 +2103,23 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# Cache validity is best-effort: invalid/missing fields, an
|
||||
# unreadable metadata file (permissions / disk), a wrongly
|
||||
# encoded one (written by a tool using the system locale
|
||||
# codec), or a metadata payload that parses to a non-mapping
|
||||
# like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises
|
||||
# ``AttributeError``) all degrade to "cache invalid" so the
|
||||
# caller falls through to a network refetch instead of
|
||||
# crashing.
|
||||
return False
|
||||
|
||||
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2079,29 +2137,55 @@ class PresetCatalog:
|
||||
"""
|
||||
cache_file, metadata_file = self._get_cache_paths(entry.url)
|
||||
|
||||
# Use cache if valid. A previously-cached payload must clear the
|
||||
# same shape checks as a freshly-fetched one — otherwise a once-
|
||||
# poisoned cache would re-crash on every invocation despite the
|
||||
# cache being "valid" by age. If validation fails on the cached
|
||||
# read, fall through to the network fetch path so the cache gets
|
||||
# refreshed.
|
||||
if not force_refresh and self._is_url_cache_valid(entry.url):
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, entry.url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
||||
# Cache is best-effort: a JSON-decode failure, an OS-level
|
||||
# read failure (permissions / disk / handle limit), or a
|
||||
# text-encoding failure on a cache file written by an
|
||||
# older client all fall through to the network fetch path.
|
||||
# Only the network failure is surfaced to the caller.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}
|
||||
metadata_file.write_text(json.dumps(metadata, indent=2))
|
||||
# Both files are written explicitly as UTF-8 to match the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent. Without this,
|
||||
# platforms whose default encoding isn't UTF-8 would write
|
||||
# locale-encoded bytes the read path can't decode, forcing an
|
||||
# unnecessary refetch on every invocation. The write itself
|
||||
# is best-effort like the read side: an unwritable cache dir
|
||||
# (read-only checkout, permissions) must not be re-raised as
|
||||
# a ``PresetError`` for a payload that was already fetched
|
||||
# and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}
|
||||
metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2127,6 +2211,17 @@ class PresetCatalog:
|
||||
try:
|
||||
data = self._fetch_single_catalog(entry, force_refresh)
|
||||
for pack_id, pack_data in data.get("presets", {}).items():
|
||||
# Per-entry guard: ``_fetch_single_catalog`` already
|
||||
# validates that ``data["presets"]`` is a mapping, but it
|
||||
# does not (and should not) validate every entry shape
|
||||
# there — one malformed entry shouldn't poison an
|
||||
# otherwise valid catalog. Skip non-mapping entries here
|
||||
# so a payload like ``{"presets": {"foo": [], "bar":
|
||||
# {...}}}`` still merges the valid entries without
|
||||
# crashing on ``**pack_data``. Mirrors
|
||||
# ``integrations/catalog.py:245``.
|
||||
if not isinstance(pack_data, dict):
|
||||
continue
|
||||
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
|
||||
merged[pack_id] = pack_data_with_catalog
|
||||
except PresetError:
|
||||
@@ -2137,6 +2232,12 @@ class PresetCatalog:
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns ``False`` for any read/decoding failure on the metadata
|
||||
file (missing fields, malformed JSON, permissions / disk errors,
|
||||
wrong text encoding) so callers fall through to a network refetch
|
||||
instead of crashing. Treating cache validity as best-effort
|
||||
matches the contract used by ``_is_url_cache_valid`` above.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
@@ -2144,7 +2245,9 @@ class PresetCatalog:
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2152,7 +2255,20 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# ``AttributeError`` covers the case where the metadata file
|
||||
# parses to a non-mapping (``[]``, ``"oops"``, ``42``) so
|
||||
# ``metadata.get(...)`` would otherwise crash. All decode /
|
||||
# shape failures degrade to "cache invalid" so the caller
|
||||
# falls through to a network refetch.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2169,35 +2285,61 @@ class PresetCatalog:
|
||||
"""
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
# Match the ``_fetch_single_catalog`` cache contract: a poisoned
|
||||
# or unreadable cache silently falls through to a network refetch
|
||||
# rather than crashing the caller. ``_validate_catalog_payload``
|
||||
# is reused here so a cache written by an older client
|
||||
# (pre-validation) is rejected and refreshed instead of returning
|
||||
# the stale malformed payload.
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
if metadata.get("catalog_url") == catalog_url:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Cache is corrupt or unreadable; fall through to network fetch
|
||||
cached_data = json.loads(
|
||||
self.cache_file.read_text(encoding="utf-8")
|
||||
)
|
||||
self._validate_catalog_payload(cached_data, catalog_url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
||||
# Cache is corrupt, unreadable, or fails the shape check;
|
||||
# fall through to network fetch.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
# ``_fetch_single_catalog`` so all three branches (root type,
|
||||
# missing keys, nested-mapping type) stay consistent.
|
||||
self._validate_catalog_payload(catalog_data, catalog_url)
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# Save to cache. Explicit UTF-8 on both writes mirrors the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent — otherwise platforms
|
||||
# whose default encoding isn't UTF-8 would write
|
||||
# locale-encoded bytes the read path can't decode, forcing an
|
||||
# unnecessary refetch on every invocation. Like the read
|
||||
# side, the write is best-effort: an unwritable cache dir
|
||||
# must not be re-raised as a ``PresetError`` for a payload
|
||||
# that was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2)
|
||||
)
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ Given that feature description, do this:
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `branch_numbering`
|
||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
|
||||
@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
|
||||
|
||||
Validates:
|
||||
- extension.yml manifest
|
||||
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||
- Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||
- PowerShell scripts (where pwsh is available)
|
||||
- Config reading from git-config.yml
|
||||
- Extension install via ExtensionManager
|
||||
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
|
||||
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
||||
|
||||
ext_installed = tmp_path / ".specify" / "extensions" / "git"
|
||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
|
||||
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
|
||||
# ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCreateFeatureBash:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates sequential branch."""
|
||||
"""Extension create-new-feature-branch.sh creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates timestamp branch."""
|
||||
"""Extension create-new-feature-branch.sh creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--timestamp", "--short-name", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
|
||||
(project / "specs" / "002-second").mkdir(parents=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "third", "Third feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
|
||||
"""create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
|
||||
"""--dry-run computes branch name without creating anything."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates sequential branch."""
|
||||
"""Extension create-new-feature-branch.ps1 creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates timestamp branch."""
|
||||
"""Extension create-new-feature-branch.ps1 creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.ps1 works without git."""
|
||||
"""create-new-feature-branch.ps1 works without git."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestInitIntegrationFlag:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -111,7 +111,7 @@ class TestInitIntegrationFlag:
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "noninteractive"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -131,7 +131,7 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -160,7 +160,6 @@ class TestInitIntegrationFlag:
|
||||
"copilot",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--preset",
|
||||
"lean",
|
||||
],
|
||||
@@ -192,7 +191,7 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -633,7 +632,6 @@ class TestInitIntegrationFlag:
|
||||
"init", "--here", "--force",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -663,7 +661,6 @@ class TestInitIntegrationFlag:
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
], input="y\n", catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -692,7 +689,7 @@ class TestForceExistingDirectory:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot", "--force",
|
||||
"--no-git", "--script", "sh",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
||||
@@ -715,22 +712,22 @@ class TestForceExistingDirectory:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot",
|
||||
"--no-git", "--script", "sh",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in _normalize_cli_output(result.output)
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
"""Tests for auto-installation of the git extension during specify init."""
|
||||
class TestGitExtensionOptIn:
|
||||
"""Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
|
||||
|
||||
def test_git_extension_auto_installed(self, tmp_path):
|
||||
"""Without --no-git, the git extension is installed during init."""
|
||||
def test_git_extension_not_auto_installed(self, tmp_path):
|
||||
"""Git extension is NOT installed automatically during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-auto"
|
||||
project = tmp_path / "git-opt-in"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -745,30 +742,16 @@ class TestGitExtensionAutoInstall:
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Check that the tracker didn't report a git error
|
||||
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
||||
|
||||
# Git extension files should be installed
|
||||
# Git extension directory should NOT be present after init
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension directory not installed"
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
||||
assert not ext_dir.exists(), "git extension should not be auto-installed"
|
||||
|
||||
# Hooks should be registered
|
||||
extensions_yml = project / ".specify" / "extensions.yml"
|
||||
assert extensions_yml.exists(), "extensions.yml not created"
|
||||
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
||||
assert "hooks" in hooks_data
|
||||
assert "before_specify" in hooks_data["hooks"]
|
||||
assert "before_constitution" in hooks_data["hooks"]
|
||||
|
||||
def test_no_git_skips_extension(self, tmp_path):
|
||||
"""With --no-git, the git extension is NOT installed."""
|
||||
def test_no_git_flag_is_rejected(self, tmp_path):
|
||||
"""--no-git flag has been removed; passing it should fail."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git"
|
||||
project = tmp_path / "no-git-rejected"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -777,75 +760,19 @@ class TestGitExtensionAutoInstall:
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
|
||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
||||
|
||||
# Git extension should NOT be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||
|
||||
def test_no_git_emits_deprecation_warning(self, tmp_path):
|
||||
"""Using --no-git emits a visible deprecation warning."""
|
||||
def test_git_extension_commands_not_registered_by_default(self, tmp_path):
|
||||
"""Git extension commands are NOT registered with the agent during default init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git-warn"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--no-git" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "0.10.0" in normalized_output
|
||||
assert "specify extension" in normalized_output
|
||||
assert "will be removed" in normalized_output
|
||||
assert "git extension will no longer be enabled by default" in normalized_output
|
||||
|
||||
def test_default_git_auto_enable_emits_notice(self, tmp_path):
|
||||
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-default-notice"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
# Check for key message components (notice may have box-drawing chars)
|
||||
assert "git extension is currently enabled by default" in normalized_output
|
||||
assert "v0.10.0" in normalized_output
|
||||
assert "explicit opt-in" in normalized_output
|
||||
assert "specify extension add git" in normalized_output
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-cmds"
|
||||
project = tmp_path / "git-cmds-absent"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -860,11 +787,11 @@ class TestGitExtensionAutoInstall:
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Git extension commands should be registered with the agent
|
||||
# Git extension skill commands should NOT be present
|
||||
claude_skills = project / ".claude" / "skills"
|
||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||
assert len(git_skills) > 0, "no git extension commands registered"
|
||||
assert len(git_skills) == 0, "git extension commands should not be registered by default"
|
||||
|
||||
|
||||
class TestSharedInfraCommandRefs:
|
||||
@@ -983,7 +910,6 @@ class TestSharedInfraCommandRefs:
|
||||
"init", str(project),
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -1014,7 +940,6 @@ class TestSharedInfraCommandRefs:
|
||||
"init", str(project),
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -1046,7 +971,6 @@ class TestSharedInfraCommandRefs:
|
||||
"--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestAgyInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
@@ -192,7 +192,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
|
||||
@@ -325,7 +325,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -409,7 +409,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -440,7 +439,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -469,7 +467,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -580,7 +578,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -589,7 +586,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -616,7 +613,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -625,7 +621,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -288,7 +288,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -319,7 +318,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -348,7 +346,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -459,7 +457,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -468,7 +465,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -495,7 +492,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -504,7 +500,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -458,7 +458,6 @@ class TestIntegrationListCatalog:
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -556,7 +555,6 @@ class TestIntegrationUpgrade:
|
||||
"init", "--here",
|
||||
"--integration", integration,
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -137,7 +137,6 @@ class TestClaudeIntegration:
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -175,7 +174,6 @@ class TestClaudeIntegration:
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -208,7 +206,6 @@ class TestClaudeIntegration:
|
||||
"--here",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -243,7 +240,7 @@ class TestClaudeIntegration:
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -139,7 +139,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestCodexInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
@@ -186,12 +186,12 @@ class TestCopilotIntegration:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
@@ -775,7 +775,6 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestCursorAgentInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestDevinInitFlow:
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||
|
||||
@@ -251,7 +251,6 @@ class TestGenericIntegration:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||
"--script", "sh", "--no-git",
|
||||
])
|
||||
# Generic requires --commands-dir via --integration-options
|
||||
assert result.exit_code != 0
|
||||
@@ -270,7 +269,7 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -292,14 +291,14 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
@@ -356,14 +355,14 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "ps", "--no-git",
|
||||
"--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
|
||||
@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
"--script", "ps", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -342,7 +342,6 @@ class TestHermesInitFlow:
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target),
|
||||
"--integration", "hermes",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestKimiNextSteps:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kimi", "--no-git",
|
||||
"init", "--here", "--integration", "kimi",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestKiroIntegration:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kiro-cli",
|
||||
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -25,8 +25,7 @@ def _run_init(project, *flags: str) -> Result:
|
||||
os.chdir(project)
|
||||
return CliRunner().invoke(
|
||||
app,
|
||||
["init", "--here", *flags, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools"],
|
||||
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -23,7 +26,6 @@ def _init_project(tmp_path, integration="copilot"):
|
||||
"init", "--here",
|
||||
"--integration", integration,
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -48,6 +50,32 @@ def _write_invalid_manifest(project, key):
|
||||
return manifest
|
||||
|
||||
|
||||
def _copy_project_template(tmp_path, template):
|
||||
project = tmp_path / "proj"
|
||||
shutil.copytree(template, project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_copilot_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_claude_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copilot_project(tmp_path, status_copilot_template):
|
||||
return _copy_project_template(tmp_path, status_copilot_template)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_project(tmp_path, status_claude_template):
|
||||
return _copy_project_template(tmp_path, status_claude_template)
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
plain = strip_ansi(output)
|
||||
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
|
||||
@@ -127,6 +155,823 @@ class TestIntegrationList:
|
||||
assert "only supports schema 1" in normalized
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationStatus:
|
||||
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_status_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: OK" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Shared templates target alignment: copilot" in result.output
|
||||
assert "Modified managed files: 0" in result.output
|
||||
assert "Missing managed files: 0" in result.output
|
||||
|
||||
def test_status_json_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["default_integration"] == "copilot"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == ["copilot"]
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is True
|
||||
assert payload["shared_templates_target_alignment"] == "copilot"
|
||||
assert "shared_templates_aligned_to" not in payload
|
||||
assert payload["findings"] == []
|
||||
|
||||
def test_status_reports_invalid_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-unreadable" in result.output
|
||||
assert "invalid JSON" in result.output
|
||||
assert "Detail:" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "Detail:" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["integration_state_schema"] = 99
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "schema 99" in payload["findings"][0]["message"]
|
||||
assert "supported schema: 1" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_missing_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-missing" in result.output
|
||||
assert ".specify/integration.json is missing" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-missing"
|
||||
|
||||
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["findings"][0]["code"] == "no-installed-integrations"
|
||||
assert "speckit" in payload["manifests"]
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
|
||||
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert any(
|
||||
item["code"] == "no-installed-integrations"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert any(
|
||||
item["code"] == "manifest-missing"
|
||||
and item["integration"] == "speckit"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["default_integration"] is None
|
||||
assert any(
|
||||
item["code"] == "default-integration-missing"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
"no-installed-integrations",
|
||||
]
|
||||
|
||||
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
]
|
||||
|
||||
def test_status_reports_default_integration_not_installed(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "codex"
|
||||
state["integration"] = "codex"
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "codex"
|
||||
assert payload["installed_integrations"] == ["codex", "claude"]
|
||||
assert payload["recorded_installed_integrations"] == ["claude"]
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
and "Default integration 'codex' is not listed" in item["message"]
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert "codex" not in payload["manifests"]
|
||||
assert not any(
|
||||
item["code"] == "manifest-missing" and item.get("integration") == "codex"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = []
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["installed_integrations"] == ["claude"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifests"]["claude"]["readable"] is True
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_missing_manifest(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "manifest-missing" in result.output
|
||||
assert "Manifest for integration 'copilot' is missing" in result.output
|
||||
|
||||
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
|
||||
_write_invalid_manifest(copilot_project, "copilot")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert payload["manifests"]["copilot"]["readable"] is False
|
||||
assert payload["manifests"]["copilot"]["missing_files"] == []
|
||||
assert payload["manifests"]["copilot"]["modified_files"] == []
|
||||
|
||||
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: WARNING" in result.output
|
||||
assert "managed-files-modified" in result.output
|
||||
assert "Modified managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_managed_files(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_shared_managed_files(self, copilot_project):
|
||||
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_file.exists()
|
||||
shared_file.unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "shared Spec Kit infrastructure" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
|
||||
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
|
||||
original_resolve = Path.resolve
|
||||
failed = {"done": False}
|
||||
|
||||
def fail_first_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project and not failed["done"]:
|
||||
failed["done"] = True
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
|
||||
self,
|
||||
copilot_project,
|
||||
monkeypatch,
|
||||
):
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
real_project = tmp_path / "real-project"
|
||||
real_project.mkdir()
|
||||
tracked = real_project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
symlinked_project = tmp_path / "symlinked-project"
|
||||
try:
|
||||
symlinked_project.symlink_to(real_project, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest = IntegrationManifest("test", real_project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
manifest.project_root = symlinked_project.absolute()
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
symlinked_project.absolute(),
|
||||
project_root_is_resolved=False,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
project_root_resolved = project.resolve()
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_parent_resolve(self, *args, **kwargs):
|
||||
if self == project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project_root_resolved,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == ["tracked.md"]
|
||||
assert valid == []
|
||||
|
||||
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
|
||||
from specify_cli import integration_status as status_module
|
||||
|
||||
def fail_load(key, project_root, **kwargs):
|
||||
raise RuntimeError(f"unexpected manifest loader bug for {key}")
|
||||
|
||||
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
|
||||
status_module.build_integration_status_report(copilot_project)
|
||||
|
||||
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
target = copilot_project / first_rel
|
||||
target.unlink()
|
||||
try:
|
||||
target.symlink_to(copilot_project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
regular_stat = tracked.lstat()
|
||||
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
tracked.unlink()
|
||||
try:
|
||||
tracked.symlink_to(project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_lstat = Path.lstat
|
||||
original_is_symlink = Path.is_symlink
|
||||
|
||||
def windows_style_lstat(self):
|
||||
if self == tracked:
|
||||
return regular_stat
|
||||
return original_lstat(self)
|
||||
|
||||
def windows_style_is_symlink(self):
|
||||
if self == tracked:
|
||||
return True
|
||||
return original_is_symlink(self)
|
||||
|
||||
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
|
||||
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == ["tracked.md"]
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
|
||||
from specify_cli.integration_status import _strip_extended_length_prefix
|
||||
|
||||
# Build the prefixed strings explicitly so the test is meaningful on
|
||||
# every platform (POSIX won't parse backslash separators, but the
|
||||
# helper operates on the string form). Compare Path objects rather than
|
||||
# their str() form: on Windows pathlib renders a UNC root with a
|
||||
# trailing separator (``\\server\share\``), so an exact string match is
|
||||
# brittle, whereas Path equality captures the intended semantics on
|
||||
# both POSIX and Windows.
|
||||
bs = "\\"
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}C:{bs}proj")
|
||||
) == Path(f"C:{bs}proj")
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
|
||||
) == Path(f"{bs}{bs}server{bs}share")
|
||||
# Paths without the prefix are returned unchanged.
|
||||
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
|
||||
|
||||
def test_is_within_project_tolerates_extended_length_prefix(self):
|
||||
from specify_cli.integration_status import _is_within_project
|
||||
|
||||
# A readlink result on POSIX never carries the prefix, so an in-project
|
||||
# child is contained and an outside path is not. The Windows
|
||||
# prefix-stripping branch is exercised by the dangling-symlink tests on
|
||||
# Windows CI; here we lock in the cross-platform containment contract.
|
||||
root = Path("/tmp/project").resolve()
|
||||
assert _is_within_project(root, root / "child")
|
||||
assert not _is_within_project(root, Path("/tmp/other").resolve())
|
||||
|
||||
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
|
||||
link = copilot_project / "outside-link"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "secret.txt"
|
||||
outside_file.write_text("outside project\n", encoding="utf-8")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
tracked_path = copilot_project / first_rel
|
||||
tracked_path.unlink()
|
||||
try:
|
||||
tracked_path.symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_stat = Path.stat
|
||||
|
||||
def fail_tracked_symlink_stat(self, *args, **kwargs):
|
||||
follows_symlinks = kwargs.get("follow_symlinks", True)
|
||||
if self == tracked_path and follows_symlinks:
|
||||
raise AssertionError("Path.stat() should not follow tracked symlinks")
|
||||
return original_stat(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["copilot", "claude"]
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("claude", copilot_project, version="test").save()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
assert "specify integration switch <key>" in result.output
|
||||
|
||||
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "mystery"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("mystery", claude_project, version="test").save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unknown-integration" in result.output
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "remove the stale integration entry" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
|
||||
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["mystery"]
|
||||
state["default_integration"] = "mystery"
|
||||
state["integration"] = "mystery"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
manifest_finding = next(
|
||||
item for item in payload["findings"]
|
||||
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
|
||||
)
|
||||
assert "remove the stale integration entry" in manifest_finding["suggestion"]
|
||||
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
|
||||
|
||||
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "../../../escape"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
outside_manifest = tmp_path / "escape.manifest.json"
|
||||
outside_manifest.write_text(
|
||||
json.dumps({"integration": unsafe_key, "files": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert unsafe_key not in payload["manifests"]
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "bad:key"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "CON"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_managed_file_collisions(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "codex"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
|
||||
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
|
||||
shared_rel = next(iter(tracked_files))
|
||||
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
|
||||
codex_manifest.record_existing(shared_rel)
|
||||
codex_manifest.save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "managed-file-collision" in result.output
|
||||
assert "Integration status: WARNING" in result.output
|
||||
|
||||
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "[red]x[/red]"
|
||||
assert payload["installed_integrations"] == ["[red]x[/red]"]
|
||||
|
||||
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Default integration: [red]x[/red]" in result.output
|
||||
assert "Installed integrations: [red]x[/red]" in result.output
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -961,7 +1806,7 @@ class TestIntegrationSwitch:
|
||||
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
||||
"""Switching refreshes managed shared scripts to the target command style."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert shared_script.exists()
|
||||
shared_content = shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in shared_content
|
||||
@@ -987,7 +1832,7 @@ class TestIntegrationSwitch:
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
# Simulate a stale vendored script: write truncated content as bytes
|
||||
@@ -999,7 +1844,7 @@ class TestIntegrationSwitch:
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
|
||||
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
|
||||
hashlib.sha256(stale_bytes).hexdigest()
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
@@ -1048,7 +1893,7 @@ class TestIntegrationSwitch:
|
||||
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
rendered_bytes = shared_script.read_bytes()
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ class TestMultiInstallSafeContracts:
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
|
||||
@@ -1,74 +1,24 @@
|
||||
"""
|
||||
Unit tests for branch numbering options (sequential vs timestamp).
|
||||
Unit tests verifying --branch-numbering removal (v0.10.0).
|
||||
|
||||
Tests cover:
|
||||
- Persisting branch_numbering in init-options.json
|
||||
- Default value when branch_numbering is None
|
||||
- Validation of branch_numbering values
|
||||
Branch numbering is now managed entirely by the git extension's config.
|
||||
The --branch-numbering flag was removed from `specify init`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli import save_init_options
|
||||
|
||||
class TestBranchNumberingFlagRemoved:
|
||||
"""--branch-numbering flag was removed in v0.10.0."""
|
||||
|
||||
class TestSaveBranchNumbering:
|
||||
"""Tests for save_init_options with branch_numbering."""
|
||||
|
||||
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "timestamp", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "timestamp"
|
||||
|
||||
def test_save_branch_numbering_sequential(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "sequential", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "proj"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
|
||||
class TestBranchNumberingValidation:
|
||||
"""Tests for branch_numbering CLI validation via CliRunner."""
|
||||
|
||||
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
|
||||
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid --branch-numbering" in result.output
|
||||
|
||||
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
|
||||
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "proj"), "--integration", "claude",
|
||||
"--branch-numbering", "sequential", "--ignore-agent-tools",
|
||||
])
|
||||
assert result.exit_code != 0, "--branch-numbering should be rejected"
|
||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
||||
|
||||
@@ -34,6 +34,15 @@ def _install_ps_scripts(repo: Path) -> None:
|
||||
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -69,7 +78,10 @@ def prereq_repo(tmp_path: Path) -> Path:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must return paths without branch validation (main branch)."""
|
||||
"""--paths-only must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
@@ -88,20 +100,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must also work on a properly named spec branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
@@ -111,7 +123,10 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only without --json must return text paths on a non-spec branch."""
|
||||
"""--paths-only without --json must return text paths from feature.json."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--paths-only"],
|
||||
@@ -128,7 +143,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, branch validation must still fail on main."""
|
||||
"""Without --paths-only, feature directory validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
@@ -139,7 +154,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
@@ -147,7 +162,10 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must return paths without branch validation (main branch)."""
|
||||
"""-PathsOnly must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -167,21 +185,26 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work on a properly named spec branch."""
|
||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
@@ -190,7 +213,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, branch validation must still fail on main."""
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -202,4 +225,5 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Feature directory not found" in combined
|
||||
|
||||
@@ -3087,6 +3087,424 @@ class TestExtensionCatalog:
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``extensions`` is the wrong type.
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
{"schema_version": "1.0", "extensions": 42},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload):
|
||||
"""Malformed catalog payloads raise ExtensionError, not AttributeError.
|
||||
|
||||
Without this guard, a payload like ``{"extensions": []}`` would pass the
|
||||
key-presence check and then crash with ``AttributeError: 'list' object
|
||||
has no attribute 'items'`` deep inside ``_get_merged_extensions``. The
|
||||
sibling integration catalog reader already validates both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``); the
|
||||
extension catalog must stay consistent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(ExtensionError, match="Invalid catalog format"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached_payload",
|
||||
[
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_cached_payload(
|
||||
self, temp_dir, cached_payload
|
||||
):
|
||||
"""A poisoned cache silently falls back to the network instead of
|
||||
crashing — cached payloads pass through the same shape validation
|
||||
as freshly-fetched ones.
|
||||
|
||||
Without this, a cache poisoned by an older spec-kit version (or a
|
||||
manual edit, or an upstream that briefly served a bad payload
|
||||
before the network guards landed) would re-crash every invocation
|
||||
of ``_get_merged_extensions`` despite the cache being "valid" by
|
||||
age. The recovery contract is: if the cached payload fails
|
||||
validation, drop it and refetch — never propagate
|
||||
``AttributeError`` to the caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
|
||||
# branch that goes through ``is_cache_valid()`` (the non-default
|
||||
# branch uses per-URL hashed cache files but the same code path
|
||||
# below).
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(cached_payload))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Network refetch returns a valid payload so the recovery path
|
||||
# can complete.
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog._fetch_single_catalog(entry, force_refresh=False)
|
||||
|
||||
# The poisoned cache was discarded and the network payload returned.
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``extensions`` is the wrong type.
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
|
||||
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
|
||||
|
||||
Before this change ``fetch_catalog`` only checked key presence — so
|
||||
a payload like ``42`` would crash with
|
||||
``TypeError: argument of type 'int' is not iterable`` during the
|
||||
``"schema_version" in catalog_data`` check, and an entry mapping
|
||||
of the wrong type would crash downstream. Reusing
|
||||
``_validate_catalog_payload`` keeps the network-side behaviour of
|
||||
the legacy single-catalog method consistent with the multi-catalog
|
||||
``_fetch_single_catalog`` path.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(ExtensionError, match="Invalid catalog format"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
|
||||
"""An unreadable / wrong-encoded cache file silently refetches.
|
||||
|
||||
The cache contract is best-effort: a JSON-decode failure, an OS
|
||||
read failure (permissions / disk / handle limit), or an invalid
|
||||
text encoding on a cache file written by an older client must
|
||||
all fall through to the network fetch rather than crash the
|
||||
caller. Covers Copilot's review point that the previous
|
||||
``except (json.JSONDecodeError,)`` was too narrow.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
# Write invalid UTF-8 bytes to the cache file so ``read_text``
|
||||
# raises ``UnicodeDecodeError`` (a subclass of ``UnicodeError``).
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
# Recovered via network rather than crashing on the unreadable cache.
|
||||
assert result == valid
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
|
||||
"""A wrongly-encoded metadata file degrades to a cache miss.
|
||||
|
||||
``is_cache_valid`` is consulted *before* the cache payload is
|
||||
read; if the metadata file itself can't be decoded (e.g. it was
|
||||
written on a Windows host whose default codec isn't UTF-8) the
|
||||
validity check must return ``False`` rather than propagate
|
||||
``UnicodeDecodeError``. Without that guard, a corrupted metadata
|
||||
file would crash every invocation instead of falling through to
|
||||
a network refetch.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
|
||||
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
|
||||
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
|
||||
|
||||
# is_cache_valid must absorb the decode failure, not crash.
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"non_mapping_metadata",
|
||||
[
|
||||
"[]", # JSON array
|
||||
'"oops"', # JSON string
|
||||
"42", # JSON number
|
||||
"true", # JSON bool
|
||||
"null", # JSON null
|
||||
],
|
||||
)
|
||||
def test_is_cache_valid_handles_non_mapping_metadata(
|
||||
self, temp_dir, non_mapping_metadata
|
||||
):
|
||||
"""Metadata that parses to a non-mapping degrades to cache-invalid.
|
||||
|
||||
The cache-validity check calls ``metadata.get("cached_at", "")``
|
||||
immediately after ``json.loads``. If the metadata file is valid
|
||||
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
|
||||
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
|
||||
previously slipped past the except tuple and crashed the
|
||||
caller. The contract documented on ``is_cache_valid`` says any
|
||||
decode/shape failure should return ``False`` so ``fetch_catalog``
|
||||
falls through to a network refetch. This test pins that
|
||||
contract across every JSON non-mapping root type so a regression
|
||||
in the except clause can't silently re-introduce the crash.
|
||||
"""
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
non_mapping_metadata, encoding="utf-8"
|
||||
)
|
||||
|
||||
# Must not raise — the contract is "any decode/shape failure → False".
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
|
||||
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
|
||||
|
||||
The earlier version of this test claimed to assert UTF-8 at the
|
||||
byte level but actually only round-tripped a non-ASCII string
|
||||
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
|
||||
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
|
||||
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
|
||||
ever reached ``write_text`` — the bytes on disk were identical
|
||||
regardless of the encoding kwarg, so a locale-encoded write
|
||||
would have round-tripped just fine. The drift Copilot's review
|
||||
flagged wasn't actually being caught.
|
||||
|
||||
Fix: directly observe the ``encoding`` argument passed to every
|
||||
``write_text`` call made against the cache directory. This is
|
||||
the production code's encoding choice, which is exactly what
|
||||
the regression guard cares about; non-ASCII payload tricks are
|
||||
unnecessary because the assertion is about the kwarg, not the
|
||||
bytes.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Record every ``write_text`` call's encoding kwarg so the
|
||||
# assertion observes the production writer's argument directly.
|
||||
recorded: list[dict] = []
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def recording_write_text(self, data, *args, **kwargs):
|
||||
recorded.append(
|
||||
{"path": str(self), "encoding": kwargs.get("encoding")}
|
||||
)
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
# Filter to writes inside the catalog's cache directory so
|
||||
# unrelated writes from other machinery don't pollute the
|
||||
# assertion.
|
||||
cache_writes = [
|
||||
r for r in recorded if str(catalog.cache_dir) in r["path"]
|
||||
]
|
||||
assert cache_writes, "fetch_catalog made no writes to the cache dir"
|
||||
for record in cache_writes:
|
||||
assert record["encoding"] == "utf-8", (
|
||||
f"write_text on {record['path']} used encoding "
|
||||
f"{record['encoding']!r}; expected 'utf-8'"
|
||||
)
|
||||
|
||||
def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
|
||||
"""An unwritable cache dir doesn't fail a successful fetch.
|
||||
|
||||
Cache writes are best-effort, mirroring the read side and the
|
||||
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
|
||||
raises ``OSError`` (read-only checkout, permissions), the
|
||||
already-fetched-and-validated payload must still be returned
|
||||
rather than surfacing the cache failure to the caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Simulate an unwritable cache dir: every write_text under the
|
||||
# cache directory raises PermissionError (an OSError subclass).
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def failing_write_text(self, data, *args, **kwargs):
|
||||
if str(catalog.cache_dir) in str(self):
|
||||
raise PermissionError("cache dir is read-only")
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
# Legacy single-catalog path.
|
||||
assert catalog.fetch_catalog(force_refresh=True) == valid
|
||||
|
||||
# Multi-catalog path.
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
assert catalog._fetch_single_catalog(entry, force_refresh=True) == valid
|
||||
|
||||
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
|
||||
"""Per-entry guard: one malformed entry shouldn't poison the merge.
|
||||
|
||||
``_fetch_single_catalog`` validates that ``extensions`` is a mapping,
|
||||
but it doesn't (and shouldn't) validate every entry inside it — a
|
||||
single bad entry in an otherwise-valid catalog should be skipped, not
|
||||
crash the whole resolve path. Mirrors the per-entry skip in
|
||||
``integrations/catalog.py``: a malformed entry returns no error,
|
||||
valid entries continue to merge normally.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
# Mix of valid entry, list-shaped entry, and string-shaped entry.
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"good": {"name": "Good", "version": "1.0.0"},
|
||||
"bad-list": [],
|
||||
"bad-str": "oops",
|
||||
},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response), \
|
||||
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
|
||||
merged = catalog._get_merged_extensions(force_refresh=True)
|
||||
|
||||
# Only the well-formed entry survives; the two malformed entries are
|
||||
# silently dropped rather than raising or crashing.
|
||||
assert [ext["id"] for ext in merged] == ["good"]
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -1514,6 +1514,421 @@ class TestPresetCatalog:
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``presets`` is the wrong type.
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
{"schema_version": "1.0", "presets": 42},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload):
|
||||
"""Malformed catalog payloads raise PresetError, not AttributeError.
|
||||
|
||||
Without this guard, a payload like ``{"presets": []}`` would pass the
|
||||
key-presence check and then crash with ``AttributeError: 'list' object
|
||||
has no attribute 'items'`` deep inside ``_get_merged_packs``. The
|
||||
sibling integration catalog reader already validates both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``); the
|
||||
preset catalog must stay consistent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached_payload",
|
||||
[
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_cached_payload(
|
||||
self, project_dir, cached_payload
|
||||
):
|
||||
"""A poisoned cache silently falls back to the network instead of
|
||||
crashing — cached payloads pass through the same shape validation
|
||||
as freshly-fetched ones.
|
||||
|
||||
Without this, a cache poisoned by an older spec-kit version (or a
|
||||
manual edit, or an upstream that briefly served a bad payload
|
||||
before the network guards landed) would re-crash every invocation
|
||||
of ``_get_merged_packs`` despite the cache being "valid" by age.
|
||||
The recovery contract is: if the cached payload fails validation,
|
||||
drop it and refetch — never propagate ``AttributeError`` to the
|
||||
caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
|
||||
# non-default URLs both flow through the same cache-load branch.
|
||||
cache_file, metadata_file = catalog._get_cache_paths(
|
||||
catalog.DEFAULT_CATALOG_URL
|
||||
)
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(cached_payload))
|
||||
metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Network refetch returns a valid payload so the recovery path
|
||||
# can complete.
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog._fetch_single_catalog(entry, force_refresh=False)
|
||||
|
||||
# The poisoned cache was discarded and the network payload returned.
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``presets`` is the wrong type.
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload):
|
||||
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
|
||||
|
||||
Before this change ``fetch_catalog`` only checked key presence —
|
||||
so a payload like ``42`` would crash with
|
||||
``TypeError: argument of type 'int' is not iterable`` during the
|
||||
``"schema_version" in catalog_data`` check, and an entry mapping
|
||||
of the wrong type would crash downstream. Reusing
|
||||
``_validate_catalog_payload`` keeps the network-side behaviour of
|
||||
the legacy single-catalog method consistent with the multi-catalog
|
||||
``_fetch_single_catalog`` path.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir):
|
||||
"""An unreadable / wrong-encoded cache file silently refetches.
|
||||
|
||||
The cache contract is best-effort: a JSON-decode failure, an OS
|
||||
read failure (permissions / disk / handle limit), or an invalid
|
||||
text encoding on a cache file written by an older client must
|
||||
all fall through to the network fetch rather than crash the
|
||||
caller. Covers Copilot's review point that the previous
|
||||
``except (json.JSONDecodeError, OSError)`` was missing
|
||||
``UnicodeError``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError``
|
||||
# (a subclass of ``UnicodeError``).
|
||||
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog.get_catalog_url(),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
# Recovered via network rather than crashing on the unreadable cache.
|
||||
assert result == valid
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir):
|
||||
"""A wrongly-encoded metadata file degrades to a cache miss.
|
||||
|
||||
``is_cache_valid`` is consulted *before* the cache payload is
|
||||
read; if the metadata file itself can't be decoded (e.g. it was
|
||||
written on a host whose default codec isn't UTF-8) the validity
|
||||
check must return ``False`` rather than propagate
|
||||
``UnicodeDecodeError``. Without that guard, a corrupted metadata
|
||||
file would crash every invocation instead of falling through to
|
||||
a network refetch.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
|
||||
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
|
||||
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
|
||||
|
||||
# is_cache_valid must absorb the decode failure, not crash.
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"non_mapping_metadata",
|
||||
[
|
||||
"[]", # JSON array
|
||||
'"oops"', # JSON string
|
||||
"42", # JSON number
|
||||
"true", # JSON bool
|
||||
"null", # JSON null
|
||||
],
|
||||
)
|
||||
def test_is_cache_valid_handles_non_mapping_metadata(
|
||||
self, project_dir, non_mapping_metadata
|
||||
):
|
||||
"""Metadata that parses to a non-mapping degrades to cache-invalid.
|
||||
|
||||
The cache-validity check calls ``metadata.get("cached_at", "")``
|
||||
immediately after ``json.loads``. If the metadata file is valid
|
||||
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
|
||||
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
|
||||
previously slipped past the except tuple and crashed the
|
||||
caller. The contract documented on ``is_cache_valid`` says any
|
||||
decode/shape failure should return ``False`` so ``fetch_catalog``
|
||||
falls through to a network refetch. This test pins that
|
||||
contract across every JSON non-mapping root type.
|
||||
"""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
non_mapping_metadata, encoding="utf-8"
|
||||
)
|
||||
|
||||
# Must not raise — the contract is "any decode/shape failure → False".
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch):
|
||||
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
|
||||
|
||||
The earlier version of this test claimed to assert UTF-8 at the
|
||||
byte level but actually only round-tripped a non-ASCII string
|
||||
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
|
||||
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
|
||||
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
|
||||
ever reached ``write_text`` — the bytes on disk were identical
|
||||
regardless of the encoding kwarg. The drift Copilot's review
|
||||
flagged wasn't actually being caught.
|
||||
|
||||
Fix: directly observe the ``encoding`` argument passed to every
|
||||
``write_text`` call made against the cache directory. This is
|
||||
the production code's encoding choice, which is exactly what
|
||||
the regression guard cares about.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Record every ``write_text`` call's encoding kwarg so the
|
||||
# assertion observes the production writer's argument directly.
|
||||
recorded: list[dict] = []
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def recording_write_text(self, data, *args, **kwargs):
|
||||
recorded.append(
|
||||
{"path": str(self), "encoding": kwargs.get("encoding")}
|
||||
)
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
cache_writes = [
|
||||
r for r in recorded if str(catalog.cache_dir) in r["path"]
|
||||
]
|
||||
assert cache_writes, "fetch_catalog made no writes to the cache dir"
|
||||
for record in cache_writes:
|
||||
assert record["encoding"] == "utf-8", (
|
||||
f"write_text on {record['path']} used encoding "
|
||||
f"{record['encoding']!r}; expected 'utf-8'"
|
||||
)
|
||||
|
||||
def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch):
|
||||
"""An unwritable cache dir doesn't fail a successful fetch.
|
||||
|
||||
Cache writes are best-effort, mirroring the read side and the
|
||||
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
|
||||
raises ``OSError`` (read-only checkout, permissions), the
|
||||
already-fetched-and-validated payload must still be returned —
|
||||
not swallowed into the broad except and re-raised as a
|
||||
``PresetError``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Simulate an unwritable cache dir: every write_text under the
|
||||
# cache directory raises PermissionError (an OSError subclass).
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def failing_write_text(self, data, *args, **kwargs):
|
||||
if str(catalog.cache_dir) in str(self):
|
||||
raise PermissionError("cache dir is read-only")
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
# Legacy single-catalog path.
|
||||
assert catalog.fetch_catalog(force_refresh=True) == valid
|
||||
|
||||
# Multi-catalog path.
|
||||
entry = PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
assert (
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True) == valid
|
||||
)
|
||||
|
||||
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
|
||||
"""Per-entry guard: one malformed entry shouldn't poison the merge.
|
||||
|
||||
``_fetch_single_catalog`` validates that ``presets`` is a mapping,
|
||||
but it doesn't (and shouldn't) validate every entry inside it — a
|
||||
single bad entry in an otherwise-valid catalog should be skipped,
|
||||
not crash the whole resolve path. Mirrors the per-entry skip in
|
||||
``integrations/catalog.py``: a malformed entry returns no error,
|
||||
valid entries continue to merge normally.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {
|
||||
"good": {"name": "Good", "version": "1.0.0"},
|
||||
"bad-list": [],
|
||||
"bad-str": "oops",
|
||||
},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response), \
|
||||
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
|
||||
merged = catalog._get_merged_packs(force_refresh=True)
|
||||
|
||||
# Only the well-formed entry survives; the two malformed entries are
|
||||
# silently dropped rather than raising or crashing.
|
||||
assert list(merged.keys()) == ["good"]
|
||||
|
||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""download_pack passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -41,6 +41,13 @@ def _minimal_templates(repo: Path) -> None:
|
||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(repo: Path, feature_directory: str) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Return a copy of the current environment with any SPECIFY_* vars removed.
|
||||
|
||||
@@ -89,10 +96,7 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -107,12 +111,8 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -123,13 +123,14 @@ def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) ->
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
|
||||
cwd=plan_repo,
|
||||
@@ -138,6 +139,7 @@ def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -161,10 +163,7 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -180,14 +179,9 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||
def test_setup_plan_ps_errors_without_feature_context(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -198,5 +192,6 @@ def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
combined = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in combined
|
||||
|
||||
@@ -41,6 +41,15 @@ def _minimal_templates(repo: Path) -> None:
|
||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -74,6 +83,7 @@ def plan_repo(tmp_path: Path) -> Path:
|
||||
_minimal_templates(repo)
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
_write_feature_json(repo)
|
||||
return repo
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -50,6 +50,15 @@ def _install_core_tasks_template(repo: Path) -> None:
|
||||
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _minimal_feature(repo: Path) -> Path:
|
||||
"""
|
||||
Create a numbered branch-style feature directory with spec.md and plan.md
|
||||
@@ -60,6 +69,7 @@ def _minimal_feature(repo: Path) -> Path:
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(repo)
|
||||
return feat
|
||||
|
||||
|
||||
@@ -85,7 +95,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""
|
||||
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
||||
rely purely on git branch + feature.json state set up by each fixture.
|
||||
rely purely on feature.json and on-disk feature directories set up by each fixture.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -153,7 +163,8 @@ def tasks_repo(tmp_path: Path) -> Path:
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
|
||||
# Switch to a numbered branch so branch validation passes without feature.json
|
||||
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
|
||||
# feature directories from repository state rather than validating git branches.
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=repo,
|
||||
@@ -492,6 +503,7 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
@@ -550,11 +562,7 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
@@ -571,21 +579,17 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
def test_setup_tasks_bash_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.sh must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
|
||||
main_feat = tasks_repo / "specs" / "main"
|
||||
main_feat.mkdir(parents=True, exist_ok=True)
|
||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -596,7 +600,7 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
# ===========================================================================
|
||||
# POWERSHELL TESTS
|
||||
@@ -731,6 +735,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
@@ -793,11 +798,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
@@ -815,22 +816,18 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
def test_setup_tasks_ps_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
|
||||
main_feat = tasks_repo / "specs" / "main"
|
||||
main_feat.mkdir(parents=True, exist_ok=True)
|
||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -839,6 +836,7 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in output
|
||||
|
||||
@@ -19,14 +19,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
EXT_CREATE_FEATURE = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
)
|
||||
EXT_CREATE_FEATURE_PS = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
)
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
|
||||
@@ -77,7 +75,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
|
||||
# Copy extension script
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||
ext_dir.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
|
||||
# Also copy git-common.sh if it exists
|
||||
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
if git_common.exists():
|
||||
@@ -106,7 +104,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
# Copy extension script
|
||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||
ext_ps.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
|
||||
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
if git_common_ps.exists():
|
||||
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
||||
@@ -279,64 +277,13 @@ class TestSequentialBranchPowerShell:
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCheckFeatureBranch:
|
||||
def test_accepts_timestamp_branch(self):
|
||||
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_sequential_branch(self):
|
||||
"""Test 7: check_feature_branch accepts sequential branch."""
|
||||
result = source_and_call('check_feature_branch "004-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_main(self):
|
||||
"""Test 8: check_feature_branch rejects main."""
|
||||
result = source_and_call('check_feature_branch "main" "true"')
|
||||
class TestCoreCommonRemovesGitHelpers:
|
||||
def test_check_feature_branch_removed(self):
|
||||
result = source_and_call('declare -F check_feature_branch >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_accepts_four_digit_sequential_branch(self):
|
||||
"""check_feature_branch accepts 4+ digit sequential branch."""
|
||||
result = source_and_call('check_feature_branch "1234-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_partial_timestamp(self):
|
||||
"""Test 9: check_feature_branch rejects 7-digit date."""
|
||||
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_timestamp_without_slug(self):
|
||||
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
|
||||
result = source_and_call('check_feature_branch "20260319-143022" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_7digit_timestamp_without_slug(self):
|
||||
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
|
||||
result = source_and_call('check_feature_branch "2026031-143022" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_accepts_single_prefix_sequential(self):
|
||||
"""Optional gitflow-style prefix: one segment + sequential feature name."""
|
||||
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_single_prefix_timestamp(self):
|
||||
"""Optional prefix + timestamp-style feature name."""
|
||||
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_invalid_suffix_with_single_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/main" "true"')
|
||||
assert result.returncode != 0
|
||||
assert "feat/main" in result.stderr
|
||||
|
||||
def test_rejects_two_level_prefix_before_feature(self):
|
||||
"""More than one slash: no stripping; whole name must match (fails)."""
|
||||
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_malformed_timestamp_with_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
|
||||
def test_has_git_removed(self):
|
||||
result = source_and_call('declare -F has_git >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@@ -344,50 +291,11 @@ class TestCheckFeatureBranch:
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestFindFeatureDirByPrefix:
|
||||
def test_timestamp_branch(self, tmp_path: Path):
|
||||
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
||||
|
||||
def test_cross_branch_prefix(self, tmp_path: Path):
|
||||
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
||||
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
||||
|
||||
def test_four_digit_sequential_prefix(self, tmp_path: Path):
|
||||
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
|
||||
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
|
||||
|
||||
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
|
||||
"""Strip one optional prefix segment before prefix directory lookup."""
|
||||
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
|
||||
|
||||
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
|
||||
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
|
||||
class TestFindFeatureDirByPrefixRemoved:
|
||||
def test_find_feature_dir_by_prefix_removed(self):
|
||||
"""Directory scanning helper is removed from core common.sh."""
|
||||
result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
||||
@@ -395,26 +303,29 @@ class TestFindFeatureDirByPrefix:
|
||||
|
||||
class TestGetFeaturePathsSinglePrefix:
|
||||
@requires_bash
|
||||
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
|
||||
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
|
||||
def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
|
||||
(tmp_path / ".specify").mkdir()
|
||||
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
|
||||
cmd = (
|
||||
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
|
||||
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
|
||||
f'source "{COMMON_SH}" && get_feature_paths'
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
|
||||
self, git_repo: Path
|
||||
):
|
||||
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
@@ -426,14 +337,8 @@ class TestGetFeaturePathsSinglePrefix:
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip()
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in (result.stderr + result.stdout)
|
||||
|
||||
|
||||
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||
@@ -453,12 +358,11 @@ class TestGetCurrentBranch:
|
||||
@requires_bash
|
||||
class TestNoGitTimestamp:
|
||||
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||
"""Test 13: Timestamp mode works without git and creates a spec dir."""
|
||||
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
||||
assert len(spec_dirs) > 0, "spec dir not created"
|
||||
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
||||
|
||||
|
||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||
@@ -467,32 +371,65 @@ class TestNoGitTimestamp:
|
||||
@requires_bash
|
||||
class TestE2EFlow:
|
||||
def test_e2e_timestamp(self, git_repo: Path):
|
||||
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||
branch = subprocess.run(
|
||||
"""Test 14: E2E timestamp flow creates only a feature directory."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
branch_name = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch_name = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
assert branch_name is not None
|
||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
|
||||
assert (git_repo / "specs" / branch_name).is_dir()
|
||||
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_e2e_sequential(self, git_repo: Path):
|
||||
"""Test 15: E2E sequential flow (regression guard)."""
|
||||
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||
branch = subprocess.run(
|
||||
"""Test 15: E2E sequential flow creates only a feature directory."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
branch_name = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch_name = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
assert branch_name == "001-seq-feat"
|
||||
assert (git_repo / "specs" / branch_name).is_dir()
|
||||
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
|
||||
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
||||
@@ -500,67 +437,22 @@ class TestE2EFlow:
|
||||
|
||||
@requires_bash
|
||||
class TestAllowExistingBranch:
|
||||
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
||||
"""T006: Pre-create branch, verify script switches to it."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "004-pre-exist"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
|
||||
"""T006: Existing feature directory can be reused when the flag is set."""
|
||||
feature_dir = git_repo / "specs" / "004-pre-exist"
|
||||
feature_dir.mkdir(parents=True)
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
||||
"--number", "4", "Pre-existing feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
current = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
|
||||
|
||||
def test_allow_existing_already_on_branch(self, git_repo: Path):
|
||||
"""T007: Verify success when already on the target branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "005-already-on"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "already-on",
|
||||
"--number", "5", "Already on branch",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
|
||||
"""T008: Verify spec directory created on existing branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "006-spec-dir"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
|
||||
"--number", "6", "Spec dir feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (git_repo / "specs" / "006-spec-dir").is_dir()
|
||||
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
|
||||
assert feature_dir.is_dir()
|
||||
assert (feature_dir / "spec.md").exists()
|
||||
|
||||
def test_without_flag_still_errors(self, git_repo: Path):
|
||||
"""T009: Verify backwards compatibility (error without flag)."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "007-no-flag"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
"""T009: Existing feature directories still fail without the flag."""
|
||||
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
|
||||
result = run_script(
|
||||
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
|
||||
)
|
||||
@@ -569,18 +461,11 @@ class TestAllowExistingBranch:
|
||||
|
||||
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
|
||||
"""T010: Pre-create spec.md with content, verify it is preserved."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "008-no-overwrite"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
spec_dir = git_repo / "specs" / "008-no-overwrite"
|
||||
spec_dir.mkdir(parents=True)
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("# My custom spec content\n")
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
|
||||
"--number", "8", "No overwrite feature",
|
||||
@@ -588,31 +473,20 @@ class TestAllowExistingBranch:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert spec_file.read_text() == "# My custom spec content\n"
|
||||
|
||||
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
|
||||
"""T011: Verify normal creation when branch doesn't exist."""
|
||||
def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
|
||||
"""T011: Verify normal directory creation when the feature dir does not exist."""
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
||||
"New branch feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
current = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
assert "new-branch" in current
|
||||
assert (git_repo / "specs" / "001-new-branch").is_dir()
|
||||
|
||||
def test_allow_existing_with_json(self, git_repo: Path):
|
||||
"""T012: Verify JSON output is correct."""
|
||||
import json
|
||||
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "009-json-test"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
(git_repo / "specs" / "009-json-test").mkdir(parents=True)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
|
||||
"--number", "9", "JSON test",
|
||||
@@ -622,64 +496,26 @@ class TestAllowExistingBranch:
|
||||
assert data["BRANCH_NAME"] == "009-json-test"
|
||||
|
||||
def test_allow_existing_no_git(self, no_git_dir: Path):
|
||||
"""T013: Verify flag is silently ignored in non-git repos."""
|
||||
"""T013: Verify flag also works in non-git repos."""
|
||||
result = run_script(
|
||||
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
|
||||
"No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
|
||||
"""Checkout failures on an existing branch should include Git's stderr."""
|
||||
shared_file = git_repo / "shared.txt"
|
||||
shared_file.write_text("base\n")
|
||||
subprocess.run(
|
||||
["git", "add", "shared.txt"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "add shared file", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "010-checkout-failure"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("branch version\n")
|
||||
subprocess.run(
|
||||
["git", "commit", "-am", "branch change", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("uncommitted main change\n")
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
|
||||
"--number", "10", "Checkout failure",
|
||||
)
|
||||
|
||||
assert result.returncode != 0, "checkout should fail with conflicting local changes"
|
||||
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
|
||||
assert "would be overwritten by checkout" in result.stderr
|
||||
assert "shared.txt" in result.stderr
|
||||
|
||||
|
||||
class TestAllowExistingBranchPowerShell:
|
||||
def test_powershell_supports_allow_existing_branch_flag(self):
|
||||
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
|
||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "-AllowExistingBranch" in contents
|
||||
# Ensure the flag is referenced in script logic, not just declared
|
||||
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
||||
|
||||
def test_powershell_surfaces_checkout_errors(self):
|
||||
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
|
||||
def test_powershell_reuses_existing_feature_dir(self):
|
||||
"""Static guard: PS script handles existing feature directories without git."""
|
||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
assert "Feature directory '$featureDir' already exists" in contents
|
||||
assert "-not $AllowExistingBranch" in contents
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
@pytest.mark.skipif(
|
||||
@@ -754,20 +590,27 @@ class TestDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
|
||||
|
||||
def test_dry_run_no_branch_created(self, git_repo: Path):
|
||||
"""T010: Dry-run does not create a git branch."""
|
||||
def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
|
||||
"""T010: Dry-run leaves the current git branch untouched."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_script(
|
||||
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*no-branch*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
|
||||
"""T011: Dry-run does not create any directories (including root specs/)."""
|
||||
@@ -832,50 +675,22 @@ class TestDryRun:
|
||||
real_branch = line.split(":", 1)[1].strip()
|
||||
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
|
||||
|
||||
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
|
||||
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
|
||||
def test_dry_run_ignores_git_branches(self, git_repo: Path):
|
||||
"""Dry-run uses only spec directories for numbering."""
|
||||
(git_repo / "specs" / "001-existing").mkdir(parents=True)
|
||||
|
||||
# Set up a bare remote and push (use subdirs of git_repo for isolation)
|
||||
remote_dir = git_repo / "test-remote.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", str(remote_dir)],
|
||||
check=True, capture_output=True,
|
||||
["git", "checkout", "-b", "005-git-only"],
|
||||
cwd=git_repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", str(remote_dir)],
|
||||
check=True, cwd=git_repo, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "push", "-u", "origin", "HEAD"],
|
||||
check=True, cwd=git_repo, capture_output=True,
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Clone into a second copy, create a higher-numbered branch, push it
|
||||
second_clone = git_repo / "test-second-clone"
|
||||
subprocess.run(
|
||||
["git", "clone", str(remote_dir), str(second_clone)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
# Create branch 005 on the remote (higher than local 001)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "005-remote-only"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "push", "origin", "005-remote-only"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
|
||||
# Primary repo: dry-run should see 005 via ls-remote and return 006
|
||||
dry_result = run_script(
|
||||
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
|
||||
)
|
||||
@@ -884,7 +699,7 @@ class TestDryRun:
|
||||
for line in dry_result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
dry_branch = line.split(":", 1)[1].strip()
|
||||
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
|
||||
assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
|
||||
|
||||
def test_dry_run_json_includes_field(self, git_repo: Path):
|
||||
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
|
||||
@@ -910,7 +725,14 @@ class TestDryRun:
|
||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||
|
||||
def test_dry_run_with_timestamp(self, git_repo: Path):
|
||||
"""T017: Dry-run works with --timestamp flag."""
|
||||
"""T017: Dry-run works with --timestamp flag without mutating git state."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_script(
|
||||
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
|
||||
)
|
||||
@@ -921,15 +743,14 @@ class TestDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None, "no BRANCH_NAME in output"
|
||||
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
||||
# Verify no side effects
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*ts-feat*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == ""
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_dry_run_with_number(self, git_repo: Path):
|
||||
"""T018: Dry-run works with --number flag."""
|
||||
@@ -989,20 +810,27 @@ class TestPowerShellDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
|
||||
|
||||
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun does not create a git branch."""
|
||||
def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun leaves the current git branch untouched."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_ps_script(
|
||||
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*no-ps-branch*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun does not create specs/ directory."""
|
||||
@@ -1046,10 +874,10 @@ class TestPowerShellDryRun:
|
||||
|
||||
@requires_bash
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
|
||||
|
||||
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
||||
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras})
|
||||
@@ -1101,10 +929,10 @@ class TestGitBranchNameOverrideBash:
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
class TestGitBranchNameOverridePowerShell:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
|
||||
|
||||
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
||||
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
||||
@@ -1230,9 +1058,8 @@ class TestFeatureDirectoryResolution:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@requires_bash
|
||||
def test_fallback_to_branch_lookup(self, git_repo: Path):
|
||||
"""Without env var or feature.json, falls back to branch-based lookup."""
|
||||
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
||||
def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
|
||||
"""Without env var or feature.json, get_feature_paths now errors."""
|
||||
spec_dir = git_repo / "specs" / "001-test-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
@@ -1242,14 +1069,8 @@ class TestFeatureDirectoryResolution:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
@@ -1343,7 +1164,7 @@ class TestDescriptionQuoting:
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
"""Extension create-new-feature-branch.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo
|
||||
/ ".specify"
|
||||
@@ -1351,7 +1172,7 @@ class TestDescriptionQuoting:
|
||||
/ "git"
|
||||
/ "scripts"
|
||||
/ "bash"
|
||||
/ "create-new-feature.sh"
|
||||
/ "create-new-feature-branch.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
check_tool, is_git_repo, merge_json_files,
|
||||
check_tool, merge_json_files,
|
||||
get_speckit_version,
|
||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||
)
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
def test_utils_symbols_importable():
|
||||
assert callable(check_tool)
|
||||
assert callable(merge_json_files)
|
||||
assert callable(is_git_repo)
|
||||
|
||||
def test_get_speckit_version_returns_string():
|
||||
version = get_speckit_version()
|
||||
|
||||
Reference in New Issue
Block a user